Seamless API Mocking For Front End Development

23 November 202012 minute read
A serene landscape of stones balanced in a stack on a reflective surfaceA serene landscape of stones balanced in a stack on a reflective surface
Image by Ralf Kunze from Pixabay

One of the more common recurring pain points I've seen in front-end development workflows is API integration. APIs are, more often than not, a cornerstone of our web applications, a hard dependency, so it's not too surprising that they can also be a source of issues for front-end developers. Here are a few scenarios that I've regularly encountered on various projects:

  • Testing: how do we test our API integrations with confidence?
  • Incomplete APIs: we start building a feature, we're two days deep and we discover that the API isn't returning what we need, we're blocked! Can we continue before the API discrepancy is resolved?
  • Non-existent APIs: our team is swarming, we're in a time crunch, we need to ship this feature fast and API development hasn't started yet. Can we deliver a turnkey UI without the API?

I've tried a variety of solutions to these problems in the past but I was never entirely happy with the results. That is, until I discovered Mock Service Worker (MSW): a service worker based library designed to intercept and respond to, forward or modify API interactions at the network level (I know we're talking about service workers here but don't worry, MSW provides support for Node environments too). In this article we'll take a look at each of the above scenarios and how we can address them using MSW.

First Things First

I've created a small application with Create React App to demonstrate working solutions as we go. The app integrates with the public Open Library API, starting with a simple document search. The main branch has the complete, finished source code for this article, and there is a pull request containing commits for each scenario as we go. If you'd like to try these solutions out for yourself feel free to check out the base commit for the PR and code along!

NOTE: while the demo application is built with React, MSW itself is library/framework agnostic so don't let that hold you back if you're coming from other frameworks!

Testing

API integration testing could easily be a lengthy article in itself but let's just say that I've tried a range of techniques in the past and usually end up settling on dependency injection or http client mocking. These approaches have their merits in certain use cases, for example dependency injection can be particularly useful when unit testing shared components, but in the majority of cases what I've really wanted is to integration test critical application flows as close to the application's boundary as possible. In the case of our demo application, I want to know that when a user performs a document search the API is called and the response is handled correctly.

MSW gives us a mechanism to mock APIs at the network layer of our runtime environment, it's about as close as we can get to calling a real API while remaining within the bounds of our local test framework. It's even simple to set up. Start by installing msw:

yarn add -D msw

Since our tests aren't running in a browser we can't technically use service workers in our test suite. MSW has this covered by providing us with server-like functionality for use in Node environments. We'll use this to manage our API request mocking for testing:

// ./apiMocks/server.js
import { rest } from "msw";
import { setupServer } from "msw/node";

import searchResults from "./fixtures/documentSearch";

const handlers = [
  rest.get("https://openlibrary.org/search.json", async (_req, res, ctx) =>
    res(ctx.json(searchResults))
  ),
];

const server = setupServer(...handlers);

export { server };

This small code snippet packs a punch. We've set up a server, and defined a handler for the search API's URL, returning a JSON fixture when it is executed. A few notes:

  • Directory structure: as you can see I've added an apiMocks directory to the project to house the server.js file. I've also added a fixtures directory to contain all of the JSON examples for our API responses - this can make it easier to ensure consistency between our mocked responses and API contracts
  • Handlers: if you have experience with setting up server side routes in Express or similar then the signature of these handlers will look familiar. We define a handler to match on a given path with a given http verb and pass it a resolver to respond to, forward or modify any intercepted requests matching on that path and verb. In this case we're just returning our JSON fixture, but you'll see in the following scenarios that we can do much more with these resolvers
  • Example code: if you're referring to the demo app source you'll notice that the code is structured a little differently and a helper function is used to create the handlers, you can ignore that for now but a pattern like this is handy as you scale up your API integrations and need to override handlers for testing

Now we need to start up our server when we run our test suite. We also reset our server's handlers in between tests to clear out any overrides from the previous test:

// ./setupTests
...
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
...

Note: when I first started using MSW for testing I added wildcard handlers to highlight any unmocked API requests in my test runs. Since then MSW have added a configuration option to take care of this for us: onUnhandledRequest

