Open4

Next.jsとReact Nativeでテスト実装体験をできるだけ揃えるためのAPIモックラッパーの実装案

meijinmeijin

課題

マナリンクのフロントエンド環境はNext.jsとReact Nativeの2つある。
それぞれReactで実装していることから、開発者体験をできるだけ(不自然にならない範囲で)揃えることで恩恵を受けていきたい。

今回の論点はjest(vitest)×Testing LibraryによるUIテストコード実装の開発者体験である。
2024年11月現在、Next.jsではvitestを採用しており、またAPIのモックにmswを使っている。
一方、React Native on Expoアプリでは、vitestが非対応のためjestを用いているし、また、mswの導入を試みたところ、React Nativeアプリ上でのURLの扱いがポリフィル経由であることなどに由来する複数のエラーを踏んでしまい、解消が難しいと判断しAPIモックができていない。

上記から、8割方はテストコードの実装が一致しているが、肝心のAPIモックがズレていることで、実装時に特有の知識が必要となり、テストコード実装のハードルが上がってしまっている。

前提知識

  • Next.jsではPages Routerであり、かつSWRを用いてCSRすることを許容している画面
  • Aspidaを用いてAPIリクエストしている。これはNext.jsとReact Native共通の思想
  • 技術を古い方に無理やり合わせるのが好みではない。今回だと、Next.jsで無理にjestを使い続けたりmswの利用をやめることでReact Native側に合わせることはしない

現状

Next.jsにおけるAPIモック
createHandlerという独自で内製したAspidaとMSWを橋渡しするWrapperを使っている

const server = setupMockServer();

describe('HogeComponent', () => {
  test('Hogeリストが表示される', async () => {
    server.use(
      createHandler(apiClient.hoge.fuga, 'get', { response: hogeMock }),
    );

React NativeにおけるAPIモック
useAspidaSWRをモックすることでお茶を濁している

jest.mock('@aspida/swr', () => ({
  __esModule: true,
  default: jest.fn(),
}));
// ...
      jest
        .mocked(useAspidaSWR)
        .mockReturnValueOnce({ data: hogeMock, error: null })
meijinmeijin

提案

mockAspidaAPIという独自Wrapperを実装し、シグネチャをNext.jsとReact Nativeで揃える。
また、内部実装でNext.jsではMSWのモックを行い、React Nativeではjestのモックを行う。
これはAspidaを通してAPIを共通の型で管理していることで実現可能である。

Next.jsの実装

基本的に、いずれはReact NativeでもMSWが使えるのが理想と考え、Next.jsを起点にシグネチャを考案する。

import type { SetupServer } from 'msw/node';

export const mockAspidaAPI = <Api extends AspidaMethods<M> & { $path: () => string }, M extends MethodNames>(
  server: SetupServer,
  ...params: Parameters<typeof createHandler<Api, M>>
) => {
  server.use(createHandler(...params));
};

const createHandler = </** ジェネリクス型は割愛 */>(
  api: Api,
  method: M,
  options?: /** 型は割愛 */,
) => {
  // 割愛

  return rest[method](api.$path(), (req, res, ctx) => {
    return res(ctx.status(200), ctx.json(options?.response));
  });
};

これにより、テストコードでは以下のように実装できる。
MSWのサーバーが引数に必要になってしまうが、それ以外はいたって自然であるし、Aspidaの型を内部的にジェネリクス管理していることで、responseの型も型安全である。

const server = setupMockServer();

describe('HogeComponent', () => {
  describe('fooがNULLの場合', () => {
    test('Childrenを表示する', async () => {
      mockAspidaAPI(server, apiClient.hoge.status, 'get', {
        response: {
          foo: null,
          bar: 'string',
          baz: true,
        },
      });

      const component = render(
        <HogeComponent>
          <Text>hogehoge</Text>
        </HogeComponent>,
      );
meijinmeijin

React Nativeでの実装

React Nativeでは、シグネチャを揃えるために少々不自然な内部実装で無理やり実装する。
内部的に何も行わないsetupMockServerを定義することで、コピペビリティを高める。

export const setupMockServer: () => { use: (p: unknown) => void } = () => {
  return {
    use: () => void 0,
  };
};

テストコード視点で見れば、Webと全く同じ手順でAPIモックが完了できるようになった。

const server = setupMockServer();

// 中略

    test('Childrenを表示する', async () => {
      mockAspidaAPI(server, apiClient.hoge.status, 'get', {
        response: {
          foo: null,
          bar: 'string',
          baz: true,
        },
      });

      const component = render(
        <HogeComponent>
          <Text>hogehoge</Text>
        </HogeComponent>,
      );
export const mockAspidaAPI = <Api extends AspidaMethods<M> & { $path: () => string }, M extends MethodNames>(
  server: { use: (p: unknown) => void },
  ...params: Parameters<typeof createHandler<Api, M>>
) => {
  server.use(createHandler(...params));
};

const createHandler = // 型引数は上記同様なので割愛
) => {
  jest.mocked(useAspidaSWR).mockImplementation((...params) => {
    if (params[0].$path() !== api.$path()) {
      throw new Error('Invalid path');
    }
    // 割愛

    return {
      data: options.response,
      isValidating: false,
      error: options?.error ?? null,
      mutate: jest.fn(),
    };
  });
};

実装者にMSWをすでに導入していると誤解させるおそれがありつつ、言い換えれば今後MSWを導入したときに導入が容易であるともいえる。

meijinmeijin

結果

良くなったところ

  • Next.jsで実装したvitest/mswベースのテストコードを、コピペしてほとんど頭を使うことなくReact Native側でも動かすことができる
  • 今後React Nativeがvitest/mswにちゃんと対応したときに、腐敗防止層の内部だけのリファクタで済む可能性が高い

良くなっていないor今後の課題

  • コンポーネントのImport元のパスなど、利用するModuleの場所が必ずしもNext.jsとReact Nativeで一緒ではないので、Import文の書き換えは必要
  • WebではtoBeInTheDocument()だが、React NativeではtoBeOnTheScreen()となりここの置換作業は必要
  • React Native側はuseAspidaSWRのモック実装しかしていないのでGETのみ対応。POST/PUTなどは別途use-aspida-caller対応をするなどして工夫の必要あり
  • シグネチャを揃えるためにReact Native側にsetUpMockServerを使っているところが、人によってはむしろわかりにくいかもしれない

→ただし、書き換えする箇所が、誰でもできる程度に落とし込めたことが本提案の主張である。APIモックをプラットフォームごとに異なる実装にすると認知負荷が高いが、Import文の書き換えなどは単純作業である。

補足

  • Next.jsとReact NativeではDOM層が異なるためプロダクトコードの共通化は一般に困難であるが、テストコードに限れば、DOM層ではなく対象コンポーネントの呼び出しのみにとどまることが多いので懸念が小さい
  • 当然ながら、vitest/jestそれぞれでsetup時の実装をそこそこカスタマイズすることが前提