Write UI Tests using an OpenAPI Spec to Auto Mock Requests

Photo by Reed Geiger on Unsplash

Write UI Tests using an OpenAPI Spec to Auto Mock Requests

Use msw and openapi-backend to automatically mock API endpoint

·

8 min read

Use msw to respond with example data from an openapi spec file.

Testing your frontend applications is a critical part of ensuring quality.

Without tests, it's hard to guarantee that your application is behaving the way you expect. Tests provide the confidence you need to add new features or update existing ones. The more closely your tests resemble how your application is used the better.

Your tests should make API requests the same way your application would.

That said, making real API requests in your tests can be expensive. You have to spin up a real instance of your server which can be slow and costly. Instead, it would be better to mock the API layer and return expected responses.

There are many ways to mock the API layer in your tests. The strategy I want to discuss is leveraging msw and openapi-backend to handle this.

We'll use these packages to automatically grab mock data from an openapi spec.

Walkthrough

I'm going to walk through setting up a repo with support for the above pattern.

If you'd prefer to just take a look at a finished version of the repo, you can check out my mocked ui integration testing repo.

Set up the npm project

npm init -y

Install dependencies

npm install --save-dev msw openapi-backend whatwg-fetch
  • msw: intercepts api requests and allows mocked responses
  • openapi-backend: a middleware tool to validate and mock OpenAPI definition files (e.g. Swagger Docs)
  • whatwg-fetch: polyfill for the fetch api so that we can call fetch in our tests

Set up Swagger

npm install --save-dev swagger-cli

This package validates and transforms openapi docs into .json files for consumption in your tests.

This means we need an openapi spec file. For the purpose of this post, we'll make a minimal openapi spec file.

Create a swagger.yaml file with the following content:

openapi: 3.0.0
info:
  title: Demo API
  description: A demo api to use in tests
  version: 1.0.0
paths:
  /users:
    get:
      summary: Returns a list of users.
      operationId: GetUsers
      responses:
        "200":
          description: A JSON array of user names.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string
              example: ["Tim", "Tam"]

It's extremely important that each endpoint has a unique operationId because it's used during the auto mocking process.

Once you have an openapi spec file, update your package.json to include the following script:

{
  "scripts": {
    "build": "swagger-cli validate swagger.yaml && swagger-cli bundle --outfile swagger.json swagger.yaml"
  }
}

Running the above script will give you a swagger.json file that can be used in our tests.

Set up Jest

npm install --save-dev @babel/core @babel/preset-env babel-jest jest

Why babel?

Internally jest uses babel to transpile (yes babel uses the term compile instead, but you get the gist) code which it doesn't know how to handle. In this case, we're using babel with jest to be able to use the module import syntax.

// jest will error out on this line without the babel magic
import fortyTwo from 'the-answer-to-life';

To support the above, make a babel.config.js file and paste in the following:

module.exports = {
  presets: [["@babel/preset-env", { targets: { node: "current" } }]],
};

Next, we need to make a slight tweak to the jest config to make sure we're running tests with jsdom which lets us simulate a real browser in our tests. In this case, we need to simulate the XMLHttpRequest object.

Create a jest.config.js file and paste in the following:

export default {
  // The default environment is 'node' which won't work in our case
  testEnvironment: "jsdom",
};

Build the Mocks

If you've been following along until now, you probably have setup fatigue.

Don't worry.

We're onto the code part of this pattern. For the sake of brevity, I'm going to keep everything in the same file. We'll build this file together, but you can go here if you just want to see the complete version of the mock setup.

It's a TypeScript file, so it may have a little extra, but you should get the idea.

Ok.

Make a mock-test.spec.ts file.

Import packages

// used to parse a swagger document and provide an API to query that document
import OpenAPIBackend from "openapi-backend";
// used to set up the endpoints we want to mock via msw
import { rest } from "msw";
// used to set up request interception via msw
import { setupServer } from "msw/node";
// the file from the beginning which is passed to the OpenAPIBackend package
import definition from "./swagger.json";
// Polyfill for fetch since Jest doesn't include it
import "whatwg-fetch";

Set up openapi-backend and msw integration

/**
 * Setup mocks via openapi-backend
 * View full set of options here: https://github.com/anttiviljami/openapi-backend/blob/master/DOCS.md#new-openapibackendopts
 */
// Used to simulate the base path for your real endpoints
const apiRoot = "/api";
const api = new OpenAPIBackend({ definition, apiRoot });

