テスト戦略の重要性
ソフトウェアの品質を担保するためには、適切なテスト戦略が不可欠です。効率的なテスト設計は、開発速度と品質のバランスを取りながら、持続可能な開発を可能にします。
flowchart TB
subgraph Purpose["テストの目的"]
subgraph QA["1. 品質保証 (Quality Assurance)"]
QA1["バグの早期発見"]
QA2["リグレッション防止"]
QA3["仕様の検証"]
end
subgraph Design["2. 設計改善 (Design Improvement)"]
D1["テスタビリティの向上"]
D2["疎結合な設計の促進"]
D3["インターフェースの明確化"]
end
subgraph Docs["3. ドキュメンテーション"]
Doc1["実行可能な仕様書"]
Doc2["使用例の提示"]
Doc3["境界条件の明示"]
end
end
テストピラミッド
flowchart TB
subgraph Pyramid["テストピラミッド"]
E2E["E2E Tests<br/>(少数・高コスト)"]
Integration["Integration Tests<br/>(中程度)"]
Unit["Unit Tests<br/>(多数・低コスト)"]
E2E --> Integration --> Unit
end
特性:
| レベル | 実行速度 | 信頼性 | コスト |
|---|---|---|---|
| E2E | 遅い | 低い | 高い |
| Integration | 中程度 | 中程度 | 中程度 |
| Unit | 速い | 高い | 低い |
ユニットテスト
基本原則(F.I.R.S.T)
// F.I.R.S.T原則に従ったユニットテスト
// Fast(高速)- テストは素早く実行できること
// Independent(独立)- テスト間に依存関係がないこと
// Repeatable(再現可能)- 何度実行しても同じ結果になること
// Self-Validating(自己検証)- 成功/失敗が明確であること
// Timely(適時)- プロダクションコードの前後に書くこと
import { describe, it, expect, beforeEach } from 'vitest';
// テスト対象のユーティリティ関数
function calculateTax(price: number, taxRate: number): number {
if (price < 0) throw new Error('Price cannot be negative');
if (taxRate < 0 || taxRate > 1) throw new Error('Invalid tax rate');
return Math.round(price * taxRate);
}
describe('calculateTax', () => {
// 正常系
it('should calculate tax correctly', () => {
expect(calculateTax(1000, 0.1)).toBe(100);
});
it('should round to nearest integer', () => {
expect(calculateTax(999, 0.1)).toBe(100); // 99.9 → 100
});
// 境界値テスト
it('should return 0 for zero price', () => {
expect(calculateTax(0, 0.1)).toBe(0);
});
it('should handle zero tax rate', () => {
expect(calculateTax(1000, 0)).toBe(0);
});
it('should handle 100% tax rate', () => {
expect(calculateTax(1000, 1)).toBe(1000);
});
// 異常系
it('should throw for negative price', () => {
expect(() => calculateTax(-100, 0.1)).toThrow('Price cannot be negative');
});
it('should throw for invalid tax rate', () => {
expect(() => calculateTax(1000, 1.5)).toThrow('Invalid tax rate');
expect(() => calculateTax(1000, -0.1)).toThrow('Invalid tax rate');
});
});
AAAパターン
// Arrange-Act-Assert パターン
import { describe, it, expect } from 'vitest';
class ShoppingCart {
private items: Array<{ name: string; price: number; quantity: number }> = [];
addItem(name: string, price: number, quantity: number = 1): void {
this.items.push({ name, price, quantity });
}
removeItem(name: string): void {
this.items = this.items.filter(item => item.name !== name);
}
getTotal(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
getItemCount(): number {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
}
describe('ShoppingCart', () => {
it('should calculate total correctly', () => {
// Arrange(準備)
const cart = new ShoppingCart();
cart.addItem('Apple', 100, 3);
cart.addItem('Banana', 80, 2);
// Act(実行)
const total = cart.getTotal();
// Assert(検証)
expect(total).toBe(460); // 100*3 + 80*2
});
it('should remove item correctly', () => {
// Arrange
const cart = new ShoppingCart();
cart.addItem('Apple', 100, 1);
cart.addItem('Banana', 80, 1);
// Act
cart.removeItem('Apple');
// Assert
expect(cart.getTotal()).toBe(80);
expect(cart.getItemCount()).toBe(1);
});
});
テストダブル
flowchart TB
subgraph TestDoubles["テストダブルの種類"]
subgraph Stub["1. Stub(スタブ)"]
S1["事前定義された値を返す"]
S2["外部依存をシミュレート"]
S3["入力に対する出力のみを制御"]
end
subgraph Mock["2. Mock(モック)"]
M1["期待される呼び出しを検証"]
M2["呼び出し回数・引数を確認"]
M3["振る舞いの検証に使用"]
end
subgraph Spy["3. Spy(スパイ)"]
Sp1["実際の実装を使いながら呼び出しを記録"]
Sp2["後から呼び出しを検証可能"]
Sp3["部分的なモック化"]
end
subgraph Fake["4. Fake(フェイク)"]
F1["実装の簡略版"]
F2["インメモリDBなど"]
F3["本番と同様のインターフェース"]
end
end
テストダブルの実装例
import { describe, it, expect, vi, beforeEach } from 'vitest';
// 依存関係のインターフェース
interface EmailService {
send(to: string, subject: string, body: string): Promise<boolean>;
}
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface User {
id: string;
email: string;
name: string;
}
// テスト対象のクラス
class UserService {
constructor(
private userRepo: UserRepository,
private emailService: EmailService
) {}
async notifyUser(userId: string, message: string): Promise<boolean> {
const user = await this.userRepo.findById(userId);
if (!user) {
throw new Error('User not found');
}
return this.emailService.send(
user.email,
'Notification',
message
);
}
}
describe('UserService', () => {
let userRepo: UserRepository;
let emailService: EmailService;
let userService: UserService;
beforeEach(() => {
// Stubの作成
userRepo = {
findById: vi.fn(),
save: vi.fn(),
};
// Mockの作成
emailService = {
send: vi.fn(),
};
userService = new UserService(userRepo, emailService);
});
it('should send notification to user', async () => {
// Stubの設定(事前定義された値を返す)
const mockUser: User = {
id: '1',
email: 'test@example.com',
name: 'Test User',
};
vi.mocked(userRepo.findById).mockResolvedValue(mockUser);
vi.mocked(emailService.send).mockResolvedValue(true);
// 実行
const result = await userService.notifyUser('1', 'Hello!');
// 結果の検証
expect(result).toBe(true);
// Mockの検証(呼び出しを確認)
expect(emailService.send).toHaveBeenCalledWith(
'test@example.com',
'Notification',
'Hello!'
);
expect(emailService.send).toHaveBeenCalledTimes(1);
});
it('should throw error when user not found', async () => {
// Stubの設定(nullを返す)
vi.mocked(userRepo.findById).mockResolvedValue(null);
// 例外の検証
await expect(userService.notifyUser('999', 'Hello!'))
.rejects.toThrow('User not found');
// emailServiceが呼ばれていないことを確認
expect(emailService.send).not.toHaveBeenCalled();
});
});
// Fakeの例(インメモリリポジトリ)
class FakeUserRepository implements UserRepository {
private users: Map<string, User> = new Map();
async findById(id: string): Promise<User | null> {
return this.users.get(id) ?? null;
}
async save(user: User): Promise<void> {
this.users.set(user.id, user);
}
// テスト用のヘルパーメソッド
seed(users: User[]): void {
users.forEach(user => this.users.set(user.id, user));
}
clear(): void {
this.users.clear();
}
}
統合テスト
// APIの統合テスト
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer, Server } from 'http';
import { app } from '../src/app';
describe('API Integration Tests', () => {
let server: Server;
let baseURL: string;
beforeAll(async () => {
server = createServer(app);
await new Promise<void>(resolve => {
server.listen(0, () => resolve());
});
const address = server.address();
const port = typeof address === 'object' ? address?.port : 0;
baseURL = `http://localhost:${port}`;
});
afterAll(async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
});
describe('POST /api/users', () => {
it('should create a new user', async () => {
const response = await fetch(`${baseURL}/api/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Test User',
email: 'test@example.com',
}),
});
expect(response.status).toBe(201);
const data = await response.json();
expect(data).toMatchObject({
name: 'Test User',
email: 'test@example.com',
});
expect(data.id).toBeDefined();
});
it('should return 400 for invalid data', async () => {
const response = await fetch(`${baseURL}/api/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: '', // 空の名前
}),
});
expect(response.status).toBe(400);
});
});
describe('GET /api/users/:id', () => {
it('should return user by id', async () => {
// 事前にユーザーを作成
const createResponse = await fetch(`${baseURL}/api/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Get Test',
email: 'get@example.com',
}),
});
const created = await createResponse.json();
// 作成したユーザーを取得
const response = await fetch(`${baseURL}/api/users/${created.id}`);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.id).toBe(created.id);
});
it('should return 404 for non-existent user', async () => {
const response = await fetch(`${baseURL}/api/users/non-existent-id`);
expect(response.status).toBe(404);
});
});
});
TDD(テスト駆動開発)
flowchart TB
subgraph TDD["TDDサイクル(Red-Green-Refactor)"]
Red["RED<br/>(失敗)"]
Green["GREEN<br/>(成功)"]
Refactor["REFACTOR<br/>(改善)"]
Red -->|"失敗するテストを書く"| Green
Green -->|"最小限のコードで通す"| Refactor
Refactor -->|"コードを改善"| Red
end
TDD実践例
// Step 1: RED - 失敗するテストを書く
describe('StringCalculator', () => {
it('should return 0 for empty string', () => {
const calc = new StringCalculator();
expect(calc.add('')).toBe(0);
});
});
// Step 2: GREEN - 最小限のコードで通す
class StringCalculator {
add(numbers: string): number {
return 0;
}
}
// Step 3: 次のテストを追加(RED)
describe('StringCalculator', () => {
it('should return 0 for empty string', () => {
const calc = new StringCalculator();
expect(calc.add('')).toBe(0);
});
it('should return number for single number', () => {
const calc = new StringCalculator();
expect(calc.add('1')).toBe(1);
expect(calc.add('5')).toBe(5);
});
});
// Step 4: GREEN - テストを通す
class StringCalculator {
add(numbers: string): number {
if (numbers === '') return 0;
return parseInt(numbers, 10);
}
}
// Step 5: さらにテストを追加
describe('StringCalculator', () => {
// ... 前のテスト
it('should return sum for multiple numbers', () => {
const calc = new StringCalculator();
expect(calc.add('1,2')).toBe(3);
expect(calc.add('1,2,3')).toBe(6);
});
it('should handle newlines as delimiter', () => {
const calc = new StringCalculator();
expect(calc.add('1\n2,3')).toBe(6);
});
it('should support custom delimiter', () => {
const calc = new StringCalculator();
expect(calc.add('//;\n1;2')).toBe(3);
});
it('should throw for negative numbers', () => {
const calc = new StringCalculator();
expect(() => calc.add('-1,2')).toThrow('Negatives not allowed: -1');
});
});
// 最終実装
class StringCalculator {
add(numbers: string): number {
if (numbers === '') return 0;
let delimiter = /,|\n/;
let numString = numbers;
// カスタムデリミタの処理
if (numbers.startsWith('//')) {
const delimiterEnd = numbers.indexOf('\n');
delimiter = new RegExp(numbers.slice(2, delimiterEnd));
numString = numbers.slice(delimiterEnd + 1);
}
const nums = numString.split(delimiter).map(n => parseInt(n, 10));
// 負の数チェック
const negatives = nums.filter(n => n < 0);
if (negatives.length > 0) {
throw new Error(`Negatives not allowed: ${negatives.join(', ')}`);
}
return nums.reduce((sum, n) => sum + n, 0);
}
}
カバレッジ戦略
カバレッジの種類
| 種類 | 説明 |
|---|---|
| ステートメントカバレッジ (Statement) | 各行が実行されたか |
| ブランチカバレッジ (Branch) | 各分岐(if/else)が実行されたか |
| 関数カバレッジ (Function) | 各関数が呼び出されたか |
| 行カバレッジ (Line) | 各行が実行されたか |
推奨目標:
| 対象 | 目標 |
|---|---|
| 全体 | 80%以上 |
| 重要なビジネスロジック | 90%以上 |
| ユーティリティ関数 | 100% |
| UIコンポーネント | 70%以上 |
注意: カバレッジは品質の指標の一つに過ぎません。100%でもバグは存在しうる。
Vitest カバレッジ設定
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.config.*',
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
フロントエンドテスト戦略
// Reactコンポーネントのテスト
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
// テスト対象のコンポーネント
function LoginForm({ onSubmit }: { onSubmit: (data: { email: string; password: string }) => void }) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
onSubmit({
email: formData.get('email') as string,
password: formData.get('password') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<label>
Email
<input type="email" name="email" required />
</label>
<label>
Password
<input type="password" name="password" required />
</label>
<button type="submit">Login</button>
</form>
);
}
describe('LoginForm', () => {
it('should render form fields', () => {
render(<LoginForm onSubmit={() => {}} />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
});
it('should call onSubmit with form data', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
ベストプラクティス
| カテゴリ | ベストプラクティス |
|---|---|
| 命名 | テスト名は「should + 期待される振る舞い」 |
| 構造 | AAAパターン(Arrange-Act-Assert)を使用 |
| 独立性 | テスト間の依存関係を排除 |
| 速度 | ユニットテストは高速に保つ |
| 信頼性 | フレーキーテストを排除 |
| 保守性 | テストコードもプロダクションコード同様に管理 |