Mock Service WorkerでテストごとにAPIのモックを変える
はじめに
テストコードでAPIのモックが必要なときにMock Service Worker
を使うとテストケースごとにハンドラーを上書きしたり追加したりできて便利だったのでその備忘録です。
環境構築
適当にベース環境を用意
ここではViteでTypeScriptが使える環境を用意します。
npm create vite@latest
Jestのセットアップ
パッケージのインストール
npm i jest @types/jest ts-jest -D
ts-jestの初期化
npx ts-jest config:init
/** @type {import('ts-jest').JestConfigWithTsJest} */
const config = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/*+(spec|test).+(ts|tsx|js)'],
};
export default config;
MSWのセットアップ
パッケージのインストール
npm install msw --save-dev
デフォルトのハンドラーを定義
REST用のハンドラー
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',
})
);
}),
];
GraphQL用のハンドラー
import { graphql } from 'msw';
export const handlers = [
graphql.query('GetUserInfo', (req, res, ctx) => {
return res(
ctx.data({
user: {
id: 0,
name: 'name1',
},
})
);
}),
];
setupServer()でリクエストのインターセプトレイヤーを確立する
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 などの) を拡張して動作します。
MSWの共通処理をJestのセットアップに追加する
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に追加する
/** @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)
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)
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
を使ってハンドラーを追加しています。
補足
setupServer
とserver.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())
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
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