Testing
@zimic/interceptor
is designed with testing in mind and provides utilities to give you confidence in your tests. Using
declarative assertions, you can write your
expectations in a readable way that makes it clear how many requests the application should make, what parameters they
should have, and what responses they will receive. If you need more specific assertions, the requests processed by the
interceptor are available in
handler.requests
.
Configuration
Configuring @zimic/interceptor
in a testing environment usually depends on your
method of intercepting requests and how
you are applying your mocks.
Interceptor lifecycle
@zimic/interceptor
interceptors need to be started before they can intercept requests. You can start an interceptor by
calling interceptor.start()
.
When an interceptor is running, you can declare mocks with
interceptor.<method>()
, where
<method>
is an HTTP method. To make sure that your tests do not affect each other, it's a good practice to clear the
interceptor between tests with
interceptor.clear()
.
After each test, you can check that all expected requests were made with
interceptor.checkTimes()
. This method will
throw an error if the number of requests or their parameters do not match the expectations you set with
handler.with()
and
handler.times()
.
After all tests are done, stop the interceptor with
interceptor.stop()
.
A common way to manage this lifecycle is to have a test setup file with hooks such as beforeAll
, beforeEach
,
afterEach
, and afterAll
. The specific implementation may vary depending on your test framework.
As an example, this is a simple setup using Jest or Vitest:
- Local interceptor
- Remote interceptor
beforeAll(async () => {
// Start intercepting requests
await interceptor.start();
});
beforeEach(() => {
// Clear interceptors so that no tests affect each other
interceptor.clear();
});
afterEach(() => {
// Check that all expected requests were made
interceptor.checkTimes();
});
afterAll(async () => {
// Stop intercepting requests
await interceptor.stop();
});
beforeAll(async () => {
// Start intercepting requests
await interceptor.start();
});
beforeEach(() => {
// Clear interceptors so that no tests affect each other
await interceptor.clear();
});
afterEach(() => {
// Check that all expected requests were made
await interceptor.checkTimes();
});
afterAll(async () => {
// Stop intercepting requests
await interceptor.stop();
});
Note that interceptor.clear()
and
interceptor.checkTimes()
are awaited in
the remote interceptor example, whereas this is not necessary for local interceptors.
This is because many operations in remote interceptors are asynchronous because they may involve communication with an interceptor server. This is different from local interceptors, which have mostly synchronous operations.
For more information on the available interceptor methods, check the
HttpInterceptor
API reference.
Managing concurrency
Running tests in parallel may cause your interceptors to start interfering with each other and result in flaky tests. How to avoid this depends on the type of interceptor you are using.
Local interceptors
With local interceptors, racing conditions between test workers are unlikely, because each interceptor affects only the thread it was created in (hence, the name local).
However, running more than one test at the same time in the same thread can lead to concurrency problems.
@zimic/interceptor
does not currently support this, so we recommend avoiding concurrent features like
test.concurrent
.
Instead, consider splitting your tests into smaller files. By doing this, your test framework can run them in parallel in separate threads, each with their own interceptors and without racing conditions.
Remote interceptors
Remote interceptors use an interceptor server to handle requests. They are especially useful when your application and your test runner run in separate processes, such as in end-to-end tests. If you are sharing the same interceptor server between multiple parallel test workers, you may run into racing conditions. In this case, some solutions include:
-
Using a path discriminator:
This is a way to make sure that each test worker uses a different path in the interceptor server, usually a worker index or identifier. In this strategy, each worker can declare its own mocks without risk of interfering with the others. Make sure that the application also uses the same path discriminator when making requests, so that they are handled by the correct interceptor. See this in practice in our Playwright example.
-
Applying default mocks before your tests start:
In this approach, you can start your interceptor server and run a script that populates a set of default mocks. When your tests start, the mocks will already be loaded and no racing conditions will occur. This is demonstrated in our Next.js App Router example. It's important that individual tests do not create new mocks that may override the default ones and affect others. Instead, declare the default mocks in such a way that they can be reused by all tests, such as with computed restrictions and computed responses.