Skip to main content

Using local interceptors

HTTP interceptors allow you to handle requests and return custom responses. Their primary use is to mock HTTP requests in development or testing environments, especially when the backend is unavailable or when you want to have more control over the responses.

In @zimic/interceptor, HTTP interceptors are available in two types: local (default) and remote. When an interceptor is local, Zimic uses MSW to intercept requests in the same process as your application. This is the simplest way to start mocking requests and does not require any server setup.

When to use local HTTP interceptors

  • Development

    Local interceptors are useful if you want to quickly mock requests for a single application in development, especially when the backend is not yet ready or when you want to test different scenarios without relying on a real server. In this case, interceptors allow you to set up mock responses and test your client-side code without depending on a backend service.

  • Testing

    If you run your application in the same process as your tests, local interceptors are a great way to mock requests and verify how you application handles success and error responses. This is common when using unit and integration test runners such as Jest and Vitest. Often, interceptors simplify the configuration of your test suites, because they allow you to easily simulate specific scenarios without needing to first set up a separate server, manage authentication, apply seed data, or handle other complexities o a real service.

Creating a local HTTP interceptor

To start using an HTTP interceptor, declare an HTTP schema using @zimic/http. The schema represents the structure of your API, including the paths, methods, request and response types.

schema.ts
import { HttpSchema } from '@zimic/http';

interface User {
id: string;
username: string;
}

type Schema = HttpSchema<{
'/users': {
GET: {
request: {
searchParams: { query?: string; limit?: number };
};
response: {
200: { body: User[] };
400: { body: { message?: string } };
401: { body: { message?: string } };
500: { body: { message?: string } };
};
};
};
}>;

With the schema defined, you can now create your interceptor with createHttpInterceptor. It takes the schema as a type parameter and returns an interceptor instance. The baseURL option represents the scope of the interceptor and points to the URL that your application will use to make requests.

import { createHttpInterceptor } from '@zimic/interceptor/http';

const interceptor = createHttpInterceptor<Schema>({
type: 'local', // optional
baseURL: 'http://localhost:3000',
});

You can also set other options, such as the interceptor type and how unhandled requests should be treated. Refer to the createHttpInterceptor API reference for more details.

HTTP interceptor lifecycle

Starting an interceptor

To intercept requests, an interceptor must be started with interceptor.start(). This is usually done in a beforeAll hook in your test suite.

beforeAll(async () => {
await interceptor.start();
});
INFO: Local interceptors in browsers

If you are using a local interceptor in a browser environment, you must first initialize a mock service worker in your public directory before starting the interceptor.

Clearing an interceptor

When using an interceptor in tests, it's important to clear it between tests to avoid that one test affects another. This is done with interceptor.clear(), which resets the interceptor and handlers to their initial states.

beforeEach(() => {
interceptor.clear();
});

Checking expectations

After each test, you can check if your application has made all of the expected requests with interceptor.checkTimes(). Learn more about how interceptors support declarative assertions to keep your tests clean and readable.

afterEach(() => {
interceptor.checkTimes();
});

Stopping an interceptor

After the interceptor is no longer needed, such as at the end of your test suite, you can stop it with interceptor.stop().

afterAll(async () => {
await interceptor.stop();
});

Mocking requests

You can now use the interceptor to handle requests and return mock responses. All paths, methods, parameters, requests, and responses are typed by default based on the schema.

test('example', async () => {
const users: User[] = [{ username: 'me' }];

interceptor
.get('/users')
.with({
headers: { authorization: 'Bearer my-token' },
searchParams: { query: 'u' },
})
.respond({
status: 200,
body: users,
})
.times(1);

// Run the application and make requests...
});
INFO: Local interceptors are synchronous

Many operations in local interceptors are synchronous because they do not involve communication with an external server. This is different from remote interceptors, which communicate with an interceptor server to handle requests and return responses.

If you need to access the requests processed by the interceptor, use handler.requests.

const handler = interceptor
.get('/users')
.with({
headers: { authorization: 'Bearer my-token' },
searchParams: { query: 'u' },
})
.respond({
status: 200,
body: users,
})
.times(1);

// Run the application and make requests...

console.log(handler.requests); // 1

console.log(handler.requests[0].headers.get('authorization')); // 'Bearer my-token'

console.log(handler.requests[0].searchParams.size); // 1
console.log(handler.requests[0].searchParams.get('username')); // 'my'

console.log(handler.requests[0].body); // null