はじめに
Next.js App Routerでは、Server Components、Server Actions、Route Handlersなど多様なサーバーサイド機能があります。効果的なモック戦略を使って、これらを効率的にテストする方法を解説します。
MSW 2.0のセットアップ
インストール
npm install msw --save-dev
npx msw init public/ --save
ハンドラー定義
// mocks/handlers.ts
import { http, HttpResponse, delay } from 'msw';
// 型定義
interface User {
id: string;
name: string;
email: string;
}
interface Post {
id: string;
title: string;
content: string;
authorId: string;
}
// モックデータ
const users: User[] = [
{ id: '1', name: '田中太郎', email: 'tanaka@example.com' },
{ id: '2', name: '佐藤花子', email: 'sato@example.com' },
];
const posts: Post[] = [
{ id: '1', title: '記事1', content: '内容1', authorId: '1' },
{ id: '2', title: '記事2', content: '内容2', authorId: '2' },
];
export const handlers = [
// ユーザー一覧取得
http.get('/api/users', async () => {
await delay(100);
return HttpResponse.json(users);
}),
// ユーザー詳細取得
http.get('/api/users/:id', async ({ params }) => {
await delay(100);
const user = users.find((u) => u.id === params.id);
if (!user) {
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return HttpResponse.json(user);
}),
// ユーザー作成
http.post('/api/users', async ({ request }) => {
const body = await request.json();
const newUser: User = {
id: String(users.length + 1),
...body,
};
users.push(newUser);
return HttpResponse.json(newUser, { status: 201 });
}),
// 外部API(例:GitHub API)
http.get('https://api.github.com/users/:username', async ({ params }) => {
return HttpResponse.json({
login: params.username,
id: 12345,
avatar_url: 'https://avatars.githubusercontent.com/u/12345',
name: 'Mock User',
});
}),
// エラーシミュレーション
http.get('/api/error', () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}),
// 認証付きエンドポイント
http.get('/api/protected', async ({ request }) => {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return HttpResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
return HttpResponse.json({ secret: 'data' });
}),
];
ブラウザ用セットアップ
// mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
Node.js用セットアップ(テスト用)
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
App Routerでの初期化
開発環境でのモック有効化
// lib/mocks.ts
export async function initMocks() {
if (typeof window === 'undefined') {
// Node.js環境(Server Components、Server Actions)
const { server } = await import('../mocks/server');
server.listen({ onUnhandledRequest: 'bypass' });
} else {
// ブラウザ環境(Client Components)
const { worker } = await import('../mocks/browser');
await worker.start({
onUnhandledRequest: 'bypass',
});
}
}
// app/layout.tsx
import { initMocks } from '@/lib/mocks';
// 開発環境でのみモックを有効化
if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_MOCK_ENABLED === 'true') {
initMocks();
}
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>{children}</body>
</html>
);
}
環境変数
# .env.development
NEXT_PUBLIC_MOCK_ENABLED=true
Vitestでのテストセットアップ
設定ファイル
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['**/*.test.{ts,tsx}'],
globals: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
セットアップファイル
// vitest.setup.ts
import { beforeAll, afterAll, afterEach } from 'vitest';
import { server } from './mocks/server';
import '@testing-library/jest-dom/vitest';
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
コンポーネントテスト
Client Componentのテスト
// components/UserList.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { UserList } from './UserList';
describe('UserList', () => {
it('ユーザー一覧を表示する', async () => {
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('田中太郎')).toBeInTheDocument();
expect(screen.getByText('佐藤花子')).toBeInTheDocument();
});
});
it('ローディング状態を表示する', () => {
render(<UserList />);
expect(screen.getByText('読み込み中...')).toBeInTheDocument();
});
});
エラー状態のテスト
// components/UserList.error.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/mocks/server';
import { UserList } from './UserList';
describe('UserList - エラー状態', () => {
it('エラー時にエラーメッセージを表示する', async () => {
// テスト用にハンドラーを上書き
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ error: 'Server Error' },
{ status: 500 }
);
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('エラーが発生しました')).toBeInTheDocument();
});
});
});
Server Actionsのモック
依存性注入パターン
// lib/db.ts
export interface Database {
users: {
create: (data: { name: string; email: string }) => Promise<User>;
findMany: () => Promise<User[]>;
};
}
// 本番用
export const db: Database = {
users: {
create: async (data) => prisma.user.create({ data }),
findMany: async () => prisma.user.findMany(),
},
};
// actions/user.ts
'use server';
import { db as defaultDb, Database } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createUser(
formData: FormData,
deps: { db: Database } = { db: defaultDb }
) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const user = await deps.db.users.create({ name, email });
revalidatePath('/users');
return user;
}
Server Actionsのテスト
// actions/user.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createUser } from './user';
// revalidatePathをモック
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}));
describe('createUser', () => {
it('ユーザーを作成できる', async () => {
const mockDb = {
users: {
create: vi.fn().mockResolvedValue({
id: '1',
name: 'テスト',
email: 'test@example.com',
}),
findMany: vi.fn(),
},
};
const formData = new FormData();
formData.set('name', 'テスト');
formData.set('email', 'test@example.com');
const result = await createUser(formData, { db: mockDb });
expect(mockDb.users.create).toHaveBeenCalledWith({
name: 'テスト',
email: 'test@example.com',
});
expect(result.name).toBe('テスト');
});
});
Prismaのモック
Prismaモッククライアント
// lib/__mocks__/prisma.ts
import { PrismaClient } from '@prisma/client';
import { mockDeep, DeepMockProxy } from 'vitest-mock-extended';
export const prisma = mockDeep<PrismaClient>();
export type MockPrismaClient = DeepMockProxy<PrismaClient>;
// vitest.setup.ts
import { vi } from 'vitest';
vi.mock('@/lib/prisma', async () => {
const { prisma } = await import('@/lib/__mocks__/prisma');
return { prisma };
});
Prismaを使ったテスト
// services/user.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { prisma, MockPrismaClient } from '@/lib/__mocks__/prisma';
import { getUserById } from './user';
describe('getUserById', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('ユーザーを取得できる', async () => {
const mockUser = {
id: '1',
name: 'テスト',
email: 'test@example.com',
createdAt: new Date(),
updatedAt: new Date(),
};
(prisma as MockPrismaClient).user.findUnique.mockResolvedValue(mockUser);
const result = await getUserById('1');
expect(result).toEqual(mockUser);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: '1' },
});
});
});
Storybookでのモック
// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon';
import { handlers } from '../mocks/handlers';
initialize();
const preview = {
parameters: {
msw: {
handlers,
},
},
loaders: [mswLoader],
};
export default preview;
// components/UserList.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { http, HttpResponse } from 'msw';
import { UserList } from './UserList';
const meta: Meta<typeof UserList> = {
title: 'Components/UserList',
component: UserList,
};
export default meta;
type Story = StoryObj<typeof UserList>;
export const Default: Story = {};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users', async () => {
await new Promise((resolve) => setTimeout(resolve, 10000));
return HttpResponse.json([]);
}),
],
},
},
};
export const Error: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users', () => {
return HttpResponse.json({ error: 'Error' }, { status: 500 });
}),
],
},
},
};
export const Empty: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users', () => {
return HttpResponse.json([]);
}),
],
},
},
};
Next.js fetchのモック
// lib/fetch.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('fetch wrapper', () => {
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
});
it('APIからデータを取得できる', async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: 'test' }),
});
const response = await fetch('/api/data');
const data = await response.json();
expect(data).toEqual({ data: 'test' });
});
});
まとめ
| 対象 | モック方法 |
|---|---|
| API Routes | MSW httpハンドラー |
| 外部API | MSW httpハンドラー |
| Server Actions | vi.mock + DI |
| Prisma | vitest-mock-extended |
| Next.js fetch | vi.fn() |