Skip to main content

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:

tests/setup.ts
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();
});
INFO: Remote interceptors are asynchronous

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:

  1. 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.

  2. 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.