演習:テストスイートを構築しよう
ストーリー
「ここまで学んだテストの知識を全て使って、 実際のテストスイートを構築してみろ」
松本先輩が要件を書いた紙を渡した。
「タスク管理アプリのバックエンド。 ユニットテスト、インテグレーションテスト、 E2Eテストの全てを書いてもらう。 テストピラミッドを意識してな」
演習の概要
タスク管理アプリケーションのテストスイートを構築します。
アプリケーション仕様
機能:
├── タスクの作成(タイトル、説明、期限、優先度)
├── タスクの一覧取得(フィルタ、ソート対応)
├── タスクの更新(ステータス変更含む)
├── タスクの削除
└── タスクの完了率の集計
ビジネスルール:
├── タイトルは必須(1〜100文字)
├── 優先度は low / medium / high の3種類
├── ステータスは todo / in_progress / done の3種類
├── 期限は未来の日付のみ設定可能
├── 完了したタスクは削除不可
└── 1ユーザーの未完了 タスク上限は50件
課題1: Value Object のユニットテスト(20分)
以下のValue Objectに対するテストを作成してください。
typescript
// src/domain/value-objects/TaskTitle.ts
export class TaskTitle {
private constructor(private readonly value: string) {}
static create(value: string): TaskTitle {
if (!value || value.trim().length === 0) {
throw new Error('Title is required');
}
const trimmed = value.trim();
if (trimmed.length > 100) {
throw new Error('Title must be 100 characters or less');
}
return new TaskTitle(trimmed);
}
getValue(): string {
return this.value;
}
}
// src/domain/value-objects/Priority.ts
export type PriorityValue = 'low' | 'medium' | 'high';
export class Priority {
private static readonly VALID_VALUES: PriorityValue[] = ['low', 'medium', 'high'];
private constructor(private readonly value: PriorityValue) {}
static create(value: string): Priority {
if (!Priority.VALID_VALUES.includes(value as PriorityValue)) {
throw new Error(`Invalid priority: ${value}. Must be one of: ${Priority.VALID_VALUES.join(', ')}`);
}
return new Priority(value as PriorityValue);
}
getValue(): PriorityValue {
return this.value;
}
isHigherThan(other: Priority): boolean {
const order = { low: 0, medium: 1, high: 2 };
return order[this.value] > order[other.value];
}
}TaskTitle と Priority のユニットテストを書いてください。
<details> <summary>解答例(自分で実装してから確認しよう)</summary>typescript
// tests/unit/domain/TaskTitle.test.ts
import { TaskTitle } from '../../../src/domain/value-objects/TaskTitle';
describe('TaskTitle', () => {
describe('create', () => {
it('有効なタイトルで作成できる', () => {
const title = TaskTitle.create('テスト実装');
expect(title.getValue()).toBe('テスト実装');
});
it('前後の空白がトリムされる', () => {
const title = TaskTitle.create(' テスト実装 ');
expect(title.getValue()).toBe('テスト実装');
});
it('空文字はエラーになる', () => {
expect(() => TaskTitle.create('')).toThrow('Title is required');
});
it('空白のみはエラーになる', () => {
expect(() => TaskTitle.create(' ')).toThrow('Title is required');
});
it('100文字ちょうどは許容される', () => {
const title = TaskTitle.create('a'.repeat(100));
expect(title.getValue()).toHaveLength(100);
});
it('101文字はエラーになる', () => {
expect(() => TaskTitle.create('a'.repeat(101)))
.toThrow('Title must be 100 characters or less');
});
});
});
// tests/unit/domain/Priority.test.ts
import { Priority } from '../../../src/domain/value-objects/Priority';
describe('Priority', () => {
describe('create', () => {
it.each(['low', 'medium', 'high'])('"%s" で作成できる', (value) => {
const priority = Priority.create(value);
expect(priority.getValue()).toBe(value);
});
it('無効な値はエラーになる', () => {
expect(() => Priority.create('urgent'))
.toThrow('Invalid priority: urgent');
});
});
describe('isHigherThan', () => {
it('high > medium', () => {
const high = Priority.create('high');
const medium = Priority.create('medium');
expect(high.isHigherThan(medium)).toBe(true);
});
it('medium > low', () => {
const medium = Priority.create('medium');
const low = Priority.create('low');
expect(medium.isHigherThan(low)).toBe(true);
});
it('low は medium より高くない', () => {
const low = Priority.create('low');
const medium = Priority.create('medium');
expect(low.isHigherThan(medium)).toBe(false);
});
it('同じ優先度は高くない', () => {
const high1 = Priority.create('high');
const high2 = Priority.create('high');
expect(high1.isHigherThan(high2)).toBe(false);
});
});
});課題2: ユースケースのユニットテスト(25分)
typescript
// src/application/use-cases/CreateTask.ts
export class CreateTaskUseCase {
constructor(private readonly taskRepository: TaskRepository) {}
async execute(input: CreateTaskInput): Promise<Task> {
const title = TaskTitle.create(input.title);
const priority = Priority.create(input.priority);
if (input.dueDate && new Date(input.dueDate) <= new Date()) {
throw new ValidationError('Due date must be in the future');
}
const activeTasks = await this.taskRepository.countByUserIdAndStatus(
input.userId, ['todo', 'in_progress']
);
if (activeTasks >= 50) {
throw new LimitExceededError('Active task limit (50) exceeded');
}
const task = Task.create({ title, priority, userId: input.userId, dueDate: input.dueDate });
await this.taskRepository.save(task);
return task;
}
}CreateTaskUseCase のユニットテスト(モック使用)を書いてください。
<details> <summary>解答例(自分で実装してから確認しよう)</summary>typescript
// tests/unit/application/CreateTask.test.ts
import { CreateTaskUseCase } from '../../../src/application/use-cases/CreateTask';
describe('CreateTaskUseCase', () => {
let useCase: CreateTaskUseCase;
let mockTaskRepo: jest.Mocked<TaskRepository>;
beforeEach(() => {
mockTaskRepo = {
save: jest.fn().mockResolvedValue(undefined),
countByUserIdAndStatus: jest.fn().mockResolvedValue(0),
} as any;
useCase = new CreateTaskUseCase(mockTaskRepo);
});
it('正常にタスクを作成できる', async () => {
const futureDate = new Date(Date.now() + 86400000).toISOString();
const result = await useCase.execute({
title: '新しいタスク',
priority: 'high',
userId: 'user-1',
dueDate: futureDate,
});
expect(result).toBeDefined();
expect(mockTaskRepo.save).toHaveBeenCalledTimes(1);
});
it('空のタイトルはエラーになる', async () => {
await expect(
useCase.execute({ title: '', priority: 'low', userId: 'user-1' })
).rejects.toThrow('Title is required');
});
it('過去の期限はエラーになる', async () => {
const pastDate = new Date('2020-01-01').toISOString();
await expect(
useCase.execute({ title: 'タスク', priority: 'low', userId: 'user-1', dueDate: pastDate })
).rejects.toThrow('Due date must be in the future');
});
it('未完了タスクが50件の場合はエラーになる', async () => {
mockTaskRepo.countByUserIdAndStatus.mockResolvedValue(50);
await expect(
useCase.execute({ title: 'タスク', priority: 'low', userId: 'user-1' })
).rejects.toThrow('Active task limit (50) exceeded');
});
it('未完了タスクが49件の場合は作成できる', async () => {
mockTaskRepo.countByUserIdAndStatus.mockResolvedValue(49);
const result = await useCase.execute({
title: 'タスク', priority: 'low', userId: 'user-1',
});
expect(result).toBeDefined();
expect(mockTaskRepo.save).toHaveBeenCalledTimes(1);
});
});課題3: APIインテグレーションテスト(25分)
エンドポイント:
POST /api/tasks → タスク作成
GET /api/tasks → タスク一覧
GET /api/tasks/:id → タスク詳細
PUT /api/tasks/:id → タスク更新
DELETE /api/tasks/:id → タスク削除
POST /api/tasks と GET /api/tasks のインテグレーションテストを書いてください。
<details> <summary>解答例(自分で実装してから確認しよう)</summary>typescript
// tests/integration/tasks.test.ts
import request from 'supertest';
import { app } from '../../src/app';
describe('Tasks API', () => {
beforeEach(async () => {
await clearTasks();
});
describe('POST /api/tasks', () => {
it('タスクを作成して201を返す', async () => {
const response = await request(app)
.post('/api/tasks')
.send({
title: 'テストスイートを構築する',
priority: 'high',
userId: 'user-1',
})
.expect(201);
expect(response.body.id).toBeDefined();
expect(response.body.title).toBe('テストスイートを構築する');
expect(response.body.priority).toBe('high');
expect(response.body.status).toBe('todo');
});
it('タイトルなしで400を返す', async () => {
const response = await request(app)
.post('/api/tasks')
.send({ priority: 'low', userId: 'user-1' })
.expect(400);
expect(response.body.error).toContain('Title is required');
});
it('不正な優先度で400を返す', async () => {
await request(app)
.post('/api/tasks')
.send({ title: 'タスク', priority: 'urgent', userId: 'user-1' })
.expect(400);
});
});
describe('GET /api/tasks', () => {
beforeEach(async () => {
await request(app).post('/api/tasks').send({
title: 'タスク1', priority: 'high', userId: 'user-1',
});
await request(app).post('/api/tasks').send({
title: 'タスク2', priority: 'low', userId: 'user-1',
});
await request(app).post('/api/tasks').send({
title: 'タスク3', priority: 'medium', userId: 'user-2',
});
});
it('全タスクを取得できる', async () => {
const response = await request(app)
.get('/api/tasks')
.expect(200);
expect(response.body).toHaveLength(3);
});
it('ユーザーでフィルタできる', async () => {
const response = await request(app)
.get('/api/tasks?userId=user-1')
.expect(200);
expect(response.body).toHaveLength(2);
response.body.forEach((task: any) => {
expect(task.userId).toBe('user-1');
});
});
it('優先度でフィルタできる', async () => {
const response = await request(app)
.get('/api/tasks?priority=high')
.expect(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].title).toBe('タスク1');
});
});
});課題4: E2Eテスト(20分)
タスクの作成と一覧表示のE2Eテストを書いてください。
<details> <summary>解答例(自分で実装してから確認しよう)</summary>typescript
// e2e/tasks.spec.ts
import { test, expect } from '@playwright/test';
test.describe('タスク管理', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('タスクを作成して一覧に表示される', async ({ page }) => {
// タスク作成フォーム
await page.click('[data-testid="new-task-button"]');
await page.fill('[data-testid="task-title"]', 'Playwrightテストを書く');
await page.selectOption('[data-testid="task-priority"]', 'high');
await page.click('[data-testid="submit-task"]');
// 一覧に表示されることを確認
const taskItem = page.locator('[data-testid="task-item"]').filter({
hasText: 'Playwrightテストを書く',
});
await expect(taskItem).toBeVisible();
await expect(taskItem.locator('[data-testid="priority-badge"]'))
.toHaveText('high');
});
test('タスクを完了にできる', async ({ page }) => {
// 既存のタスクの完了ボタンをクリック
const taskItem = page.locator('[data-testid="task-item"]').first();
await taskItem.locator('[data-testid="complete-button"]').click();
// ステータスが変わることを確認
await expect(taskItem.locator('[data-testid="status-badge"]'))
.toHaveText('done');
});
});まとめ
| 課題 | テストレベル | テスト数目安 |
|---|---|---|
| 課題1 | ユニットテスト(Value Object) | 10-12件 |
| 課題2 | ユニットテスト(ユースケース) | 5-7件 |
| 課題3 | インテグレーションテスト(API) | 6-8件 |
| 課題4 | E2Eテスト | 2-3件 |
チェックリスト
- Value Object のバリデーションテストを網羅的に書けた
- モックを使ったユースケースのテストが書けた
- supertest を使ったAPIテストが書けた
- Playwright でE2Eテストが書けた
- テストピラミッドを意識した配分になっている
次のステップへ
テストスイートを構築したら、チェックポイントクイズで理解度を確認しましょう。
推定読了時間: 90分