MSWを活用したフロントエンドIntegrationテストのノウハウ
こんにちは!JX通信社のシニアエンジニアのSirosuzumeです。
通信を含むコンポーネントのテストや、Storybookの動作を確認する際、皆さんはどんなアプローチをしていますか?
私はMSWを使用して、通信処理をMockしてテストを行っています。
MSWを導入する以前は、通信をMockするためにコンポーネントのPropsでfetcherを渡すという方法をとっていました。
実際、この方法は特別なライブラリを必要とせず、シンプルでわかりやすい方法ですが、コンポーネントの親子関係が複雑になると、Propsのバケツリレーが発生しがちです。
MSWはnode環境、あるいはbrowser環境で通信をMockするためのライブラリであり、上記のようなPropsによる制御が不要になります。
JestやVitestといったテスト環境だけではなく、Storybookや開発環境でも使用することができるため、実際に動かしてUXを確認する際にも便利です。
この記事では、MSWの活用方法のうち、Node環境で実行するIntegrationテストの書き方について紹介します。
準備
テスト環境におけるMSWのライフサイクルを把握する
Node環境下でMSWを動かす場合、msw/browserではなくmsw/nodeを使い、setupServer関数を使用して通信のMock設定を行う必要があります。
基本の流れとしては以下のようになります。
- setupServer関数を使ってMockサーバーのインスタンスを作成する
- listenメソッドを使ってMockサーバーを有効化する(基本的にbeforeAllで呼び出す)
- 必要に応じてuseメソッドを使用して、ハンドラーを追加し、テストを実行する
- afterEachでresetHandlersメソッドを呼び出して、3で追加したハンドラーを無効化する
- afterAllでcloseメソッドを呼び出してMockサーバーを無効化する
公式ドキュメントではjest.setup.tsなどでグローバルのタイミングでセットアップを行うことを推奨しています。
しかし、私達のプロジェクトではMSWを使わないUnitテストのファイルも多いこと、Mock箇所は必要最小限に抑えたいという方針であるため、MSWの使用はテストファイルごとに行う方針でテストを記述しています。
この方針でMSWを使用する場合、以下のようなスニペットをリポジトリに追加しておくと便利です。
const server = setupMockServer(/* TODO: デフォルトのハンドラーを追加 */);
beforeAll(() => {
server.listen();
});
afterEach(()=> {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
予想外のリクエストをキャッチするように設定する
MSWのhandlerは第一引数に指定された文字列、正規表現およびメソッドにマッチしたリクエストをMockし、マッチしなかったものは通常通りの通信を行います。
これはブラウザでの確認時、まだデプロイされていないエンドポイントの一部だけをMockするといった用途には適した動作なのですが、テスト実行時に外部に向けて予期せぬリクエストが飛んでしまう可能性があります。
私達のプロジェクトでは、下記の例のようにsetupServerをラップした関数を用意し、キャッチできなかった全てのリクエストを404にしてしまうハンドラーを末尾に追加しています。(GraphQLのリクエストもPostリクエストであるため、このハンドラーでキャッチできます)
テスト時はmsw/nodeから直接setupServerをimportせず、この関数(setupMockServer)を使うようにすることで安全性を高めることができます。
import { type SetupServerApi, setupServer } from "msw/node";
export function setupMockServer(
...handlers: Array<RequestHandler>
): SetupServerApi {
return setupServer(
...handlers,
http.all(
"*",
() =>
new HttpResponse(null, {
status: 404,
statusText: "not found",
}),
),
);
}
// 自分の環境だけかもしれないが、setupServerを自動でimportしようとすると
// msw/nodeではなくmsw/lib/nodeが優先してimportされて、テストが失敗してしまうことがある。
// 副作用的だが、その問題への対処にもなっている
Integrationテストの書き方
フロントエンドでのテストでは、よくTesting Trophyという考え方が取り上げられます。大雑把に言うと、Integrationテストを最も厚くし、UnitテストやE2Eテストの数は、Integrationテストよりも少なくなっているのが理想的という指針です。
この指針は意識的にUnitテストを減らしたり、E2Eテストを減らすといったやり方で行うべきではありません。
MSWを活用してIntegrationテストを書いていくと、自然とこの指針に従ったテストができあがっていきます。
サーバーからのレスポンスにより変更した要素を確かめる
通信を含むコンポーネントのIntegrationテストで、最も基本となる形は「レスポンスに応じて要素が変化することの検証」になります。
コンポーネントがレンダリングされた後、非同期の処理を挟んで要素が変化する場合、要素の変化を待機する必要があります。
testing-libraryではこうした非同期処理や、useEffectによる要素の変更に対応するために、waitFor、findBy〜といったメソッドが用意されています。
findByは内部的にwaitForを利用しているUtilityな立ち位置の関数で、記述もこちらのほうが直感的でわかりやすいので、要素の変更が発生する場合は、なるべくfindByを使うようにしましょう。
例として、検索欄に入力された情報を元にユーザー一覧を表示するコンポーネントのテストを考えてみます。
it("検索したユーザーが表示される", async () => {
const user = userEvent.setup();
const handler = http.get("/users", (req) => {
return HttpResponse.json({ items: [generateMockUser({ name: "JX太郎" }] });
});
server.use(handler);
render(<UserList />);
const searchInput = screen.getByRole("textbox", { name: "検索欄" });
await user.type("JX太郎");
const button = screen.getByRole("button", { name: "送信" });
await user.click(button);
const userName = await screen.findByText("JX太郎");
expect(userName).toBeVisible();
});
上記の例は、まさしくハッピーパスといった感じのテストケースですが、通信を伴うコンポーネントは、多くの場合以下のような状態を持っています。
- 初期状態
- ローディング中
- データが存在する
- データが存在しない
- エラー発生時
これらのテストケースをカバーすると、Integrationテストは必然的にテストコード全体の中でも多くの割合を占めることになり、Testing Trophyの指針に従ったテストが書きやすくなります。
特に通信エラー時の表示などの異常系のテストは、手動やE2Eなどのブラウザー環境で実行するには何かと特殊な操作やMockが必要となるため難しくスキップされ、結果的に「想定通りに動いていなかった」といった事態が起こりやすいです。
MSWをつかったIntegrationテストでは、これらのテストをハッピーパスのテストと同程度の難易度で書くことがきます。
// 例 UserListコンポーネントのテスト
it("検索欄が空", () => {
render(<UserList />);
const searchInput = screen.getByRole("textbox", { name: "検索欄" });
expect(searchInput).toHaveValue("");
});
async function setupWithSearch(handler: RequestHandler) {
const user = userEvent.setup();
server.use(handler);
render(<UserList />);
const searchInput = screen.getByRole("textbox", { name: "検索欄" });
const button = screen.getByRole("button", { name: "送信" });
await user.type("JX太郎");
await user.click(button);
}
it("ローディング中の表示がされる", async () => {
const handler = http.get("/users", (req) => {
// 無限にローディング中の状態を続ける
await delay('infinite')
return HttpResponse.json({ items: [generateMockUser({ name: "JX太郎" }] });
});
await setup(handler);
const loading = await screen.findByText("now loading...", { exact: true });
expect(loading).toBeVisible();
});
it("データが存在する", () => {
// 省略
})
it("検索結果が0件"), () => {
// 省略
})
it("通信エラー時", () => {
// 省略
})
サーバーに送信したリクエストを検査する
コンポーネントのアウトプットというと、第一にHTML要素が考えられますが、サーバーに送信されるリクエストもアウトプットの一種とみなすことができます。
フロントエンジニアであれば誰もが「入力内容に対して、期待通りのAPIリクエストが送信されておらず、バグが発生していた」と言ったバグを生み出した経験があると思います。
MSWのハンドラー内にコールバックの関数を設定しておくと、サーバー側に送信されたリクエストを検証することができます。
it("検索欄に入力した内容がsearchParamsに反映される", async () => {
const user = userEvent.setup();
const handleSearchParams = jest.fn()
const handler = http.get("/users", (req) => {
// レスポンスを返す前にコールバックを呼ぶ
handleSearchParams(new URL(req.request.url).searchParams);
return HttpResponse.json({ items: [generateMockUser({ name: "JX太郎" }] });
});
server.use(handler);
render(<UserList />);
const searchInput = screen.getByRole("textbox", { name: "検索欄" });
const button = screen.getByRole("button", { name: "送信" });
await user.type("JX太郎");
await user.click(button);
await waitFor(() => expect(handleSearchParams).toBeCalledTimes(1));
expect(handleSearchParams).toBeCalledWith({ name: "JX太郎" });
});
MSWを使ったテストを書きやすくする環境作り
Integrationテストの数が多くなるぶん、テストコード自体の書きやすさや可読性、保守性等も重要になります。
MSWを使ったIntegrationテストでは、リクエストハンドラーの生成、レスポンスデータの生成が頻繁に発生します。
Mock用のデータ生成関数を用意する
MSWを使ったテストに限った話ではありませんが、サーバーからのレスポンスデータのMockを生成する関数を作成しておくと、テストコードの作成に取り組むときに非常に便利です。
私達のチームではAPI、レスポンスの型がきまったとき、モックデータの生成関数の作成も必須のタスクとしています。
以下は、ユーザー情報を生成する関数の例です。
// ファイル名はuser.mock.tsなど、通常のコードと区別できるファイル名にすると、lintの設定等で、mock.tsのimportを禁止するなどの対策を行うことができます
import type { User } from "./user"
// generateMock{モデル名}など、Mockデータ生成関数の命名規則は統一する
export function generateMockUser(override: Partial<User> = {}): User {
return {
id: "1",
name: "JX太郎",
age: 20,
...override,
};
}
ハンドラーの生成を簡単にする
通信を伴うコンポーネントのIntegrationテストを行う際、以下のようなテストケースが頻出することが多いです。
- 正常系
- レスポンスに対して想定通りの要素が表示される
- コンポーネントを操作したとき、サーバーに想定通りのリクエストが送信される
- SearchParamsが想定通りか
- PathParamsが想定通りか
- RequestBodyが想定通りか
- 異常系
- サーバーから既知のエラーが返ってきたとき、コンポーネントが想定通りの動作をする
- サーバーから未知のエラーが返ってきたとき、コンポーネントが想定通りの動作をする
fetchやaxiosなどを使った通信処理をコーディングする場合、たいていエンドポイントやメソッドを関数内に隠蔽した、関数を作成するのではないかと思います。
MSWハンドラーを作成するときも同じです。
エンドポイントとメソッドは固定で設定し、レスポンスだけを差し替えたハンドラーを作るのが便利です。
type Props = {
// Propsの型パズルを頑張れば、invalidなときだけ任意の型を設定するなどできる
response: JsonBodyType;
status?: number;
statusText?: string;
onPathParams?: (params: unknown) => void;
onRequestBody?: (body: unknown) => void;
onRequestSearchParams?: (searchParams: URLSearchParams) => void;
}
function buildMockGetUsersMswHandler(props: Props) {
return http.get("*/users", ({ req }) => {
props.onRequestSearchParams?.(new URL(req.request.url).searchParams);
props.onPathParams?.(req.params);
props.onRequestBody?.(await req.request.json());
return HttpResponse.json(props.response, {
status: props.status ?? 200,
statusText: props.statusText ?? "ok",
});
})
}
RESTApiであれば、ほとんどのテストケースで使用されるハンドラーは、上述の例のうち「エンドポイント」と「メソッド」だけの違いになります。もう一段階カリー化すれば、毎回ハンドラーを作成する手間が省けそうです。
以下の例は、エンドポイントとメソッドを引数に取り、REST API用のMSWハンドラーを返す関数を作成する例です。
type MswHttpHandlerBuilderProps = {
response: JsonBodyType;
status?: number;
statusText?: string;
onPathParams?: (params: unknown) => void;
onRequestBody?: (body: unknown) => void;
onRequestSearchParams?: (searchParams: URLSearchParams) => void;
};
type BuildMswHttpHandlerBuilderProps = {
path: Path;
method: keyof typeof http;
};
export function buildMswHttpHandlerBuilder({
path,
method,
}: BuildMswHttpHandlerBuilderProps) {
return (props: MswHttpHandlerBuilderProps): HttpHandler =>
http[method](path, async (req) => {
props.onRequestSearchParams?.(new URL(req.request.url).searchParams);
props.onPathParams?.(req.params);
props.onRequestBody?.(await req.request.json());
return HttpResponse.json(props.response, {
status: props.status ?? 200,
statusText: props.statusText ?? "ok",
});
});
}
// ハンドラー生成用関数を作成
export const buildGetUsersMswHandler =
buildMswHttpHandlerBuilder({
path: "*/users",
method: "get",
});
// テスト時に以下のようなハンドラー随時作成する
const handler = buildGetUsersMswHandler({
response: { items: [generateMockUser({ name: "JX太郎" })] },
});
まとめ
人間が使いやすいUI/UXを実現しようとするほど、コンポーネントは複雑化していまう運命にあると感じています。
複雑なコンポーネントをテストするには、相応に複雑なMockやStubが必要になり、コンポーネントをただ書くことよりも難易度が高くなります。
そのためIntegrationテストはE2EテストやUnitテストよりも難易度が高く、どうしても省略したい、回避したい、という感情が生まれてしまいがちです。
昨年、新しいメンバーがプロジェクトに加わった際、Integrationテストのノウハウを、言語化して伝える機会があり、自分でも「ああ、こう書けばいいんだ!」という発見が多くありました。
この記事が参考になった!MSWを活用してIntegrationテストを書き始めた!という人が一人でもいれば幸いです。
Discussion