Using remote 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
. Interceptors with
type remote
use a dedicated interceptor server to handle requests. This
opens up more possibilities for mocking than
local interceptors, such as handling requests from
multiple applications. It is also more robust because it uses a regular HTTP server and does not depend on local
interception algorithms.
When to use remote HTTP interceptors
-
Development
Remote interceptors are useful if you want your mocked responses to be accessible by multiple applications (e.g. browser, other projects,
curl
). This is not possible with local interceptors, which only work in the application where they are defined. Remote interceptors allow you to set up a mock server, which is fully configurable and can be used to mock requests in your local development or testing environment. -
Testing
If you do not run your application in the same process as your tests, remote interceptors are the way to go to mock requests and verify how you application handles success and error responses. When using Cypress, Playwright, or other end-to-end testing tools, this is generally the case because the test runner and the application run separately. Complex unit and integration test setups might also benefic from remote interceptors, such as testing a server that is running in another terminal. Because remote interceptors do not rely on local interception, they are generally more robust and simulate real server behavior more closely.
Creating a remote 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.
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.
In the case of a remote interceptor, the baseURL
should point to an
interceptor server, which is configured by the interceptor to handle
requests.
import { createHttpInterceptor } from '@zimic/interceptor/http';
const interceptor = createHttpInterceptor<Schema>({
type: 'remote',
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.
Path discriminators
When using multiple remote interceptors connected to the same interceptor server, it is important to differentiate them
by using path discriminators. This is done by appending a suffix to the baseURL
of each interceptor and makes sure
that no interceptor interferes with another's requests.
This is especially useful if you have multiple interceptors mocking different services or APIs, or if you are running tests in parallel, each with its own interceptors.
Consider the following example:
import { createHttpInterceptor } from '@zimic/interceptor/http';
const authInterceptor = createHttpInterceptor<AuthSchema>({
type: 'remote',
baseURL: `http://localhost:3000/auth/${crypto.randomUUID()}`,
});
const notificationInterceptor = createHttpInterceptor<NotificationSchema>({
type: 'remote',
baseURL: `http://localhost:3000/notification/${crypto.randomUUID()}`,
});
Here, two remote interceptors are created, one for a fictional authentication service and another for a notification
service. Both interceptors use the same interceptor server, but they are differentiated by service with the paths
/auth
and /notification
. Moreover,
crypto.randomUUID()
is used to generate a unique
identifier for each interceptor, which makes sure that they don't interfere with each other if multiple instances are
running at the same time.
If you are using a setup like this, note that your application should use the same base URL as the interceptor when making requests, otherwise they may not be handled. A common strategy is to change an environment variable or similar to point to the base URL of the interceptor.
process.env.AUTH_SERVICE_URL = authInterceptor.baseURL;
process.env.NOTIFICATION_SERVICE_URL = notificationInterceptor.baseURL;
HTTP interceptor lifecycle
Starting an interceptor
To intercept requests, start the interceptor server using the
zimic-interceptor server start
CLI. It can
run as a standalone server:
zimic-interceptor server start --port 3000
Or as a prefix of another command, such as a test runner or a script:
zimic-interceptor server start --port 3000 --ephemeral -- npm run test
The command after --
will be executed when the server is ready. The flag --ephemeral
indicates that the server
should automatically stop after the command npm run test
finishes.
If you are exposing the server publicly, consider enabling authentication in the interceptor server.
Once the server is running, you can start the interceptor
interceptor.start()
. This is usually done in a
beforeAll
hook in your test suite.
beforeAll(async () => {
await interceptor.start();
});
During the start up, the interceptor will connect to the server and get ready to handle requests.
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 performed with interceptor.clear()
,
which resets the interceptor and handlers to their initial states.
beforeEach(async () => {
await 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(async () => {
await 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' }];
await 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...
});
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.
If you are using typescript-eslint
, a handy rule is
@typescript-eslint/no-floating-promises
. It checks promises
appearing to be unhandled, which is helpful to indicate missing await
's in remote interceptor operations.
If you need to access the requests processed by the interceptor, use
handler.requests
.
const handler = await 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
Interceptor server authentication
Interceptor servers can be configured to require interceptor authentication. This is strongly recommended if you are exposing the server publicly. Without authentication, the server is unprotected and any interceptor can connect to it and override the responses of any request.
To create an interceptor authentication token, use the
zimic-interceptor server token create
CLI:
zimic-interceptor server token create \
--name <token-name>
Then, start the server using the --tokens-dir
option pointing to the directory where the tokens are saved. The server
will only accept remote interceptors bearing a valid token.
zimic-interceptor server start --port 3000 \
--tokens-dir .zimic/interceptor/server/tokens
You can list the authorized tokens with
zimic-interceptor server token ls
.
Make sure to keep the tokens directory private. Do not commit it to version control or expose it publicly. Even though the tokens are hashed in the directory, exposing it can lead to security issues. If you are running the server inside a container, make sure to persist the tokens directory, such as in a volume. Otherwise, the tokens will be lost when the container is removed or recreated.
Once the server is running, remote interceptors can use the auth.token
option to provide a token.
import { createHttpInterceptor } from '@zimic/interceptor/http';
const interceptor = createHttpInterceptor<Schema>({
type: 'remote',
baseURL: 'http://localhost:3000',
auth: { token: '<token>' },
});
Replace <token>
with the token you created earlier. Interceptor tokens do not expire, so you can use the same token
for multiple interceptors. If you need to invalidate a token, use the
zimic-interceptor server token rm
CLI.