Next.js と MSW 高階関数
本稿では Next.js アプリ設計と同時に検討しておきたい、API Mocking の設計(MSW の活用テクニック)を紹介していきます。※ 解説のなかで jest を使いますが、ここは特別こだわりがあるわけではありません。
MSW で表現する API 群
MSW は Next.js アプリのローカル開発に役立ちます。任意の API を任意のレイヤーで、個別にインターセプト可能です。
- 「ブラウザー → API Routes」間でインターセプト
- 「API Routes → API 群」間でインターセプト
- 「getServerSideProps → API 群」間でインターセプト
また、自動テストに利用でき、フロントエンドの単体・結合テストが書きやすくなります。同一プロセスでサーバーレスポンスをモックするため、外部プロセスに依存しない、高速な自動テストを回すことができます。
MSW 高階関数とは
「MSW ハンドラーを生成する関数」を指します(これは以前、こちらの記事でも触れた内容です)例えば以下のcreateHandler
の様に、特定のテストケースで 400 レスポンスをさしこめます。
const server = setupMockServer(createHandler());
test("400", async () => {
// Intercept mock Error
server.use(createHandler(400));
// some test case
});
フロント・バックエンドを並行開発している場合、API 定義は往々にして変更が入ります。この様な関数でくるんでおくと、API 詳細がテストケースに漏れないため、修正耐性があがります。上記の高階関数内訳は以下の様になっていました。
export const createHandler = (status?: 200 | 400 = 200) =>
rest.post<Data, { id: string }, Data | Err>(path(), (req, res, ctx) => {
if (status === 400 || !req.body.title)
return res(
ctx.status(400),
ctx.json({ message: "Bad Request", status: 400 })
);
return res(ctx.json(req.body));
});
ここからは、この高階関数の発展させ、より便利にしたものを紹介していきます。
モック関数を仕込み payload を検証
このファクトリー関数に、jest.fn
のようなモック関数を渡せるよう手を加えておくと、どの様な payload をもって API が叩かれたのかを検証することができます。
const server = setupMockServer();
describe("モック関数を渡した場合", () => {
const mock = jest.fn();
test("期待値を引数にモック関数が呼ばれること", async () => {
server.use(createHandler({ mock }));
await fetcher();
expect(mock).toHaveBeenCalledWith(expectedData);
});
});
特に難しいことはなく、ハンドラー関数内部で RequestBody を引数にモック関数を実行しているだけです。さらに詳細を検証する必要がありそうなら、req
自体を引数にしても良いかもしれません。
export const createHandler = (args?: { mock?: jest.Mock<any, any> }) =>
rest.post<Data, { id: string }, Data | Err>(path(), (req, res, ctx) => {
args?.mock?.(req.body);
return res(ctx.json(req.body));
});
この仕組みを使うことで「UI の入力操作・送信の結果、payload が〇〇であること」といったテストケースを書くことができます。
設計にあわせた API Mocking
先日投稿した記事で「Service 非同期関数」というものを紹介しました。この非同期関数では、戻り値を{ data, err, status }
という一律規格に変換しており、プロジェクト向け Middleware との相性が良いことも紹介しました。この一律規格は、MSW 高階関数にも統合することが出来ます。
createHandler({ data, err, status, mock });
以下の様に、テストケースごとにレスポンスを差し込めるため、エッジケースの検証に役立ちます。型定義も一元管理されているため、テストコード側で型注釈をする必要がありません。
describe("data にモック値を渡した場合", () => {
const mockResponse = { message: "OK" };
test("モック値を引数にコールバックされること", async () => {
server.use(createHandler({ data: mockResponse }));
const res = await fetcher();
expect(res).toMatchObject(mockResponse);
});
});
describe("err にモック値を渡した場合", () => {
const mockResponse = { message: "NG", status: 400 };
test("モック値を引数にコールバックされること", async () => {
server.use(createHandler({ err: mockResponse }));
fetcher().catch((data) => {
expect(data).toMatchObject(mockResponse);
});
});
});
生成関数を更に追加し、実装速度をあげる
createHandler({ data, err, status, mock })
のような入力規格を定めたのはいいものの、ハンドラー定義ごとに毎回同じモック注入処理を書くのは面倒です。そこで、次のrestHandlerFactory
関数のような生成関数を更に追加します。入力規格を定めたことにより、モック注入処理を内部で一律適用可能となっています。ハンドラー定義時は、必要な最低限の情報を与えるのみで済むため、作業がはかどります。
export const createUserHandler = restHandlerFactory<UserInput, {}, UserData>(
"post", // リクエストメソッド
path(), // API Path
// 生成されるデフォルトハンドラーを定義
(req, res, ctx) =>
res(
ctx.status(201),
ctx.json({
user: { id: "0", name: req.body.name, email: req.body.email },
})
)
);
対になる Service 非同期関数
export const createUser = (data: UserInput, throwErr = false) =>
fetcher<UserData>(
path(),
{
method: "POST",
headers: defaultHeaders,
body: JSON.stringify(data),
},
UserInputSchema,
throwErr
);
restHandlerFactory 関数内訳
import {
PathParams,
ResponseComposition,
ResponseResolver,
rest,
RestContext,
RestRequest,
} from "msw";
import type { Err } from "./type";
export function restHandlerFactory<T, K extends PathParams, U>(
method: keyof typeof rest,
path: string,
resolver: ResponseResolver<RestRequest<T, K>, RestContext, U | Err>
) {
return (args?: {
data?: U;
err?: Err;
status?: number;
mock?: jest.Mock<any, any>;
}) =>
rest[method](
path,
(
req: RestRequest<T, K>,
res: ResponseComposition<U | Err>,
ctx: RestContext
) => {
if (args?.err) {
args?.mock?.(args.err);
return res(ctx.status(args.err.status), ctx.json(args.err));
}
if (args?.data) {
args?.mock?.(args.data);
return res(
ctx.status(args.status || method === "post" ? 201 : 200),
ctx.json(args.data)
);
}
args?.mock?.(req.body);
return resolver(req, res, ctx);
}
);
}
restHandlerFactory 関数単体テスト
import { setupMockServer } from "@/tests/jest";
import { restHandlerFactory } from "./mock";
describe("src/services/api.example.com/fetcher/mock.test.ts", () => {
describe("restHandlerFactory", () => {
const path = "/api/example";
const expectedData = { message: "test" };
const fetcher = () =>
fetch(path, {
method: "POST",
body: JSON.stringify(expectedData),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
}).then(async (res) => {
const data = await res.json();
if (!res.ok) throw data;
return data;
});
const createHandler = restHandlerFactory<{}, {}, { message: string }>(
"post",
path,
(_, res, ctx) => res(ctx.json(expectedData))
);
const server = setupMockServer();
describe("空引数でハンドラー関数を生成した場合", () => {
test("初期設定した fixture が返ってくること", async () => {
server.use(createHandler());
const res = await fetcher();
expect(res).toMatchObject(expectedData);
});
});
describe("モック関数を渡した場合", () => {
const mock = jest.fn();
test("bodyを引数にコールバックされること", async () => {
server.use(createHandler({ mock }));
await fetcher();
expect(mock).toHaveBeenCalledWith(expectedData);
});
});
describe("data にモック値を渡した場合", () => {
const mockResponse = { message: "OK" };
test("モック値を引数にコールバックされること", async () => {
server.use(createHandler({ data: mockResponse }));
const res = await fetcher();
expect(res).toMatchObject(mockResponse);
});
});
describe("err にモック値を渡した場合", () => {
const mockResponse = { message: "NG", status: 400 };
test("モック値を引数にコールバックされること", async () => {
server.use(createHandler({ err: mockResponse }));
fetcher().catch((data) => {
expect(data).toMatchObject(mockResponse);
});
});
});
});
});
まとめ
Next.js アプリ開発に限った話ではないですが、テスト戦略をはじめからアプリと密に設計することで、テストの書きやすさも変わります。MSW を使わないテスト手法も含め、いろいろなアプローチが検討できると良いように思います。
Discussion