And now for our tests (using React Testing Library's test utilities):

import { rest } from "msw";
import { server } from "./apiMocks/server";
import { errorResponse } from "./apiMocks/fixtures/documentSearch";

// MSW responds to our search request with the handler defined in `server.js` by default
it("searches for a document and displays the results", async () => {
  render(<App />);

  let searchField = screen.getByLabelText(/^search$/i);
  userEvent.type(searchField, "the secret life of cats");
  let searchButton = screen.getByRole("button", { name: /^search$/i });
  userEvent.click(searchButton);

  await screen.findByText(/^the secret life of cats by claire bessant$/i);
  screen.getByText(/^the secret life of cats by ralph reese$/i);
});

// And we can override the default handler in `server.js` to define responses case by case
it("searches for a document and displays an error message when the request fails", async () => {
  render(<App />);

  server.use(
    rest.get("https://openlibrary.org/search.json", async (_req, res, ctx) => {
      return res(ctx.status(500), ctx.json(errorResponse));
    })
  );

  let searchField = screen.getByLabelText(/^search$/i);
  userEvent.type(searchField, "the secret life of cats");
  let searchButton = screen.getByRole("button", { name: /^search$/i });
  userEvent.click(searchButton);

  await screen.findByText(/^something went wrong: internal server error$/i);
});

As you can see it didn't take much effort to set this up, and we now have a low-friction pattern for integration testing components with API dependencies, and a higher degree of confidence that our critical flow works than we would get from dependency injection or HTTP client mocking. Taking this a step further, you could even use the request handlers to validate outbound requests against the API contracts e.g. handling failure scenarios.

Incomplete APIs

So we have an app that can search The Open Library for documents and display matches. Now imagine that we have a requirement to display a link to purchase the document, and the URL should be provided by the search API, but it currently isn't. At this point I would advocate for resolving the API discrepancy before proceeding, but let's just say that we've talked it through with our team and agreed to continue with an updated API spec.

So what are our options? We could insert the missing field into the API response (e.g. in our promise's then block), we could hardcode the link into our component, or anything in between, but these approaches all involve polluting our application code with temporary patches, and that's without even considering our tests. So what if we could just modify the response before it hits our application code? Then we could build our feature as if we have the required API support, music to my ears. We'll set up MSW to do just that.

First we use MSW's CLI to generate a service worker in our public directory:

npx msw init ./public

Next we configure the worker - this is essentially creating a browser equivalent of the server.js file we use in our test setup:

// ./apiMocks/browser.js
import { rest, setupWorker } from "msw";

const handlers = [
  rest.get("https://openlibrary.org/search.json", async (req, res, ctx) => {
    // Forward the intercepted request on to the real API and capture the response
    const response = await ctx.fetch(req);
    const responseData = await response.json();

    // Patch the response before returning it to the caller
    responseData.docs.forEach((doc) => {
      if (doc.store_link === undefined)
        doc.store_link = `https://fake.store.com/${
          doc.isbn?.length ? doc.isbn[0] : ""
        }`;
    });
    return res(ctx.json(responseData));
  }),
];

export const worker = setupWorker(...handlers);

And finally we start up our service worker on app load (we generally don't want to run up this service worker in deployed environments so we'll load it conditionally):

// ./src/index.js
...

if (process.env.NODE_ENV === "development") {
  const { worker } = require("./apiMocks/browser");
  worker.start();
}

...

If you reload your app, run a document search and inspect the response you should see that a store_link field appears on every document. You can now update your fixture and write your feature and tests as if the field was there all along. The best parts? Because we've only patched the response if the field is missing, once the API is updated the correct values will flow through the handler, and our response patch is outside of our application code; the only cleanup remaining is to delete the patched handler from our worker.

Non-existent APIs

So what if you need to implement a UI for an API that hasn't been built yet? As long as you have a stable API schema (again, I'd advocate for having the API built first if possible) you can combine the above approaches to completely mock the API for both development and testing, without hacks or patches in your application code, and have your code practically ready to ship when the API is complete. All it takes is:

  1. Add a handler to server.js (for testing) and browser.js (for development), returning the desired data/fixture
  2. Write your tests and code to interact with the API as normal, following the API schema
  3. Once the API is available, simply delete the handler from browser.js!

Let's do a quick example. We'll add some basic "reading list" functionality to our document listing:

  1. Mark any documents that have been added to the reading list (GET the /reading_list and match it against the document list)
  2. Support adding a document to the reading list if it has not already been added (POST an id to /reading_list/add to add a document)

Yep, you guessed it, these APIs don't exist. We'll mock them out, and this time we'll go a step further and use the intercepted request's body to maintain a reading list in our mocks:

// ./apiMocks/browser.js
const readingList = { works: [] };

...

const handlers = [
  ...,
  // Mock API to fetch the Reading List
  rest.get(
    "https://openlibrary.org/reading_list",
    async (_req, res, ctx) => {
      return res(ctx.json(readingList));
    }
  ),
  // Mock API to add a Document to the Reading List
  rest.post(
    "https://openlibrary.org/reading_list/add",
    async (req, res, ctx) => {
      // We take the workId from the request body and add it to our stubbed reading list
      let { workId } = req.body;
      readingList.works.push(workId);
      return res(ctx.status(200), ctx.delay(500));
    }
  ),
];

That's all there is to it. Now we can call those APIs in our application code, write tests, and even observe documents being added to the reading list. As with the previous scenario, once the APIs are ready, we just delete these handlers from the worker and we're good to go!

Final Thoughts

Now that we've taken a look at a few common API mocking problem scenarios I hope that you can see how much value we can get out of the Mock Service Worker library for front-end development. The highlights for me are:

  • Non-invasive mocking: mock APIs outside of our application code without relying on patterns like dependency injection
  • Flexible and familiar request handlers: write server-like mocks without managing an actual HTTP server
  • Ease of use: very good developer ergonomics for setting up, defining and overriding API mocks
  • Great documentation: take a look, I don't think I've come across any undocumented features/configurations in the time I've been using MSW
  • Support for both REST and GraphQL APIs
  • Support for both Browser and Node environments
  • Direct control of mocked responses: we choose how to manage our response data, we could even use an in-memory database if we wanted

Next time you're considering how to mock API interactions give MSW a go!