🍢

Vitest Browser Mode と MSW

に公開

MSWにVitest Browser Modeとの統合があったので試します。

https://mswjs.io/docs/recipes/vitest-browser-mode/

サンプルアプリの作成

pnpm create viteでReactプロジェクトを作成してTanStack Queryを追加します

pnpm create vite
pnpm add @tanstack/react-query

TanStack Query でAPIを叩くコンポーネントを作ります

src/app-tanstack-query.tsx
import { getTodoList } from "./utils";
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";
import "./App.css";

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <main>
        <h1>MSW vitest browser mode</h1>
        <TodoList />
      </main>
    </QueryClientProvider>
  );
}

export default App;

function TodoList() {
  const { data: todoList } = useTodoList();

  if (!todoList) return <p>Now Loading...</p>;

  return (
    <>
      <h2>TodoList: total {todoList.total} todos</h2>
      <ul>
        {todoList.todos.map((item) => {
          return <li key={item.id}>{item.todo}</li>;
        })}
      </ul>
    </>
  );
}

function useTodoList() {
  return useQuery({
    queryKey: ["todo-list"],
    queryFn: async () => {
      return getTodoList();
    },
  });
}

APIはdummyjson.comを適当に叩きます

utils.ts
export const baseUrl = "https://dummyjson.com";

export const getTodoList = async (): Promise<TodoResponseData> => {
  const response = await fetch(`${baseUrl}/todos`);
  return await response.json();
};

export type TodoResponseData = {
  todos: Array<{
    id: number;
    todo: string;
    completed: boolean;
    userId: number;
  }>;
  total: number;
  skip: number;
  limit: number;
};

サンプルアプリ完成。Todoの合計が254となってますが後ほどこの箇所をMSWでモックしてテストでアサートします。

テストの環境を整える

テストのためのパッケージを追加します。ブラウザ環境にplaywrightを使います。playwrightは並列実行をサポートしているので公式でもおすすめしています。

pnpm add -D msw @vitest/browser vitest @vitest/browser vitest-browser-react playwright

pnpm exec playwright install

vitestを設定します。

vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    browser: {
      provider: "playwright",
      enabled: true,
      instances: [{ browser: "chromium" }],
    },
  },
});

MSWもドキュメントを参考に設定します。

handler.ts
import { http, HttpResponse } from "msw";

const dummyTodo = {
  todos: [
    {
      id: 1,
      todo: "洗剤を買う",
      completed: false,
      userId: 23,
    },
    {
      id: 2,
      todo: "トイレットペーパーを買う",
      completed: false,
      userId: 23,
    },
  ],
  total: 1000,
  skip: 10,
  limit: 100,
};

export const handlers = [
  // dummyjsonのモックを作成
  http.get("https://dummyjson.com/todos", () => {
    return HttpResponse.json(dummyTodo);
  }),
];
browser.ts
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);
test-extends.ts
/* eslint-disable no-empty-pattern */
import { test as testBase } from "vitest";
import { worker } from "./browser";

export const test = testBase.extend({
  worker: [
    async ({}, use) => {
      // Start the worker before the test.
      await worker.start();

      // Expose the worker object on the test's context.
      await use(worker);

      // Stop the worker after the test is done.
      worker.stop();
    },
    {
      auto: true,
    },
  ],
});

テストを書く

テスト環境ではMSWによるモックでTodoリストの合計数は1000で固定しているため1000でアサートしています。

app-tanstack-query.test.tsx
import { expect } from "vitest";
import { page } from "@vitest/browser/context";
import { render } from "vitest-browser-react";
import App from "./app-tanstack-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { test } from "./__test__/mocks/test-extends";

const testQueryClient = new QueryClient();

test("app tanstack query", async () => {
  render(
    <QueryClientProvider client={testQueryClient}>
      <App />
    </QueryClientProvider>
  );

  await expect
    .element(page.getByRole("heading", { name: "TodoList: total 1000 todos" }))
    .toBeInTheDocument();
});

pnpm test src/app-tanstack-query.test.tsx を実行すると、MSWでモックしたデータが表示されていることがわかります。テストもPASSします。

(今回のテストにはないですが)ボタンクリックなどの操作は通常@testing-library/user-eventパッケージのuserEventを使用しますが、このパッケージはブラウザーのイベントをシミュレートするので、Browser Modeでは実際のブラウザイベントを発行する@vitest/browser/contextパッケージのuserEventを使います。
Reactコンポーネントのレンダリングにvitest-browser-reactを使用していますが、これもブラウザ環境でレンダリングさせるために使います。

Suspenseとuseによるデータフェッチ

Suspenseとuseによるデータフェッチをしているコンポーネントのテストも書いてみます。
以下テスト対象のコンポーネントです。

