T-CREATOR

Vitest テストデータ設計技術:Factory / Builder / Fixture の責務分離と再利用

Vitest テストデータ設計技術:Factory / Builder / Fixture の責務分離と再利用

テストコードの品質を左右する重要な要素の一つが、テストデータの設計です。テストを書いていると、同じようなデータを何度も作成したり、複雑なオブジェクトの準備に多くのコードを書く必要があったりと、メンテナンス性が低下する問題に直面することがあります。

本記事では、Vitest でのテストデータ設計において、Factory、Builder、Fixture という 3 つのパターンを活用し、責務を明確に分離しながら再利用性を高める技術を解説します。これらのパターンを適切に使い分けることで、テストコードの保守性と可読性を大幅に向上させることができるでしょう。

背景

テストデータ管理の重要性

Vitest を使った開発では、ユニットテスト、統合テスト、E2E テストなど、様々なレベルでテストを記述します。これらのテストでは、関数やコンポーネントに渡すデータを準備する必要がありますが、データの準備方法が統一されていないと、以下のような問題が発生しやすくなります。

テストコード内で直接データを作成すると、同じようなデータ構造を複数のテストで重複して定義することになります。また、データモデルが変更された際に、すべてのテスト内のデータ定義を修正する必要があり、保守コストが高くなるのです。

データ設計パターンの必要性

テストデータを効率的に管理するため、ソフトウェア工学では長年にわたって様々なパターンが確立されてきました。Factory、Builder、Fixture はその代表的なパターンであり、それぞれ異なる責務と利用シーンを持っています。

これらのパターンを理解し、適切に使い分けることで、テストデータの作成・管理・再利用が容易になり、テスト全体の品質が向上します。Vitest のような現代的なテストフレームワークでは、これらのパターンを TypeScript の型システムと組み合わせることで、さらに強力な設計が可能になるでしょう。

以下の図は、3 つのパターンがテストコードにおいてどのような位置づけにあるかを示しています。

mermaidflowchart TD
  test["テストコード"] -->|データ生成依頼| factory["Factory<br/>(データ生成)"]
  test -->|段階的構築| builder["Builder<br/>(段階的構築)"]
  test -->|固定データ取得| fixture["Fixture<br/>(固定データ)"]

  factory -->|生成| data1["テストデータ"]
  builder -->|構築| data2["カスタマイズ<br/>されたデータ"]
  fixture -->|提供| data3["定義済みデータ"]

  data1 --> assertion["アサーション"]
  data2 --> assertion
  data3 --> assertion

このように、Factory はデータ生成の自動化、Builder はカスタマイズ性の高い段階的なデータ構築、Fixture は固定されたテストデータの提供という、それぞれ異なる役割を担っています。

課題

テストデータ作成における共通課題

実際のプロジェクトでテストを書いていると、以下のような課題に直面することが多くあります。

データの重複定義による保守性の低下が挙げられます。各テストケース内で同じようなデータ構造を繰り返し定義すると、コードの重複が発生します。例えば、ユーザーオブジェクトを必要とする 100 個のテストがあった場合、それぞれでユーザーオブジェクトを定義していると、ユーザーモデルに新しいプロパティが追加された際に 100 箇所を修正しなければなりません。

複雑なオブジェクト構築のコストが高いという問題もあります。ネストした構造や多数のプロパティを持つオブジェクトを作成する際、テストごとにすべてのプロパティを指定するのは非効率的です。必要な部分だけをカスタマイズし、それ以外はデフォルト値を使えるような仕組みが求められます。

テストの意図が読み取りにくいケースも頻繁に発生します。テストデータの準備コードが長くなると、そのテストが何をテストしているのかが分かりにくくなり、可読性が低下するのです。

パターンの誤用による問題

Factory、Builder、Fixture の概念を知っていても、それらを正しく使い分けられないと、新たな問題が生じます。

すべてのデータを Fixture として定義してしまうと、柔軟性が失われます。固定されたデータセットだけでは、様々なエッジケースやバリエーションをテストすることが困難になるでしょう。

逆に、すべてを Factory で生成しようとすると、ランダム性が高すぎて再現性のないテストになる恐れがあります。テストの安定性を保つためには、適度な固定データも必要です。

Builder を過度に使用すると、かえってコードが冗長になり、シンプルなデータ作成にも多くのメソッド呼び出しが必要になってしまいます。

