Tools for testing with msw.js

October 07, 2022

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-docks):

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.


Profile picture

Written by Nils Knappmeier who has been a full-time web-developer since 2006, programming since he was 10 years old.