Tools for testing with msw.js
This article shows two tools that help you write tests with the mock-service-worker library.
Motivation
The mock-service-worker library is a great tool to write mock-backends for test-cases, but I two things still bothered me.
- There is a certain amount of boilerplate-code that you need to write in each test.
- There is no easy way to assert requests being sent.
Boilerplate
When using mock-service-worker with jest
(or with vitest
), we must make sure
that tests can run independently of each other and that we clean up after tests.
So what we can do is define beforeEach
- and afterEach
-handlers in all our
test files that use the mock-api:
const server = setupServer(
rest.post("/api/login", (req, res, context) => {
/* ... */
}),
);
beforeEach(() => {
server.listen({ onUnhandledRequest: "error" });
});
afterEach(() => {
server.close();
});
Since we are using this in a lot of tests, it would make sense to extract a utility method here:
export function mockBackend(...handlers: RestHandler[]): void {
const server = setupServer(...handlers);
beforeEach(() => {
server.listen({ onUnhandledRequest: "error" });
});
afterEach(() => {
server.close();
});
}
We can now just call
mockBackend(rest.post("/api/login", (req, res, context) => {}));
in each of our test files.
Overriding a RestHandler
In a test file, we will probably use the same handlers in most tests. But
sometimes, you also need a different response, for example an error-response)
Mock-service-worker allows to override a RestHandler with the server.use(...)
method. With our methods, we do not have access to the server object.
We can just return the server
object from our function. In the test, we can
call server.use()
, but we also need to reset the handlers, if we want to
isolate tests from each other:
export function mockBackend(...handlers: RestHandler[]): {
server: SetupServerApi;
} {
const server = setupServer(...handlers);
beforeEach(() => {
server.listen({ onUnhandledRequest: "error" });
});
afterEach(() => {
server.resetHandlers();
server.close();
});
return { server };
}
You can then call server.use()
in a tests, and it will register a different
request-handler just for this test.
It may not be wise to just expose the msw-api from the setup-function. Maybe there will be features in msw later, that also need cleanup. And there is the danger of having to rewrite many tests because of API-changes. In Gachou, I exposed a “useHandlers”-function instead.
Asserting sent requests
In many cases, we use API mocks to create proper backend responses so that we can validate the state (i.e. the DOM) of our API. However, sometimes we also need to make sure that a correct request has been sent.
For example: When we write a test that creates a new user, we can display the new-user form, write a test that submits the form. At this point, the correct POST request needs to be sent to the backend. The test can verify that correct username is displayed in the UI afterwards. But it will probably not contain the password. In order to verify that, we need to record the requests being sent to the backend and see that our expected request has been sent.
msw and events
Mock-service-worker can register handlers for certain events (see msw-documentation):
server.events.on("request:end", (request) => {
/* This code will be executed when requests-processing finishes */
});
We can use this to collect all sent requests in a list.
Querying requests
I really like the query functions of the react-testing-library
. It generally
provides
6 different queries
for each kind of “needle”. For example, when searching for an element by its
title, you can getByTitle()
, getAllByTitle()
, queryByTitle()
,
queryAllByTitle()
, findByTitle()
and findAllByTitle()
.
Generally, the default variant expects a single element and will error on
multiple results. The *all
-variant will always return an array.
If no element is found,get*
will throw an error, query*
will return null,
find
will retry a couple of times with and then throw an error.
In Gachou, I decided to implement a QueryableList that implements those queries to search for a partial object in that list.
We can use that list to store requests, but in order for queries to work properly, we need to store JSON-like objects. The request objects exposed by msw are pretty low-level, but we can just extract the data that we need to query:
server.events.on("request:end", (request) => {
capturedRequests.add({
method: request.method,
pathname: request.url.pathname,
headers: request.headers.all(),
body: request.body,
});
});
In the same way the screen
is a global object in the react-testing-library
,
we export requests
from our module.
Putting it all together
We can add to the beforeEach
-handler code to clear the request-list before
each test and add captured requests during the test. In the afterEach
-handler
we need to unregister the request:end
event. The final code than looks like
this.
Code examples should give you an understanding of what I am trying to achieve and how it can be done. I haven’t tested the code in this article, it is not meant to be copied and pasted into your project. The license allows it (with attribution), but I mean to give you inspiration. Don’t forget to think for yourself, please.
export interface Queries<T> {
queryAll(needle: Partial<T>): T[];
getAll(needle: Partial<T>): T[];
findAll(needle: Partial<T>): Promise<T[]>;
query(needle: Partial<T>): T | null;
get(needle: Partial<T>): T;
find(needle: Partial<T>): Promise<T>;
}
const capturedRequests = new QueryableList<CapturedRequest>();
export const requests: Queries<CapturedRequest> = capturedRequests;
export function mockBackend(...handlerConfigs: RestHandler[]) {
const server = mySetupServer(...handlerConfigs);
beforeEach(() => {
server.listen({ onUnhandledRequest: "error" });
capturedRequests.clear();
server.events.on("request:end", (request) => {
capturedRequests.add({
method: request.method,
pathname: request.url.pathname,
headers: request.headers.all(),
body: request.body,
});
});
});
afterEach(() => {
server.events.removeAllListeners("request:end");
server.resetHandlers();
server.close();
});
return { server };
}
You can then use it in your code like this:
// Wait for a request to be sent:
await requests.find({
method: "PUT",
pathname: "/api/users/bob",
body: { password: "pw-bob" },
});
My complete code can be found here. Note that it looks a bit different from the solution in this article because there are other features implemented as well.
Avoiding frustration with leaking requests
Sometimes you run a test-suite and some tests fail. Then you try to look closer
and run the failing test on its own - and it passes. If the test failure is due
to a failure in the requests.find()
call, than it may well that you have
production code where requests are executed with a slight delay, or a
setInterval
or setTimeout
.
setInterval(() => fetchAndStoreUser(), 2000)
In the application, this is not problematic most of the item. But tests are very short-lived and the network-stack, even the mocked one, is a global entity.
In this example, the fetchAndStoreUser()
function will probably be executed,
even after the test is finished, causing requests to be sent when the next test
is already running. This can mess up the results of requests.find()
in the
following tests.
Spotting leaky requests
First of all, you can spot such a problem more easily, if you use different request bodies or query-parameters in each test. This does not solve the problem, though.
Solving leaky requests
All Frameworks that I know offer ways to clean up components when they are unmounted. Use this to clear the timeout or interval.
If the asynchronous code is buried deeper in classes or other functions, you should provide an API for canceling it in those classes. AbortController and AbortSignal are a good way to do that.
function updateUserAtIntervals(signal?: AbortSignal): void {
const interval = setInterval(() => fetchAndStoreUser(), 2000);
signal?.addEventListener("abort", () => clearInterval(interval));
}
const abortController = new AbortController();
updateUserAtIntervals(abortController.signal);
// Later
abortController.abort();
That way, you can pass an AbortSignal from you component under test down to the code that executes the requests, so you can stop it once the test is done.
Conclusion
In this post I have presented two tools that can make using mock-service-worker a lot easier.
I hope you can get some inspiration from this post and that it makes your testing-life easier too.