LESSON 30分

ユニットテストの実践(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分