【Webフロントエンド】テスト自動化のガイドラインを考えてみる
はじめに
この記事では、テストの主要なパターンとテクニックを網羅することに重点を置いています。そのため、プロダクションコードは必要最低限の説明に留め、テストコードを中心に記載する構成としています。
技術スタックは以下です:
Next.js, React, TypeScript, Vitest, Testing Library, MSW, Storybook, Playwright
ディレクトリ構成
多くのテストコードはプロダクションコードと1対1の関係を持つため、両方のコードを同じディレクトリに配置します。これによりテストの存在が認識しやすくなるため、__tests__
のように別ディレクトリに分けるよりも適切だと考えます。
例外としてE2Eテストは、プロダクションコードとテストコードの間に1対1の関係がないため、専用の別ディレクトリを用意します。ここでは採用しませんが、E2E専用リポジトリを作るという運用方法もあります。
テスト構造
テストは以下のような構造で書きます。
// describeなしの場合 test を使用
test(<テストケース名>)
// describeありの場合 it を使用
describe(<テストスイート名(省略可)>, () => {
it(<テストケース名>) // Playwrightには itがないので testのみ
})
describe(<ファイル名>, ...)
は、書かなくても自明なので不要です。
テストケース名は、なるべく非開発者にもわかるくらいのシンプルな言い回しで書いてください。事実をそのまま伝えるのが重要です。英語を第一言語とするメンバーがチームにいる場合は、英語で書くことをお勧めします。
AAAパターン
テストコードは、準備(Arrange)、実行(Act)、確認(Assert)の3つのフェーズを意識して書くと読みやすくなります。コード量が多い場合、各フェーズの前に// Arrange
といったコメントを入れるのも有効な方法です。
準備フェーズ
テストに必要なデータやオブジェクト、モックなどをセットアップします。このフェーズは一般的に一番行数が多くなりますが、各テストケースに共通する部分は、モジュール化したりフィクスチャで切り出したりすることで、テストコードをシンプルに保てます。一方で、共通化し過ぎると、テスト単独で見た時、何が書かれているか分かりにくくなってしまうのでバランスには気をつけましょう。
実行フェーズ
テスト対象の処理を実行するフェーズです。基本的には一行で書けるのが理想的です。複数の処理が必要な場合は、それらを一つの関数にまとめることを検討してください。一つの関数にまとめられない場合は、テストの観点が複数混在している可能性があります。その場合は、テストケースを分割するか、プロダクションコードのリファクタリングを検討する必要があるかもしれません。
確認フェーズ
実行結果が期待通りかを検証します。一つのテストケースでは、一つの振る舞いに焦点を当てるようにしますが、その振る舞いを確認する処理は複数でも構いません。
結合テスト、E2Eテストについて
これらのテストでは、一連の処理の流れを確認する性質上、複数の実行フェーズや確認フェーズを繰り返すことが自然です。この場合でも、テストの範囲と意図を明確に記述することで、可読性を確保できます。
関数単体テスト
まずはシンプルな例として「消費税計算の関数」をテストします。このテストを通じて、取るに足らないテストの説明もします。
calculateTax.ts
import { z } from 'zod'
export const taxInputSchema = z
.number()
.int()
.positive()
.describe('Price before tax')
export type TaxInput = z.infer<typeof taxInputSchema>
import { taxInputSchema, type TaxInput } from '@/schemas/tax'
export const calculateTax = (price: TaxInput) => {
const validatedPrice = taxInputSchema.parse(price)
const taxRate = 0.1
const tax = Math.floor(validatedPrice * taxRate)
return tax
}
// 悪いテスト
test('calculates 10% tax on price and rounds down decimal places', () => {
const price = 1000
const taxRate = 10
// 実装と同じロジックを別の書き方にしただけ
const expected = parseInt((price * (taxRate / 100)).toString())
expect(calculateTax(price)).toBe(expected)
})
このテストでは、プロダクションコードのロジックがテストに漏れ出しています。全く同じ書き方なら無意味であるとすぐに気づけますが、今回のように同じロジックを別の書き方にしただけのテストも同様に価値がありません。このようなテストを取るに足らないテストと言います。
// 良いテスト
test('tax for 1000 yen item is 100 yen', () => {
expect(calculateTax(1000)).toBe(100)
})
このテストでは、期待値を用いて関数の挙動を検証しています。これにより、関数の振る舞いを明確に検証できます。
悪いテストの例は、テストの目的が本来の意義を失い、テストを通すことやカバレッジ向上だけに意識が向いてしまう際に陥りがちなミスです。テストを書く際には、そのテストにどのような意味があるのかを考えることが重要です。
UIコンポーネント単体テスト
ガチャのような場面でランダムにアイテムを選択するコンポーネントのテストを考えます。このコンポーネントでは、生成される乱数に応じて以下のアイテムを選択します:
アイテム | 乱数の範囲 |
---|---|
Common Potion | 0以上0.5未満 |
Uncommon Shield | 0.5以上0.8未満 |
Rare Sword | 0.8以上1.0以下 |
乱数は Math.random
によって生成されます。このまま使用するとフレーキーテストになってしまうため、Math.random
をモックします。ここでは userEvent と spyOn を活用して、ユーザー操作の再現と乱数の制御を実現し、境界値テストをパラメタライズドテストの形式で実装します。
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import RandomItemPicker from ".";
afterEach(() => {
// モックした関数を元に戻す
vi.restoreAllMocks();
});
const testCases = [
{ randomValue: 0.49, expectedItem: "Common Potion" },
{ randomValue: 0.5, expectedItem: "Uncommon Shield" }, // 境界値
{ randomValue: 0.51, expectedItem: "Uncommon Shield" },
{ randomValue: 0.79, expectedItem: "Uncommon Shield" },
{ randomValue: 0.8, expectedItem: "Rare Sword" }, // 境界値
{ randomValue: 0.81, expectedItem: "Rare Sword" },
];
test.each(testCases)(
"when random value is $randomValue should get $expectedItem",
async ({ randomValue, expectedItem }) => {
// Arrange
vi.spyOn(Math, "random").mockReturnValue(randomValue);
render(<RandomItemPicker />);
const button = screen.getByRole("button", { name: /pick an item/i });
// Act
await userEvent.click(button);
// Assert
expect(screen.getByText(`You got: ${expectedItem}`)).toBeInTheDocument();
}
);
✓ when random value is 0.49 should get 'Common Potion'
✓ when random value is 0.5 should get 'Uncommon Shield'
✓ when random value is 0.51 should get 'Uncommon Shield'
✓ when random value is 0.79 should get 'Uncommon Shield'
✓ when random value is 0.8 should get 'Rare Sword'
✓ when random value is 0.81 should get 'Rare Sword'
それではテスト技法の確認です。
ユーザー操作
userEvent
は、ユーザーのインタラクションを実際に近い形で再現するためのツールです。このテストケースではシンプルなボタンのクリック操作を再現していますが、ホバーやキーボード入力など、よりリアルなユーザー操作をシミュレートできる点が特徴です。
参考: 低レベルのイベントをトリガーし最小限のイベントをディスパッチする
fireEvent
でしかサポートしていない機能を除いて、基本的にuserEvent
の使用が推奨されています。
フレーキーテスト防止のためのモック活用
spyOn
を使用することで、オブジェクトの一部の関数をモックできます。今回は Math.random
をモックし、特定の乱数を返すように設定しています。テストでは、環境に依存する値が原因で結果が不安定になることを防ぐために、それらをモックで固定することが重要です。他にも代表的な例として、現在時刻に依存する処理が挙げられます。この場合、実行時の環境によって結果が変わらないように、モックを使って現在時刻を固定する必要があります。
境界値テスト
このコンポーネントでは、条件式の評価結果が閾値によって変化するため、境界値テストを行うことが重要です。境界値テストでは、「境界直前」「境界そのもの」「境界直後」のすべてを網羅することで、ロジックが正しく動作することを確認します。
パラメタライズドテスト
境界値テストでよく併用されるのがパラメタライズドテストです。パラメタライズドテストとは、複数の入力データを使って同じテストケースを繰り返し実行する手法です。テストコードの重複を減らし、異なる条件での動作確認を効率的に行えます。
カスタムフックテスト
カウンターのクリックを 300ms の Debounce で制御し、連続クリックを 1回の APIリクエストにまとめるカスタムフックの単体テストについて考えます。
カスタムフックのテストでは、renderHook を使用してフックを直接実行し、フック内の状態更新や副作用を検証します。また、Debounce の動作を適切にテストするために、vi.useFakeTimers を使用してタイマーをモック化し、時間の経過を制御します。
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
import { postCount } from '@/services/client/Counter';
import { Mock } from 'vitest';
// postCount関数をモック関数に置き換え
vi.mock('@/services/client/Counter', () => ({
postCount: vi.fn(),
}));
describe('useCounter', () => {
beforeEach(() => {
vi.clearAllMocks();
// 現在時刻をモック(setTimeout等をコントロール可能に)
vi.useFakeTimers();
});
afterEach(() => {
// 現在時刻のモックを元に戻す
vi.useRealTimers();
});
it('should debounce multiple clicks and send accumulated count', async () => {
(postCount as Mock).mockResolvedValue({ ok: true });
const { result } = renderHook(() => useCounter({
initialCount: 0
}));
// 状態を更新するには act で囲う
act(() => {
result.current.handleCountClick();
result.current.handleCountClick();
result.current.handleCountClick();
});
expect(result.current.count).toBe(3);
// デバウンス時間(300ms)を進める
act(() => {
vi.advanceTimersByTime(300);
});
// カウント数が3で、APIが1回コールされたことを確認
expect(postCount).toHaveBeenCalledWith(3);
expect(postCount).toHaveBeenCalledTimes(1);
});
});
今回は単体テストのため、API をコールする関数には Vitest のモック関数を使用しましたが、結合テストでよく使われる MSW を使用しても問題ありません。
MSW
ログインユーザーのプロフィールを取得するAPIをモックにして結合テストを行います。テストケースごとにHTTPレスポンスステータスコードを切り替えたいため、MSWを選択します。
ここでは、MSWの使い方やテストの準備、モックハンドラーとStoryオブジェクトの使い回しなどを解説していきます。
まず大まかなディレクトリ構成は以下の通りです。
src/
├── app/
│ ├── api/
│ │ ├── auth/
│ │ │ └── [...nextauth]/
│ │ │ └── route.ts # next-authの認証ルート処理
│ │ └── my/
│ │ └── profile/
│ │ └── route.ts # ユーザーのプロフィール取得API
│ │
│ └── components/
│ └── providers/
│ └── LoginUserInfo/ # ログインユーザー情報の管理
│
├── middleware/
│ └── withAuth.ts # 認証確認ミドルウェア
│
└── services/
├── server/
│ └── MyProfile/ # Prismaを使用してプロフィール情報を取得
│
└── client/
└── MyProfile/
├── fetcher.ts # API経由でプロフィール情報を取得する関数
└── __mock__/
├── msw.ts # モックハンドラー
└── fixture.ts # フィクスチャ
それではテストに関わる部分を説明していきます。
フィクスチャ
フィクスチャとは、各テストケースが実行される前に決められた(fixed)状態になっている必要のあるオブジェクトのことです。ここではAPIレスポンスの固定値です。
import type { MyProfileData } from '@/services/server/MyProfile';
export const mockMyProfileData: MyProfileData = {
id: 'xxxxxxxxxxxxxxxx',
email: 'john@example.com',
name: 'john'
};
モックハンドラー
リクエスト処理を行う関数(handleGetMyProfile
)を用意します。このようにモックハンドラーを高階関数に切り出すことで Storyオブジェクトでも使い回すことが可能です。
import { HttpResponse, http } from 'msw'
import { path } from '..'
import { mockMyProfileData } from './fixture'
type HandleGetMyProfileOptions = {
mock?: () => void
status?: number
response?: typeof mockMyProfileData
}
export function handleGetMyProfile(options?: HandleGetMyProfileOptions) {
return http.get(path, () => {
options?.mock?.()
if (options?.status) {
return new HttpResponse(null, {
status: options.status
})
}
return HttpResponse.json(
options?.response ?? mockMyProfileData,
{
status: 200
}
)
})
}
export const handlers = [handleGetMyProfile()]
Vitestでモックハンドラーを使い回す
handleGetMyProfile
を使ってプロフィール表示を出し分けます。
まず、モックサーバー作成時の共通処理をまとめます
import type { RequestHandler } from "msw";
import { setupServer } from "msw/node";
export function setupMockServer(...handlers: RequestHandler[]) {
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
return server;
}
これを使ってモックサーバーを作成し、各テストケースでHTTPレスポンスステータスコードを切り替えます。
import { render, screen, waitFor } from '@testing-library/react';
import { setupMockServer } from '@/app/tests/vitest';
import { handleGetMyProfile } from '@/services/client/MyProfile/__mock__/msw'
import { LoginUserInfoProviders } from '@/app/components/providers/LoginUserInfo';
import UserProfile from '.';
// モックサーバーを作成し、モックハンドラーを登録
const server = setupMockServer(handleGetMyProfile())
function TestComponent () {
return (
// context経由でユーザー情報を渡している
<LoginUserInfoProviders>
<UserProfile />
</LoginUserInfoProviders>
)
}
test('When not logged in: A button to navigate to the login page is displayed', async () => {
// 401 に切り替える
server.use(handleGetMyProfile({ status: 401 }));
render (<TestComponent />);
expect(screen.getByText('loading...')).toBeInTheDocument();
// 非同期UIのテストのため、一定時間リトライし続ける処理を内部で行っている
await waitFor(() => {
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
});
});
test('When logged in: "john" is displayed', async () => {
render (<TestComponent />);
expect(screen.getByText('loading...')).toBeInTheDocument();
await waitFor(() => {
// john はフィクスチャの値
expect(screen.getByRole('heading', { level: 2, name: /john/i })).toBeInTheDocument();
});
});
Storyオブジェクトでモックハンドラーを使い回す
同じ handleGetMyProfile
を使ってログイン画面の Storyオブジェクト を作ります。
import type { Meta, StoryObj } from '@storybook/react'
import Login from '.'
import { handleGetMyProfile } from '@/services/client/MyProfile/__mock__/msw'
import { LoginUserInfoDecorator } from '@/app/tests/storybook'
const meta = {
title: 'templates/Login',
component: Login,
tags: ['autodocs'],
// context経由でユーザー情報を渡している
decorators: [LoginUserInfoDecorator],
} satisfies Meta<typeof Login>
export default meta
type Story = StoryObj<typeof meta>
// 次の節のテストコードで使い回します
export const NotLoggedIn: Story = {
parameters: {
msw: {
handlers: [
handleGetMyProfile({
status: 401
})
]
}
}
}
export const LoggedIn: Story = {
parameters: {
msw: {
handlers: [
handleGetMyProfile()
]
}
}
}
VitestでStoryオブジェクトを使い回す
さらに、composeStories を使って、一つ前に作った NotLoggedIn
Storyオブジェクトを使い回すことが可能です。
import { render, screen, waitFor } from '@testing-library/react';
import { setupMockServer } from '@/app/tests/vitest';
import { composeStories } from '@storybook/react';
import * as stories from "./index.stories";
const { NotLoggedIn } = composeStories(stories);
setupMockServer();
test('When not logged in: Email/Password input forms should be displayed', async () => {
render(<NotLoggedIn />);
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
});
このようにモックハンドラーやStoryオブジェクトを作成する際には再利用性も考慮すると、コードの重複を避けることができ、コードベースで一貫した挙動を維持しやすくなります。
Storybook
Storybookを使い、ナビゲーションコンポーネントを含むヘッダーコンポーネントの結合テストを行います。ここでは、Next.js特有の機能に対するテストや Play function を使ったインタラクションテストを解説していきます。
Next.js navigation
ヘッダー内のナビゲーションコンポーネントでは、next/navigation
の usePathname フックを利用して現在のパスを取得します。このパスに基づき、該当するナビゲーションリンクがハイライトされます。
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent as user, within, expect } from '@storybook/test';
import { Header } from '.';
const meta = {
title: 'Layout/Header',
component: Header,
tags: ['autodocs'],
} satisfies Meta<typeof Header>;
export default meta;
type Story = StoryObj<typeof meta>;
export const RouteAbout: Story = {
parameters: {
nextjs: {
navigation: {
// AboutタブがハイライトされるのをStorybook上で目視で確認
pathname: "/about"
},
}
},
};
Play function
SPサイズ時にナビゲーションコンポーネントのドロワーメニューが表示されるかどうかのインタラクションテストを Play function で実装します。実行結果は Storybook の Interactions タブに表示されます。
まず、addon-viewport でviewportの設定を用意します
import { INITIAL_VIEWPORTS } from "@storybook/addon-viewport";
export const SPStory = {
parameters: {
viewport: {
viewports: INITIAL_VIEWPORTS,
defaultViewport: "iphoneSE3", // 375 × 667 という小さめサイズを選択
}
}
}
「SPサイズ時にハンバーガーメニュー(押下可能なボタン)が表示され、クリックするとドロワーメニューが表示される」というシナリオを Play function で実装します。
import { SPStory } from '@/app/tests/storybook';
export const SPOpenMenu: Story = {
parameters: {
...SPStory.parameters,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// ハンバーガーメニューをクリックすると
const buttonOpen = await canvas.findByRole("button", { name: 'open menu' });
await user.click(buttonOpen);
// ドロワーメニューが表示される
const navigation = await canvas.findByRole("navigation");
await expect(navigation).toBeVisible();
}
};
Playwright
単体テストからE2Eテストまで幅広くカバーできる Playwright ですが、なるべくE2Eでしかできないことに絞ることが重要です。
ここではTodo管理画面のE2Eテストを例として出します。まず、ログイン情報を含むセッションを保存するセットアップ処理を書きます。これをプロジェクトの依存関係に含めることで、各テストケースでログイン処理を繰り返す必要がなくなります。続いて、Todo関連の処理をまとめたフィクスチャを作り、それを使ってTodo管理画面のE2Eテストを行なうという流れです。
セッション
ログイン処理を行い、セッションを保存します。
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('http://localhost:3000/login')
await page.getByPlaceholder('Email Address / Username').fill('admin@example.com')
await page.getByPlaceholder('Password').fill('very-strong-password')
await page.getByRole('button', { name: /Login/ }).click()
await page.waitForURL('http://localhost:3000/todo')
await expect(page.getByText(/TODO/)).toBeVisible()
// セッション情報を保存
await page.context().storageState({ path: authFile })
})
chromium プロジェクトの実行前にログイン処理を行うようにセットアップします。
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
フィクスチャ機能
テスト環境のセットアップや後処理をカプセル化する機能です。
TodoPage フィクスチャを用意します。
todo-page.ts
import { type Page } from '@playwright/test';
export class TodoPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(): Promise<void> {
await this.page.goto('http://localhost:3000/todo');
}
async addTodo(text: string): Promise<void> {
await this.page.getByPlaceholder('Add todo').fill(text);
await this.page.keyboard.press('Enter');
}
async deleteTodo(index: number): Promise<void> {
await this.page.locator('.todo-item').nth(index).locator('.delete-button').click();
}
async deleteAllTodos(): Promise<void> {
const count = await this.getTodoCount();
for (let i = count - 1; i >= 0; i--) {
await this.deleteTodo(i);
}
}
async getTodoCount(): Promise<number> {
return await this.page.locator('.todo-item').count();
}
async getTodoText(index: number): Promise<string | null> {
const todo = this.page.locator('.todo-item').nth(index);
return await todo.locator('span').textContent();
}
}
用意したフィクスチャを使用してTodo管理画面のテストを行います。
ログイン済み状態のため、ログイン処理を書く必要はありません。
import { test as base, expect } from '@playwright/test';
import { TodoPage } from './todo-page';
const test = base.extend<{ todoTest: TodoPage }>({
todoTest: async ({ page }, use) => {
// beforeEach に相当
const todoTest = new TodoPage(page);
await todoTest.goto();
for (const text of ['todo1', 'todo2', 'todo3']) {
await todoTest.addTodo(text);
}
// フィクスチャを登録、テストの実行に相当
await use(todoTest);
// afterEach に相当
await todoTest.deleteAllTodos();
},
});
test('should add a new todo', async ({ todoTest }) => {
await todoTest.addTodo('new todo');
expect(await todoTest.getTodoCount()).toBe(4);
expect(await todoTest.getTodoText(3)).toBe('new todo');
});
test('should delete todo', async ({ todoTest }) => {
await todoTest.deleteTodo(0);
expect(await todoTest.getTodoCount()).toBe(2);
});
依存性の注入
依存性の注入(Dependency Injection, DI)を使うことで、対象のコンポーネントが具体的な実装に依存せず、インターフェースや抽象クラスにのみ依存する設計が可能になります。これにより、実装を柔軟に切り替えたり、テスト時にモックやスタブを注入したりしやすくなります。DIはNext.jsプロジェクトのフロントエンド開発にはあまり使わないかもしれませんが、有用なテクニックなので記載しておきます。
以下のコードでは、リポジトリを UserService
に注入することで、データ取得の方法を柔軟に切り替えられる設計 にしています。まず、UserRepository
インターフェースを定義します(findUserById
を持っていれば何でも差し替えられる)
import { prisma } from "@/lib/prisma";
export interface UserRepository {
findUserById(userId: number): Promise<{ id: number; name: string } | null>;
}
// 本番用
export class PrismaUserRepository implements UserRepository {
async findUserById(userId: number) {
return await prisma.user.findUnique({ where: { id: userId } });
}
}
// テスト用
export class MockUserRepository implements UserRepository {
async findUserById(userId: number) {
return userId === 1 ? { id: 1, name: "Mock User" } : null;
}
}
サービスでリポジトリを注入します。UserRepository
の具体的な実装を UserService
に直接書かず、コンストラクタで注入できる設計にしています。
import { UserRepository } from "@/repositories/userRepository";
export class UserService {
constructor(private userRepository: UserRepository) {}
async getUserProfile(userId: number) {
const user = await this.userRepository.findUserById(userId);
if (!user) {
throw new Error("User not found");
}
return { id: user.id, name: user.name };
}
}
APIルートで依存を注入します。
import { NextRequest, NextResponse } from "next/server";
import { UserService } from "@/services/userService";
import { PrismaUserRepository } from "@/repositories/userRepository";
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
// 本番用のリポジトリを注入
const userRepository = new PrismaUserRepository();
const userService = new UserService(userRepository);
const result = await userService.getUserProfile(Number(params.id));
return NextResponse.json(result);
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 404 });
}
}
テストでモックを注入します。
import { UserService } from "@/services/userService";
import { MockUserRepository } from "@/repositories/userRepository";
describe("getUserProfile", () => {
it("should get a user from the mock repository", async () => {
// テスト用のモックを注入
const userRepository = new MockUserRepository();
const userService = new UserService(userRepository);
const result = await userService.getUserProfile(1);
expect(result).toEqual({ id: 1, name: "Mock User" });
});
});
Discussion