以下の図は、これらの課題がどのように相互に関連しているかを示しています。

mermaidflowchart LR
  issue1["データ重複定義"] --> problem1["保守性の低下"]
  issue2["複雑な構築コスト"] --> problem2["開発効率の低下"]
  issue3["テスト意図<br/>不明確"] --> problem3["可読性の低下"]
  issue4["パターン誤用"] --> problem4["柔軟性・<br/>再現性の喪失"]

  problem1 --> solution["責務分離と<br/>再利用性の向上"]
  problem2 --> solution
  problem3 --> solution
  problem4 --> solution

これらの課題を解決するためには、各パターンの特性を理解し、適切な場面で使い分ける必要があります。

解決策

Factory パターン:データ生成の自動化

Factory パターンは、テストデータの生成を自動化し、必要最小限のカスタマイズだけで様々なバリエーションのデータを作成できるようにするパターンです。

Factory の主な責務は以下の通りです。

#責務説明
1デフォルト値の提供すべてのプロパティに妥当なデフォルト値を設定
2部分的なカスタマイズ必要な部分だけを上書きできる仕組み
3一意性の保証ID やユニークな値の自動生成
4関連データの生成依存関係のあるデータの自動作成

Factory を使用することで、テストコードは簡潔になり、データモデルの変更にも柔軟に対応できるようになります。

Builder パターン:段階的な構築

Builder パターンは、複雑なオブジェクトを段階的に構築するためのパターンです。メソッドチェーンを使って、読みやすく意図が明確なコードでデータを組み立てることができます。

Builder の主な責務は以下の通りです。

#責務説明
1段階的な設定プロパティを 1 つずつ設定できるインターフェース
2可読性の向上メソッド名で設定内容を明示
3複雑な構築ロジック条件分岐や計算を含む構築処理のカプセル化
4不変性の保証各ステップで新しいインスタンスを返す設計

Builder は特に、テストケースの意図を明確に示したい場合や、複雑な前提条件を設定する必要がある場合に威力を発揮します。

Fixture パターン:固定データの提供

Fixture パターンは、あらかじめ定義された固定のテストデータを提供するパターンです。再現性が重要なテストや、特定のシナリオで共通して使用されるデータセットに適しています。

Fixture の主な責務は以下の通りです。

#責務説明
1固定データの定義予測可能な値を持つデータセット
2共有データの提供複数のテストで使い回せるデータ
3エッジケースの表現特殊な条件や境界値のデータ
4リファレンスデータドキュメントとしても機能するデータ

Fixture は、スナップショットテストや統合テストなど、データの一貫性が重要な場面で特に有用です。

以下の図は、3 つのパターンの使い分け基準を示しています。

mermaidflowchart TD
  start["テストデータが必要"] --> q1{"データは<br/>固定すべきか?"}

  q1 -->|はい| fixture["Fixture を使用"]
  q1 -->|いいえ| q2{"複雑な<br/>構築ロジックが<br/>必要か?"}

  q2 -->|はい| builder["Builder を使用"]
  q2 -->|いいえ| q3{"多数の<br/>バリエーションが<br/>必要か?"}

  q3 -->|はい| factory["Factory を使用"]
  q3 -->|いいえ| simple["直接定義"]

  fixture --> done["テストデータ完成"]
  builder --> done
  factory --> done
  simple --> done

この判断フローを参考にすることで、状況に応じた適切なパターンを選択できます。

具体例

Factory パターンの実装

まず、基本的な Factory の実装から見ていきます。TypeScript の型システムを活用して、型安全な Factory を作成しましょう。

型定義

typescript// ユーザーの型定義
interface User {
  id: string;
  name: string;
  email: string;
  age: number;
  role: 'admin' | 'user' | 'guest';
  createdAt: Date;
  updatedAt: Date;
}

この型定義は、テストで使用するユーザーオブジェクトの構造を表しています。Factory はこの型に準拠したデータを生成します。

Factory 関数の実装

typescript// Factory 関数の実装
let userIdCounter = 1;

export const createUser = (
  overrides: Partial<User> = {}
): User => {
  const id = `user-${userIdCounter++}`;
  const now = new Date();

  return {
    id,
    name: `Test User ${id}`,
    email: `${id}@example.com`,
    age: 25,
    role: 'user',
    createdAt: now,
    updatedAt: now,
    ...overrides, // 上書き可能
  };
};

