Vitest で API 通信のモックはどうしてる?主要3パターン(vi.mock / MSW / Queryキャッシュ)を比較・使い分け
はじめに
Vitest で API 通信が絡んだコンポーネントのテストをするとき、vi.mock、MSW、TanStack Query のようなデータフェッチングライブラリの Query キャッシュを活用する手法があります。この記事では 3 つのモックパターンを比較し、どう使い分けるかを整理しました。
パターン 1: vi.mock で依存モジュールを差し替える
まずは vi.mock を使って依存モジュールを差し替えるパターンです。テスト対象となるコンポーネントはこちら。
import { useQuery } from "@tanstack/react-query";
import { fetchTimeline } from "../client/fetchTimeline";
type Post = {
id: string;
title: string;
};
export function Timeline() {
const { data = [] } = useQuery<Post[]>({
queryKey: ["timeline"],
queryFn: fetchTimeline,
});
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
export async function fetchTimeline() {
const response = await fetch("/api/timeline");
return response.json();
}
以下がテストコードです。vi.mock を使用して fetchTimeline を mock 関数に差し替えることで、API 通信を発生させないようにしています。
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Timeline } from "./Timeline";
vi.mock("../client/fetchTimeline", () => ({
fetchTimeline: vi.fn().mockResolvedValue([
{ id: "post-1", title: "Mocked Post" },
{ id: "post-2", title: "Another Post" },
]),
}));
function renderWithClient(ui: JSX.Element) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<QueryClientProvider client={client}>{ui}</QueryClientProvider>
);
}
describe("Timeline (vi.mock)", () => {
it("renders mocked posts", async () => {
renderWithClient(<Timeline />);
expect(await screen.findByText("Mocked Post")).toBeInTheDocument();
expect(screen.getByText("Another Post")).toBeInTheDocument();
});
});
vi.mock を使用した場合の注意点として、mock 対象となるファイルの import 文が変わったときに mock が失敗します。
- import { fetchTimeline } from '../client/fetchTimeline';
+ import { fetchTimeline } from '@/client/fetchTimeline'; // path aliasに変えただけ
このように import パスが実装とズレると mock が効かなくなるため、ファイル名やディレクトリ構造を変更する規模のリファクタリングには耐性が低いというデメリットがあります。
パターン 2: MSW で HTTP レイヤーを横取りする
次に MSW を使った時のテストコードです。
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
export const server = setupServer(
http.get("/api/timeline", () =>
HttpResponse.json([
{ id: "post-1", title: "Mocked Post" },
{ id: "post-2", title: "Another Post" },
])
)
);
import { afterAll, afterEach, beforeAll } from "vitest";
import { server } from "./msw/server";
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Timeline } from "./Timeline";
function renderWithClient(ui: JSX.Element) {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<QueryClientProvider client={client}>{ui}</QueryClientProvider>
);
}
describe("Timeline (MSW)", () => {
it("renders intercepted response", async () => {
renderWithClient(<Timeline />);
expect(await screen.findByText("Mocked Post")).toBeInTheDocument();
expect(screen.getByText("Another Post")).toBeInTheDocument();
});
});
MSW を使用した場合、API 自体を mock できるため、リファクタリング耐性の高いテストを書くことができます。一方で MSW の依存や setup が必要となるため、GET 系の API を軽く mock したいだけの場合は too much に感じるかもしれません。また、JSDOM を使用した Vitest のテストと Vitest Browser Mode を使用した Vitest のテストでは setup 関数が異なるため、元々 JSDOM を使用していたテストを Browser Mode で動かすと API の mock に失敗します(JSDOM 環境では msw/node の setupServer を使い、Browser Mode 環境では msw/browser の setupWorker を使用する必要があります)。
パターン 3: TanStack Query の queryClient.setQueryData でキャッシュを差し替える
TanStack Query を使用している場合、以下のように記述することで API の mock を行うことができます。
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Timeline } from "./Timeline";
function renderWithClient(ui: JSX.Element, client: QueryClient) {
return render(
<QueryClientProvider client={client}>{ui}</QueryClientProvider>
);
}
describe("Timeline (setQueryData)", () => {
it("renders cached posts", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(
["timeline"],
[
{ id: "post-1", title: "Mocked Post" },
{ id: "post-2", title: "Another Post" },
]
);
renderWithClient(<Timeline />, queryClient);
expect(await screen.findByText("Mocked Post")).toBeInTheDocument();
expect(screen.getByText("Another Post")).toBeInTheDocument();
});
});
queryClient.setQueryData を実行することでクエリキャッシュを前もって設定することができ、useQuery を実行した時にキャッシュが使われ、API通信が発生しなくなります。
これにより、ファイルやディレクトリの命名変更に耐性があり、複雑な setup なしで mock をすることができます。欠点として useMutation で発生した API 通信を mock することはできないため、props で渡して vi.fn などで mock するか、MSW を使用する必要があります。
番外編: そもそもコンポーネントで API 通信を発生させないパターン
補足としてコンポーネント内で API 通信を発生させず、すべての API 通信をトップレベルで行うことも考えられます。そうすることで mock 自体を考えなくてもよくなり、テストは容易になります。しかし、データコロケーションを行うことができなくなり、再利用性も落ちやすくなるというトレードオフがあります。
import { useQuery } from "@tanstack/react-query";
import { PermissionsBanner } from "../components/PermissionsBanner";
import { UsersList } from "../components/UsersList";
import { fetchPermissions } from "../client/fetchPermissions";
import { fetchUsers } from "../client/fetchUsers";
type User = {
id: string;
name: string;
};
type Permission = {
id: string;
label: string;
};
export function AdminDashboard() {
const { data: users = [], isLoading: usersLoading } = useQuery<User[]>({
queryKey: ["users"],
queryFn: fetchUsers,
});
const { data: permissions = [], isLoading: permissionsLoading } = useQuery<
Permission[]
>({
queryKey: ["permissions"],
queryFn: fetchPermissions,
});
if (usersLoading || permissionsLoading) {
return <p>Loading...</p>;
}
return (
<div>
<UsersList users={users} />
<PermissionsBanner permissions={permissions} />
</div>
);
}
type UsersListProps = {
users: User[];
};
export function UsersList({ users }: UsersListProps) {
return (
<section>
<h2>Users</h2>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</section>
);
}
type PermissionsBannerProps = {
permissions: Permission[];
};
export function PermissionsBanner({ permissions }: PermissionsBannerProps) {
return (
<section>
<h2>Permissions</h2>
<p>{permissions.map((permission) => permission.label).join(", ")}</p>
</section>
);
}
export async function fetchUsers() {
const response = await fetch("/api/users");
return response.json();
}
export async function fetchPermissions() {
const response = await fetch("/api/permissions");
return response.json();
}
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { UsersList } from "./UsersList";
describe("UsersList", () => {
it("renders the provided users", () => {
render(
// props で渡すだけなのでテストが簡単
<UsersList
users={[
{ id: "user-1", name: "Alice" },
{ id: "user-2", name: "Bob" },
]}
/>
);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
});
});
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PermissionsBanner } from "./PermissionsBanner";
describe("PermissionsBanner", () => {
it("renders permission labels", () => {
render(
<PermissionsBanner
permissions={[
{ id: "perm-1", label: "can:read" },
{ id: "perm-2", label: "can:write" },
]}
/>
);
expect(screen.getByText("can:read, can:write")).toBeInTheDocument();
});
});
おわり
数年前はトップレベルで API 通信をするという考え方が主流だったと思いますが、昨今では様々な方法で API 通信を mock することができるため、あまり気にする必要がなくなっているように感じます。今回紹介した vi.mock・MSW・Query キャッシュの 3 パターンを押さえておけば、リファクタリング耐性や初期コストなどの観点から最適な選択がしやすくなります。状況に応じて手段を使い分け、必要に応じてトップレベルでのフェッチに切り替える、という判断軸を持っておきたいところです。
Discussion