/**
 * MSW / OpenAPIBackend Integration
 */
api.register("notImplemented", (...[openAPICtx, _req, res, ctx]) => {
  // Since no requests are registered all endpoints in the
  // openapi spec file will fall through to this function.
  const { status, mock } = openAPICtx.api.mockResponseForOperation(
    openAPICtx.operation.operationId ?? ""
  );

  return res(ctx.status(status), ctx.json(mock));
});

api.register("notFound", (...[_openAPICtx, _req, res, ctx]) =>
  // If a request is made that isn't in the openapi spec file return a 404
  res(ctx.status(404))
);

/**
 * MSW Mocks
 */
const mockResponse = (...[req, res, ctx]) => {
  const { method, url, headers: headersRaw, body } = req;
  const path = url.pathname;
  const headers = headersRaw.all();

  const requestConfig = { method, path, headers, body };
  return api.handleRequest(requestConfig, req, res, ctx);
};

const mocks = [rest.get(/api/, mockResponse), rest.post(/api/, mockResponse)];

In this case, we're telling msw to intercept any get or post request that matches the regex /api/. This should catch every request. Once msw intercepts the request, we use the handleRequest method to hook into the openapi-backend package.

openapi-backend lets you register responses for endpoints in your openapi spec.

Instead of manually setting this up for every endpoint, we can use the notImplemented event as a catch-all. This event takes a callback function that's invoked anytime a request is intercepted. Using the first argument in the callback (openAPICtx) we can search for a matching endpoint in the document and retrieve example data using the operationId (this is why I mentioned this was important at the beginning).

Once we get the example data, we can return the data with an expected response code. This works for expected requests, but what about unexpected requests (i.e. requests not defined in your openapi spec)?

openapi-backend provides us with the notFound event which will be called if a matching request can't be found. If this happens, we return a 404 so that you're notified immediately in your tests.

Set up Tests

/**
 * Jest Setup
 */

// Passes the mocks and request handlers to msw for setup
const server = setupServer(...mocks);

beforeAll(async () => {
  // Initialize OpenAPIBackend
  await api.init();

  // Start MSW
  server.listen();
});

// Reset MSW mocks between tests
afterEach(server.resetHandlers);

// Stop MSW
afterAll(server.close);

// Request wrapper to make tests more concise
async function request(url, method = "GET") {
  const response = await window.fetch(url, {
    method,
    headers: { "Content-Type": "application/json" },
  });

  switch (response.status) {
    case 200:
      return await response.json();
    case 404:
      return { error: "No matching endpoint" };
    default:
      throw new Error(`Unexpected response ${response}`);
  }
}

The code above initializes openapi-backend and msw to set up the mocking. It also creates a small wrapper around the fetch api to format the responses of our requests.

Finally!

Tests.

Write Tests

test("should return a list of users based on the example in the swagger docs", async () => {
  // I make a request to the endpoint defined in the swagger document (including the apiRoot in the path)
  // The response we get back is the example defined
  const users = await request("/api/users");
  expect(users).toEqual(["Tim", "Tam"]);
});

test("should return a 404 if a request is made to an endpoint which is not in the swagger docs", async () => {
  // I make a request to an endpoint which is not defined in the swagger document (including the apiRoot in the path)
  // The response we get back is an error because our mock setup returned a `404`
  const error = await request("/api/some-other-endpoint");
  expect(error).toEqual({ error: "No matching endpoint" });
});

That's pretty much it.

The tests aren't that useful because they just test example data from the openapi spec. But, this can be useful if you're pairing this with proper UI testing with a package like React Testing Library.

Other Use Cases

While we set this up for our test suite, there's nothing stopping us from enabling this behavior in the browser while we're doing development.

One of the primary use cases of msw is to leverage a service worker in the browser to intercept / mock requests during development. Since openapi-backend is just JavaScript, it also works in the browser.

If you want to try it out, all you need to do is the following:

// Notice that we're pulling from the main msw package and not the node one
import { setupWorker } from "msw";

// This should be familiar
const worker = setupWorker(...mocks);

const setupDevServer = () => {
  // Start msw
  worker.start();
  // Initialize OpenAPIBackend
  api.init();
};

At this point, you can just call setupDevServer when you spin up your dev environment. Make sure to wrap the function call in a development mode environment check so that it's not included in your production code.

// Something like this should work
if (process.env.NODE_ENV !== "production") {
  setupDevServer();
}