この Factory 関数は、デフォルト値を持つユーザーオブジェクトを生成します。overrides パラメータを使って、必要な部分だけをカスタマイズできます。

Factory の使用例

typescriptimport { describe, it, expect } from 'vitest';
import { createUser } from './factories/userFactory';

describe('ユーザー関連の処理', () => {
  it('管理者ユーザーは特別な権限を持つ', () => {
    // 管理者ユーザーを作成(role だけカスタマイズ)
    const admin = createUser({ role: 'admin' });

    expect(admin.role).toBe('admin');
    expect(hasAdminPrivileges(admin)).toBe(true);
  });

  it('未成年ユーザーは制限がかかる', () => {
    // 未成年ユーザーを作成(age だけカスタマイズ)
    const minor = createUser({ age: 17 });

    expect(canAccessContent(minor)).toBe(false);
  });
});

このように、Factory を使うことでテストデータの作成が簡潔になり、テストの意図が明確になります。

関連データを含む Factory

typescript// 投稿の型定義
interface Post {
  id: string;
  title: string;
  content: string;
  author: User;
  publishedAt: Date | null;
  tags: string[];
}

// 投稿用 Factory
let postIdCounter = 1;

export const createPost = (
  overrides: Partial<Post> = {}
): Post => {
  const id = `post-${postIdCounter++}`;

  return {
    id,
    title: `Test Post ${id}`,
    content: 'This is test content.',
    author: createUser(), // 関連する User も自動生成
    publishedAt: new Date(),
    tags: ['test'],
    ...overrides,
  };
};

関連データも自動生成することで、複雑なデータ構造のテストも容易になります。

Factory の使用例(関連データ)

typescriptdescribe('投稿の公開状態', () => {
  it('未公開の投稿は一覧に表示されない', () => {
    // 未公開の投稿を作成
    const draft = createPost({ publishedAt: null });

    const publishedPosts = getPublishedPosts([draft]);

    expect(publishedPosts).toHaveLength(0);
  });

  it('特定の著者の投稿を取得できる', () => {
    // 特定の著者を作成
    const author = createUser({ name: 'John Doe' });

    // その著者の投稿を作成
    const post = createPost({ author });

    expect(post.author.name).toBe('John Doe');
  });
});

Builder パターンの実装

次に、Builder パターンを実装して、より複雑なデータ構築に対応します。

Builder クラスの実装

typescript// User Builder クラス
export class UserBuilder {
  private user: User;

  constructor() {
    // デフォルト値で初期化
    this.user = createUser();
  }

  // メソッドチェーン用のメソッド群
  withName(name: string): this {
    this.user.name = name;
    return this;
  }

  withEmail(email: string): this {
    this.user.email = email;
    return this;
  }

  withAge(age: number): this {
    this.user.age = age;
    return this;
  }

  withRole(role: User['role']): this {
    this.user.role = role;
    return this;
  }
}

Builder クラスは、各プロパティを設定するメソッドを提供し、メソッドチェーンで段階的にオブジェクトを構築できるようにします。

Builder の特殊なメソッド

typescriptexport class UserBuilder {
  // ... 既存のメソッド

  // 便利メソッド:管理者として設定
  asAdmin(): this {
    this.user.role = 'admin';
    this.user.email = this.user.email.replace(
      '@example.com',
      '@admin.example.com'
    );
    return this;
  }

  // 便利メソッド:未成年として設定
  asMinor(): this {
    this.user.age = 16;
    return this;
  }

  // 便利メソッド:古いアカウントとして設定
  asOldAccount(): this {
    const oldDate = new Date('2020-01-01');
    this.user.createdAt = oldDate;
    this.user.updatedAt = oldDate;
    return this;
  }

  // ビルド完了
  build(): User {
    return { ...this.user };
  }
}

複雑な設定パターンを便利メソッドとしてカプセル化することで、コードの再利用性と可読性が向上します。

Builder の使用例

typescriptimport { UserBuilder } from './builders/userBuilder';

