ユニットテストの実践(Jest/Vitest)
ストーリー
「いよいよ実践だ。まずはユニットテストから始めよう」
松本先輩がエディタを開いた。
「Jest と Vitest、どちらもTypeScript のユニットテストで 人気のフレームワークだ。APIはほぼ同じだから、 どちらを使っても知識は活かせる。 今日は実際にテストを書いていこう」
Jest / Vitest の基本
セットアップ
bash
# Jest
npm install --save-dev jest @types/jest ts-jest
npx ts-jest config:init
# Vitest
npm install --save-dev vitestテストファイルの配置
src/
├── services/
│ ├── orderService.ts
│ └── orderService.test.ts ← 同じディレクトリに配置
├── domain/
│ ├── valueObjects/
│ │ ├── Email.ts
│ │ └── Email.test.ts
│ └── entities/
│ ├── User.ts
│ └── User.test.ts
テストの基本構文
describe / it / expect
typescript
import { describe, it, expect } from 'vitest'; // Vitest の場合
// Jest の場合は import 不要(グローバル)
describe('テスト対象のグループ名', () => {
it('テストケースの説明', () => {
// Arrange(準備)
const input = 10;
// Act(実行)
const result = double(input);
// Assert(検証)
expect(result).toBe(20);
});
});よく使うマッチャー
typescript
// 一致
expect(value).toBe(expected); // 厳密等価(===)
expect(value).toEqual(expected); // 深い等価(オブジェクト比較)
expect(value).toStrictEqual(expected); // 厳密な深い等価
// 真偽値
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// 数値
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3);
expect(value).toBeLessThan(5);
expect(value).toBeCloseTo(0.3, 5); // 浮動小数点
// 文字列
expect(value).toMatch(/regex/);
expect(value).toContain('substring');
// 配列
expect(array).toContain(item);
expect(array).toHaveLength(3);
// 例外
expect(() => throwingFn()).toThrow();
expect(() => throwingFn()).toThrow('Error message');
expect(() => throwingFn()).toThrow(CustomError);実践:Value Object のテスト
typescript
// src/domain/value-objects/Email.ts
export class Email {
private constructor(private readonly value: string) {}
static create(value: string): Email {
if (!value) {
throw new Error('Email is required');
}
if (!value.includes('@')) {
throw new Error('Invalid email format');
}
if (value.length > 254) {
throw new Error('Email is too long');
}
return new Email(value.toLowerCase());
}
getValue(): string {
return this.value;
}
equals(other: Email): boolean {
return this.value === other.value;
}
}typescript
// src/domain/value-objects/Email.test.ts
import { Email } from './Email';
describe('Email Value Object', () => {
describe('create', () => {
it('有効なメールアドレスで作成できる', () => {
const email = Email.create('user@example.com');
expect(email.getValue()).toBe('user@example.com');
});
it('大文字は小文字に変換される', () => {
const email = Email.create('User@Example.COM');
expect(email.getValue()).toBe('user@example.com');
});
it('空文字はエラーになる', () => {
expect(() => Email.create('')).toThrow('Email is required');
});
it('@がないメールアドレスはエラーになる', () => {
expect(() => Email.create('invalid-email')).toThrow('Invalid email format');
});
it('254文字を超えるメールアドレスはエラーになる', () => {
const longEmail = 'a'.repeat(250) + '@b.com';
expect(() => Email.create(longEmail)).toThrow('Email is too long');
});
});
describe('equals', () => {
it('同じメールアドレスは等しい', () => {
const email1 = Email.create('user@example.com');
const email2 = Email.create('user@example.com');
expect(email1.equals(email2)).toBe(true);
});
it('異なるメールアドレスは等しくない', () => {
const email1 = Email.create('user1@example.com');
const email2 = Email.create('user2@example.com');
expect(email1.equals(email2)).toBe(false);
});
it('大文字小文字が異なっても等しい', () => {
const email1 = Email.create('User@Example.com');
const email2 = Email.create('user@example.com');
expect(email1.equals(email2)).toBe(true);
});
});
});モック(Mock)の活用
外部依存をモックに置き換えてユニットテストを独立させます。
typescript
// src/application/use-cases/CreateUser.ts
export class CreateUserUseCase {
constructor(
private readonly userRepository: UserRepository,
private readonly emailService: EmailService,
) {}
async execute(input: { name: string; email: string }): Promise<User> {
const email = Email.create(input.email);
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new ConflictError('Email already exists');
}
const user = User.create({ name: input.name, email });
await this.userRepository.save(user);
await this.emailService.sendWelcome(email);
return user;
}
}
// テスト
describe('CreateUserUseCase', () => {
let useCase: CreateUserUseCase;
let mockUserRepo: jest.Mocked<UserRepository>;
let mockEmailService: jest.Mocked<EmailService>;
beforeEach(() => {
mockUserRepo = {
findByEmail: jest.fn(),
save: jest.fn(),
} as any;
mockEmailService = {
sendWelcome: jest.fn(),
} as any;
useCase = new CreateUserUseCase(mockUserRepo, mockEmailService);
});
it('新規ユーザーを作成できる', async () => {
mockUserRepo.findByEmail.mockResolvedValue(null);
mockUserRepo.save.mockResolvedValue(undefined);
mockEmailService.sendWelcome.mockResolvedValue(undefined);
const user = await useCase.execute({
name: '田中太郎',
email: 'tanaka@example.com',
});
expect(user.name).toBe('田中太郎');
expect(mockUserRepo.save).toHaveBeenCalledTimes(1);
expect(mockEmailService.sendWelcome).toHaveBeenCalledTimes(1);
});
it('既存メールアドレスの場合はエラーになる', async () => {
mockUserRepo.findByEmail.mockResolvedValue({} as User);
await expect(
useCase.execute({ name: '田中太郎', email: 'tanaka@example.com' })
).rejects.toThrow('Email already exists');
expect(mockUserRepo.save).not.toHaveBeenCalled();
expect(mockEmailService.sendWelcome).not.toHaveBeenCalled();
});
});テストのベストプラクティス
| プラクティス | 説明 |
|---|---|
| AAA パターン | Arrange(準備)→ Act(実行)→ Assert(検証) |
| 1テスト1アサーション | 1つのテストで1つのことを検証 |
| テスト名は仕様を表す | 「〇〇の場合、△△になる」形式 |
| テストの独立性 | テスト間で状態を共有しない |
| マジックナンバー避ける | テスト内でも意味のある変数名を使う |
まとめ
| 項目 | ポイント |
|---|---|
| フレームワーク | Jest / Vitest(API互換) |
| 基本構文 | describe / it / expect + マッチャー |
| モック | 外部依存を置き換えてテストを独立させる |
| ベストプラクティス | AAA パターン、1テスト1アサーション |
チェックリスト
- Jest/Vitest の基本構文を使える
- 主要なマッチャー(toBe, toEqual, toThrow 等)を使い分けられる
- モックを使って外部依存を置き換えられる
- AAA パターンでテストを構造化できる
次のステップへ
ユニットテストの基本を学んだら、次はインテグレーションテストを学びます。DB接続やAPI呼び出しを含むテストの書き方を見ていきましょう。
推定読了時間: 30分