Documentation Next.js

はじめに

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 RoutesMSW httpハンドラー
外部APIMSW httpハンドラー
Server Actionsvi.mock + DI
Prismavitest-mock-extended
Next.js fetchvi.fn()

参考文献

円