app-suspense-and-use.tsx
import { use, Suspense } from "react";
import { type TodoResponseData, getTodoList } from "./utils";
import "./App.css";

function App() {
  const todoListPromise = getTodoList();

  return (
    <main>
      <h1>MSW vitest browser mode</h1>
      <Suspense fallback={<p>Now Loading...</p>}>
        <TodoList todoListPromise={todoListPromise} />
      </Suspense>
    </main>
  );
}

export default App;

function TodoList({
  todoListPromise,
}: {
  todoListPromise: Promise<TodoResponseData>;
}) {
  const todoList = use<TodoResponseData>(todoListPromise);

  return (
    <>
      <h2>TodoList: total {todoList.total} todos</h2>
      <ul>
        {todoList.todos.map((item) => {
          return <li key={item.id}>{item.todo}</li>;
        })}
      </ul>
    </>
  );
}

テストを書いてみます。

app-suspense-and-use.test.tsx
import { test } from "./__test__/mocks/test-extends";
import { expect } from "vitest";
import { page } from "@vitest/browser/context";
import { render } from "vitest-browser-react";
import App from "./app-suspense-and-use";

test("app suspense and use", async () => {
  render(<App />);

  await expect
    .element(page.getByRole("heading", { name: "TodoList: total 1000 todos" }))
    .toBeInTheDocument();
});

pnpm test src/app-suspense-and-use.test.tsx でテストを実行すると、データフェッチ中であることを示す「Now Loading...」表示されたままになります。

Reactのactで囲ってみます。

app-suspense-and-use.test.tsx
import { act } from "react";
import { test } from "./__test__/mocks/test-extends";
import { expect } from "vitest";
import { page } from "@vitest/browser/context";
import { render } from "vitest-browser-react";
import App from "./app-suspense-and-use";

test("app suspense and use", async () => {
  await act(() => {
    render(<App />);
  });

  await expect
    .element(page.getByRole("heading", { name: "TodoList: total 1000 todos" }))
    .toBeInTheDocument();
});

PASSしました。vitest-browser-reactのrender関数によるレンダリングは最初のレンダリングだけフラッシュするため初回以降はactで明示的にReactの更新を進めてあげる必要があります。

おまけ、@testing-library/reactでrenderしてみる

余談ですがnode.jsの環境で@testing-library/reactのrender関数を使った場合にSuspense+useがどうなるか試してみます。

vitest.config.tsを消してvitest.workspace.tsを作ります。src/app-suspense-and-user.node.test.tsxだけnode.js環境で動くようにしています。

vitest.workspace.ts
import { defineWorkspace } from "vitest/config";

export default defineWorkspace([
  {
    test: {
      include: ["./**/*.test.tsx"],
      name: "browser",
      browser: {
        provider: "playwright",
        enabled: true,
        instances: [{ browser: "chromium" }],
      },
    },
  },
  {
    test: {
      include: ["./src/app-suspense-and-user.node.test.tsx"],
      name: "unit",
      environment: "jsdom",
      setupFiles: ["./vitest-setup.js"],
    },
  },
]);

依存を追加します。

pnpm add -D @testing-library/react @testing-library/dom jsdom @testing-library/jest-dom

Node.js環境でMSWの環境は作らずにテストを試します。@testing-library/reactのrender関数でコンポーネントをレンダリングしています。

src/app-suspense-and-user.node.test.tsx
import { render } from "@testing-library/react";
import { expect, test } from "vitest";
import App from "./app-suspense-and-use";

test("app suspense and use", async () => {
  const { findByRole } = render(<App />);
  const heading = await findByRole("heading", {
    name: "TodoList: total 254 todos",
  });

  await expect(heading).toBeInTheDocument();
});

pnpm test src/app-suspense-and-user.node.test.tsx します。

actで囲む必要がありそうなのでactで囲みます。

src/app-suspense-and-user.node.test.tsx
import { render, act } from "@testing-library/react";
import { expect, test } from "vitest";
import App from "./app-suspense-and-use";

test("app suspense and use", async () => {
  const { findByRole } = await act(() => render(<App />));
  const heading = await findByRole("heading", {
    name: "TodoList: total 254 todos",
  });

  await expect(heading).toBeInTheDocument();
});

PASSしました。node環境で@testing-library/reactによりAPIフェッチを行うコンポーネントをrenderする場合もactで囲む必要があることがわかりました。

まとめ

一つ目に関してはよく調べてないので推測ですがvitestによるテスト内での非同期処理は以下の挙動をしているのかなと思います。

  • TanStack Queryによるデータフェッチ:データフェッチは自動的に行われるがアサートをretryし続けてデータフェッチが完了するとアサートが成功する
  • Suspense+useによるデータフェッチ:actによる明示的な更新をすることでデータフェッチが完了してアサートが成功する

Discussion