describe('ユーザーのアクセス権限', () => {
  it('古い管理者アカウントは移行対象', () => {
    // Builder を使って段階的に構築
    const oldAdmin = new UserBuilder()
      .asAdmin()
      .asOldAccount()
      .withName('Legacy Admin')
      .build();

    expect(needsMigration(oldAdmin)).toBe(true);
  });

  it('未成年の一般ユーザーは制限付きアクセス', () => {
    const minorUser = new UserBuilder()
      .asMinor()
      .withName('Young User')
      .build();

    expect(getAccessLevel(minorUser)).toBe('restricted');
  });
});

Builder を使うことで、テストケースの前提条件が非常に読みやすく表現されています。

Post Builder の実装

typescript// Post Builder クラス
export class PostBuilder {
  private post: Post;

  constructor() {
    this.post = createPost();
  }

  withTitle(title: string): this {
    this.post.title = title;
    return this;
  }

  withContent(content: string): this {
    this.post.content = content;
    return this;
  }

  withAuthor(author: User): this {
    this.post.author = author;
    return this;
  }

  withTags(...tags: string[]): this {
    this.post.tags = tags;
    return this;
  }

  asDraft(): this {
    this.post.publishedAt = null;
    return this;
  }

  asPublished(date: Date = new Date()): this {
    this.post.publishedAt = date;
    return this;
  }

  build(): Post {
    return { ...this.post };
  }
}

Post Builder の使用例

typescriptimport { PostBuilder } from './builders/postBuilder';

describe('投稿のフィルタリング', () => {
  it('タグで投稿を絞り込める', () => {
    const techPost = new PostBuilder()
      .withTitle('TypeScript Tips')
      .withTags('typescript', 'programming')
      .build();

    const posts = filterByTag([techPost], 'typescript');

    expect(posts).toHaveLength(1);
  });
});

Fixture パターンの実装

最後に、Fixture パターンで固定データを管理します。

Fixture データの定義

typescript// ユーザー Fixture
export const userFixtures = {
  // 一般的な管理者
  adminUser: {
    id: 'user-admin-001',
    name: 'Admin User',
    email: 'admin@example.com',
    age: 35,
    role: 'admin' as const,
    createdAt: new Date('2023-01-01'),
    updatedAt: new Date('2023-01-01'),
  },

  // 一般的なユーザー
  regularUser: {
    id: 'user-regular-001',
    name: 'Regular User',
    email: 'user@example.com',
    age: 28,
    role: 'user' as const,
    createdAt: new Date('2023-06-01'),
    updatedAt: new Date('2023-06-01'),
  },

  // ゲストユーザー
  guestUser: {
    id: 'user-guest-001',
    name: 'Guest User',
    email: 'guest@example.com',
    age: 20,
    role: 'guest' as const,
    createdAt: new Date('2024-01-01'),
    updatedAt: new Date('2024-01-01'),
  },
} as const;

Fixture は定数として定義し、すべてのテストで同じデータを参照できるようにします。

投稿 Fixture の定義

typescript// 投稿 Fixture
export const postFixtures = {
  // 公開済みの技術記事
  publishedTechPost: {
    id: 'post-001',
    title: 'Getting Started with Vitest',
    content: 'Vitest is a modern testing framework...',
    author: userFixtures.adminUser,
    publishedAt: new Date('2024-01-15'),
    tags: ['vitest', 'testing', 'typescript'],
  },

  // 下書きの記事
  draftPost: {
    id: 'post-002',
    title: 'Upcoming Feature Announcement',
    content: 'We are working on...',
    author: userFixtures.regularUser,
    publishedAt: null,
    tags: ['announcement'],
  },
} as const;

Fixture の使用例

typescriptimport { userFixtures, postFixtures } from './fixtures';

describe('権限によるアクセス制御', () => {
  it('管理者は下書きにアクセスできる', () => {
    const { adminUser } = userFixtures;
    const { draftPost } = postFixtures;

    expect(canAccess(adminUser, draftPost)).toBe(true);
  });

  it('一般ユーザーは下書きにアクセスできない', () => {
    const { regularUser } = userFixtures;
    const { draftPost } = postFixtures;

    expect(canAccess(regularUser, draftPost)).toBe(false);
  });
});

Fixture を使うことで、テストの再現性が保証され、データの一貫性が保たれます。

3 つのパターンの組み合わせ

実際のプロジェクトでは、これら 3 つのパターンを組み合わせて使用することが多くなります。

組み合わせパターンの例

