🤖

Mock Service WorkerでテストごとにAPIのモックを変える

2023/01/02に公開

はじめに

テストコードでAPIのモックが必要なときにMock Service Workerを使うとテストケースごとにハンドラーを上書きしたり追加したりできて便利だったのでその備忘録です。

https://github.com/t-shiratori/msw-jest

環境構築

適当にベース環境を用意

ここではViteでTypeScriptが使える環境を用意します。

npm create vite@latest

https://vitejs.dev/guide/

Jestのセットアップ

パッケージのインストール

npm i jest @types/jest ts-jest -D

ts-jestの初期化

npx ts-jest config:init
jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} */
const config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/*+(spec|test).+(ts|tsx|js)'],
};

export default config;

https://kulshekhar.github.io/ts-jest/docs/getting-started/installation

MSWのセットアップ

パッケージのインストール

npm install msw --save-dev

デフォルトのハンドラーを定義

REST用のハンドラー

src/mocks/rest/handlers.ts
import { rest } from 'msw';
import { ORIGIN } from '../../const';

export const handlers = [
  rest.get(`${ORIGIN}/user`, (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        username: 'admin',
      })
    );
  }),
];

https://mswjs.io/docs/getting-started/mocks/rest-api

GraphQL用のハンドラー

src/mocks/graphql/handlers.ts
import { graphql } from 'msw';

export const handlers = [
  graphql.query('GetUserInfo', (req, res, ctx) => {
    return res(
      ctx.data({
        user: {
          id: 0,
          name: 'name1',
        },
      })
    );
  }),
];

https://mswjs.io/docs/getting-started/mocks/graphql-api

setupServer()でリクエストのインターセプトレイヤーを確立する

src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers as resetHandlers } from './rest/handlers';
import { handlers as graphqlHandler } from './graphql/handlers';

export const server = setupServer(...resetHandlers, ...graphqlHandler);

setupServerはNodeJS環境にリクエストインターセプトレイヤーを設置するメソッドです。

Service Worker API はブラウザ以外の環境では実行できないため、NodeJSにおけるリクエストインターセプトのサポートは、node-request-interceptor を使って実現しているようです。

setupServerには「サーバー」という言葉が使われていますが、実際はサーバーを構築するわけではなく、ネイティブのリクエスト発行モジュール (https や XMLHttpRequest などの) を拡張して動作します。

https://mswjs.io/docs/getting-started/integrate/node#configure-server

https://mswjs.io/docs/api/setup-server

MSWの共通処理をJestのセットアップに追加する

jest.setup.tsを作成

jest.setup.ts
import { server } from './src/mocks/server';

// Establish API mocking before all tests.
beforeAll(() => server.listen());

// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => server.resetHandlers());

// Clean up after the tests are finished.
afterAll(() => server.close());

これを書いておけば個別のテストファイルに毎回以下の処理を書かなくて済むので便利です。

  • 全てのテストを実行する前にインターセプトレイヤーを確立する
  • 各テスト終了時にハンドラーをリセットする
  • 全てのテストが終了したらインターセプトを停止してネイティブモジュールの拡張をクリーアップする

jest.config.jsに追加する

jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} */
const config = {
+ setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/*+(spec|test).+(ts|tsx|js)'],
};

export default config;

テスト対象を用意

例として以下のようなAPI呼び出しのクライアントを用意します。

type Args = {
  url: string;
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
  reqBody?: unknown;
};

export class CustomError extends Error {
  message: string;
  constructor() {
    super();
    this.message = 'Custom Error';
  }
}

export const fetcher = async ({ url, method = 'GET', reqBody }: Args) => {
  const response = await fetch(url, {
    method,
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(reqBody),
  });

  if (!response.ok) throw new CustomError();

  return response.json();
};

cross-fetchのインストール

nodejsのテスト環境でfetchを使えるようにするためにcross-fetchを入れます。

npm install --save cross-fetch

テストコード(REST)

src/__test__/rest/fetch.test.ts
import 'cross-fetch/polyfill';
import { rest } from 'msw';
import { ORIGIN } from '../../const';
import { CustomError, fetcher } from '../../fetcher';
import { server } from '../../mocks/server';

describe('fetcher', () => {
  describe('endpoint /user', () => {
    test('user: 200', async () => {
      const expectedValue = { username: 'admin' };

      const response = await fetcher({ url: `${ORIGIN}/user` });

      expect(response).toEqual(expectedValue);
    });
  });

  describe('endpoint /book', () => {
    describe('Get', () => {
      describe('Success', () => {
        test('book: 200', async () => {
          const expectedValue = { title: 'A Game of Thrones' };

          server.use(
            rest.get(`${ORIGIN}/book/:bookId`, (_, res, ctx) => {
              return res(ctx.json(expectedValue));
            })
          );

          const response = await fetcher({ url: `${ORIGIN}/book/1` });

          expect(response).toEqual(expectedValue);
        });
      });

      describe('Fail', () => {
        test.each`
          status
          ${`401`}
          ${`403`}
          ${`500`}
        `('book: $status', async ({ status }) => {
          server.use(
            rest.get(`${ORIGIN}/book/:bookId`, (req, res, ctx) => {
              return res(ctx.status(status));
            })
          );
          await expect(
            fetcher({ url: `${ORIGIN}/book/1` })
          ).rejects.toThrowError(new CustomError());
        });
      });
    });
  });
});

実行結果

 PASS  src/__test__/rest/fetch.test.ts
  fetcher
    endpoint /user
      ✓ user: 200 (18 ms)
    endpoint /book
      Get
        Success
          ✓ book: 200 (8 ms)
        Fail
          ✓ book: 401 (5 ms)
          ✓ book: 403 (3 ms)
          ✓ book: 500 (3 ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        2.169 s, estimated 3 s

${ORIGIN}/userのエンドポイントはデフォルトのハンドラー定義にあるのでsetupServerの実行で使えるようになっています。

${ORIGIN}/book/:bookIdのエンドポイントはserver.useを使ってハンドラーを追加しています。

テストコード(GraphQL)

src/__test__/graphql/fetch.test.ts
import 'cross-fetch/polyfill';
import { graphql } from 'msw';
import { ORIGIN } from '../../const';
import { CustomError, fetcher } from '../../fetcher';
import { server } from '../../mocks/server';

describe('fetcher', () => {
  describe('GetUserInfo', () => {
    test('200', async () => {
      const expectedValue = {
        user: {
          id: 0,
          name: 'name1',
        },
      };

      const reqBody = {
        query: `query GetUserInfo {
        user
      }`,
      };

      const response = await fetcher({
        url: `${ORIGIN}`,
        method: 'POST',
        reqBody,
      });

      expect(response.data).toEqual(expectedValue);
    });
  });

  describe('GetBookInfo', () => {
    describe('Success', () => {
      test('200', async () => {
        const expectedValue = {
          book: {
            id: 0,
            title: 'title1',
          },
        };

        const reqBody = {
          query: `query GetBookInfo {
          book
        }`,
        };

        server.use(
          graphql.query('GetBookInfo', (req, res, ctx) => {
            return res(ctx.data(expectedValue));
          })
        );

        const response = await fetcher({
          url: `${ORIGIN}`,
          method: 'POST',
          reqBody,
        });

        expect(response.data).toEqual(expectedValue);
      });
    });

    describe('Fail', () => {
      test.each`
        status
        ${`401`}
        ${`403`}
        ${`500`}
      `('$status', async ({ status }) => {
        const reqBody = {
          query: `query GetBookInfo {
          book
        }`,
        };

        server.use(
          graphql.query('GetBookInfo', (req, res, ctx) => {
            return res(ctx.status(status));
          })
        );

        await expect(
          fetcher({
            url: `${ORIGIN}`,
            method: 'POST',
            reqBody,
          })
        ).rejects.toThrowError(new CustomError());
      });
    });
  });
});

実行結果

 PASS  src/__test__/graphql/fetch.test.ts
  fetcher
    GetUserInfo
      ✓ 200 (21 ms)
    GetBookInfo
      Success
        ✓ 200 (8 ms)
      Fail
        ✓ 401 (9 ms)403 (4 ms)500 (4 ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        3.207 s

GetUserInfoクエリーはデフォルトのハンドラー定義にあるのでsetupServerの実行で使えるようになっています。

GetBookInfoクエリーはserver.useを使ってハンドラーを追加しています。

補足

setupServerserver.use

server.useを使うと、setupServerで初期化したあとで現在のインターセプトインスタンスにリクエストハンドラーを追加できます。これをruntime request handler(ランタイムリクエストハンドラー) と呼ぶようです。インターセプトインスタンスはserver.resetHandlers()でハンドラーを初期化できます。また、server.close()でインターセプトインスタンスを停止し、ネイティブモジュールの拡張をクリーアップできます。

それらを共通処理としてjestのセットアップに追加しておけば他のテストケースへの影響を気にせずに書けるので良いかなと思います。

import { server } from './mocks/server.js'
// Establish API mocking before all tests.
beforeAll(() => server.listen())
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => server.resetHandlers())
// Clean up after the tests are finished.
afterAll(() => server.close())

node#setup

It's recommended to configure API mocking as a part of your tests setup, so that your tests don't have to reference any mocking during their runs, focusing on testing what matters.

print-handlersを書いておくと現在アクティブなリクエストハンドラーのリストをログに出力してくれるので確認するのに便利です。

server.printHandlers();
% 省略 -t 'fetcher'
  console.log
    [rest] GET https://api.backend.dev/user
      Declaration: 省略/src/mocks/rest/handlers.ts:5:8

      at node_modules/msw/src/node/SetupServerApi.ts:147:15
          at Array.forEach (<anonymous>)

  console.log
    [graphql] query GetUserInfo (origin: *)
      Declaration: 省略/src/mocks/graphql/handlers.ts:4:11

      at node_modules/msw/src/node/SetupServerApi.ts:147:15
          at Array.forEach (<anonymous>)

 PASS  src/__test__/graphql/fetch.test.ts
  fetcher
    GetUserInfo
      ✓ 200 (18 ms)
    GetBookInfo
      Success
        ✓ 200 (6 ms)
      Fail
        ✓ 401 (4 ms)403 (3 ms)500 (4 ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        1.265 s, estimated 5 s

Discussion