EXERCISE 90分

演習:テストスイートを構築しよう

ストーリー

「ここまで学んだテストの知識を全て使って、 実際のテストスイートを構築してみろ」

松本先輩が要件を書いた紙を渡した。

「タスク管理アプリのバックエンド。 ユニットテスト、インテグレーションテスト、 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);
    });
  });
});
</details>

課題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);
  });
});
</details>

課題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');
    });
  });
});
</details>

課題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');
  });
});
</details>

まとめ

課題テストレベルテスト数目安
課題1ユニットテスト(Value Object)10-12件
課題2ユニットテスト(ユースケース)5-7件
課題3インテグレーションテスト(API)6-8件
課題4E2Eテスト2-3件

チェックリスト

  • Value Object のバリデーションテストを網羅的に書けた
  • モックを使ったユースケースのテストが書けた
  • supertest を使ったAPIテストが書けた
  • Playwright でE2Eテストが書けた
  • テストピラミッドを意識した配分になっている

次のステップへ

テストスイートを構築したら、チェックポイントクイズで理解度を確認しましょう。


推定読了時間: 90分