🗻

Next.js と MSW 高階関数

2022/06/16に公開

本稿では 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関数のような生成関数を更に追加します。入力規格を定めたことにより、モック注入処理を内部で一律適用可能となっています。ハンドラー定義時は、必要な最低限の情報を与えるのみで済むため、作業がはかどります。

src/services/api.example.com/users/mock.ts
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 非同期関数
src/services/api.example.com/users/index.ts
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