typescriptdescribe('複雑なシナリオのテスト', () => {
  it('特定のユーザーが作成した公開済み投稿の一覧を取得', () => {
    // Fixture から基本データを取得
    const baseAuthor = userFixtures.adminUser;

    // Factory で複数の投稿を生成
    const posts = [
      createPost({
        author: baseAuthor,
        title: 'First Post',
      }),
      createPost({
        author: baseAuthor,
        title: 'Second Post',
      }),
      // Builder で複雑な条件の投稿を作成
      new PostBuilder()
        .withAuthor(baseAuthor)
        .withTitle('Complex Post')
        .withTags('special', 'featured')
        .asPublished(new Date('2024-01-01'))
        .build(),
    ];

    const result = getPostsByAuthor(posts, baseAuthor.id);

    expect(result).toHaveLength(3);
  });
});

この例では、Fixture で基本となるユーザーを取得し、Factory で簡単な投稿を生成し、Builder で複雑な条件の投稿を作成しています。

ヘルパー関数の作成

typescript// テストヘルパー関数
export const createTestScenario = {
  // シナリオ1: 新規ユーザーの投稿
  newUserWithPost: () => {
    const user = createUser({
      createdAt: new Date(),
    });
    const post = new PostBuilder()
      .withAuthor(user)
      .asDraft()
      .build();

    return { user, post };
  },

  // シナリオ2: ベテランユーザーの公開投稿
  veteranUserWithPublishedPost: () => {
    const user = new UserBuilder().asOldAccount().build();
    const post = new PostBuilder()
      .withAuthor(user)
      .asPublished()
      .withTags('featured')
      .build();

    return { user, post };
  },
};

よく使うシナリオをヘルパー関数としてまとめることで、さらにテストコードが簡潔になります。

ヘルパー関数の使用例

typescriptdescribe('ユーザーと投稿の関係性テスト', () => {
  it('新規ユーザーの下書きは編集可能', () => {
    const { user, post } =
      createTestScenario.newUserWithPost();

    expect(canEdit(user, post)).toBe(true);
  });

  it('ベテランユーザーの公開投稿は人気', () => {
    const { user, post } =
      createTestScenario.veteranUserWithPublishedPost();

    expect(isPopular(post)).toBe(true);
  });
});

以下の図は、3 つのパターンを組み合わせた実際のデータフローを示しています。

mermaidsequenceDiagram
  participant Test as テストコード
  participant Fixture as Fixture
  participant Factory as Factory
  participant Builder as Builder
  participant Data as テストデータ

  Test->>Fixture: 基本ユーザー取得
  Fixture->>Test: adminUser

  Test->>Factory: 投稿を生成<br/>(author: adminUser)
  Factory->>Data: 投稿1, 投稿2 作成

  Test->>Builder: 複雑な投稿を構築
  Builder->>Builder: withAuthor()
  Builder->>Builder: withTags()
  Builder->>Builder: asPublished()
  Builder->>Data: 投稿3 作成

  Data->>Test: すべての投稿
  Test->>Test: アサーション実行

この図からわかるように、各パターンは連携しながら、効率的にテストデータを提供しています。

まとめ

本記事では、Vitest におけるテストデータ設計の 3 つの重要なパターン、Factory、Builder、Fixture について解説しました。

Factory パターンは、デフォルト値を持つデータを自動生成し、必要な部分だけをカスタマイズできる仕組みを提供します。多数のバリエーションが必要な場合や、関連データを含む複雑なオブジェクトを扱う際に最適です。

Builder パターンは、メソッドチェーンによる段階的なデータ構築を実現し、テストの意図を明確に表現できます。複雑な前提条件を設定する必要がある場合や、可読性を重視したい場合に有効でしょう。

Fixture パターンは、固定されたテストデータを提供することで、再現性と一貫性を保証します。スナップショットテストや、特定のエッジケースを検証する際に重宝します。

これら 3 つのパターンを適切に使い分け、組み合わせることで、保守性が高く、可読性に優れ、拡張しやすいテストコードを書くことができます。テストデータの設計に時間を投資することは、長期的なプロジェクトの品質向上に大きく貢献するでしょう。

ぜひ、ご自身のプロジェクトでこれらのパターンを試してみてください。テストコードの品質が向上し、開発体験が大きく改善されるはずです。

関連リンク