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 つのパターンを適切に使い分け、組み合わせることで、保守性が高く、可読性に優れ、拡張しやすいテストコードを書くことができます。テストデータの設計に時間を投資することは、長期的なプロジェクトの品質向上に大きく貢献するでしょう。
ぜひ、ご自身のプロジェクトでこれらのパターンを試してみてください。テストコードの品質が向上し、開発体験が大きく改善されるはずです。
関連リンク
articleVitest テストデータ設計技術:Factory / Builder / Fixture の責務分離と再利用
articleVitest Matchers 技術チートシート:`expect.extend` で表現力を拡張する定石
articleVitest モノレポ技術セットアップ:pnpm / Nx / Turborepo で超高速化する手順
articleVitest モック技術比較:MSW / `vi.mock` / 手動スタブ — API テストの最適解はどれ?
articleVitest ESM/CJS 混在で `Cannot use import statement outside a module` が出る技術対処集
articleVitest モジュールモック技術の基礎と応用:`vi.mock` / `vi.spyOn` を極める
articleKubernetes で WebSocket:Ingress(NGINX/ALB) 設定とスティッキーセッションの実装手順
articleStorybook × Design Tokens 設計:Style Dictionary とテーマ切替の連携
articleWebRTC Simulcast 設計ベストプラクティス:レイヤ数・ターゲットビットレート・切替条件
articleSolidJS コンポーネント間通信チート:Context・イベント・store の選択早見
articleWebLLM 中心のクライアントサイド RAG 設計:IndexedDB とベクトル検索の組み立て
articleShell Script の set -e が招く事故を回避:pipefail・サブシェル・条件分岐の落とし穴
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来