1. はじめに
プロジェクトを進めていると、「テストコードを書かなければならない」と分かっていても、スケジュールに追われて後回しにしてしまうことがよくあります。私も同じでした。バックエンドサービスを開発する中で、初期段階ではテストコードを書かず、Insomniaや画面を通じて手動で機能を確認していました。しかし、サービスの規模が大きくなり、ドメイン間の関係が複雑になるにつれて、1つの機能を修正した際に別の機能へ影響を与えるケースが次第に増えていきました。手動テストだけでこれらすべての経路を毎回確認するのは難しく、コードを修正した後に予期しない機能でエラーが発生することもしばしばありました。こうした経験をきっかけに統合テストを本格的に追加するようになり、この記事では、その中でもテストデータを準備するFixtureの設計に焦点を当て、経験を共有したいと思います。
2. テスト環境の概要
統合テスト環境は次のように構成しました。@SpringBootTestとH2インメモリDBを利用してアプリケーション全体のコンテキストをロードしつつ、外部DBなしでテストを実行します。@MockBeanを利用して外部サービスへの依存をMockとして分離します。また、テストごとにTRUNCATEを実行してすべてのテーブルを空にし、テスト間のデータ干渉を防ぎます。テストクラスでこのベースクラスを継承することで、一貫性のあるテスト環境を構成しました。
@SpringBootTest(classes = ServiceBootApplication.class)
public abstract class FeatureH2BootTestSupport {
@MockBean
protected OtherServiceClient otherServiceClient;
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void clearDatabase() {
jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY FALSE");
List<String> tableNames = jdbcTemplate.queryForList(
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'PUBLIC'",
String.class
);
for (String tableName : tableNames) {
jdbcTemplate.execute("TRUNCATE TABLE " + tableName);
}
jdbcTemplate.execute("SET REFERENTIAL_INTEGRITY TRUE");
}
}
3. Fixtureが必要だった理由
統合テストで最も煩雑だったのは、テストデータの準備でした。私が担当していたサービスはマルチテナンシー情報を管理する特性上、上位階層の情報が存在しないとエラーが発生したり、動作させられなかったりする機能が数多く存在しました。例えば、「Actorに役割を付与する機能」をテストするには、次のような前提データがすべてDBに存在していなければなりません。
Pavilion → Cineroom → Stage → StageRoleSe
└→ Subscription → Episode → AssignedEpisode → StagedEpisode
このデータをテストごとに一つずつ作成すると、テストコードが過度に長くなり、データ生成ロジックも重複します。また、各エンティティのIDが上位エンティティのIDを基に生成される構造だったため、IDを手動で組み合わせるには時間がかかるだけでなく、ミスも多く、可読性も低下していました。
4. Fixtureの設計
4.1 FixtureDefaults — 定数の一元管理
まず行ったのは、テストで使用するすべてのIDとデフォルト値を1か所に集約することでした。
public class FixtureDefaults {
public static final String SQUARE_ID = "TST";
public static final String PAVILION_ID = SQUARE_ID + ":1";
public static final String CINEROOM_ID = PAVILION_ID + ":1";
public static final String STAGE_ID = CINEROOM_ID + "-1";
public static final String SUBSCRIPTION_ID = PAVILION_ID + "-S00AA";
public static final String EPISODE_ID = PAVILION_ID + "-" + EPISODE_CODE;
public static final String ASSIGNED_EPISODE_ID =
AssignedEpisode.genId(CINEROOM_ID, EPISODE_ID);
public static final String STAGED_EPISODE_ID =
StagedEpisode.genId(STAGE_ID, ASSIGNED_EPISODE_ID);
// ...
}
IDを定数として一元管理することで、正しいIDデータをあらかじめ用意しておき、複数のテストから利用できるようになったため、テスト前のデータ設定にかかる時間を短縮できました。また、ID体系に変更があった場合も1か所だけ修正すればよいため、テストコード作成の負担が軽減されました。さらに、テストコードでは`FixtureDefaults.PAVILION_ID`のように参照できるため、可読性も向上しました。
4.2 ドメイン別Fixtureクラス
FixtureクラスはAggregateごとに作成しました。各Fixtureは@Componentとして登録し、Springコンテキストから注入して使用します。
Fixtureクラスのメソッドは、役割に応じて大きく3種類に分けて設計しました。
genメソッドは、FixtureDefaults定数を基にデフォルトのエンティティオブジェクトを生成します。DBには保存せず、オブジェクトだけを返すため、テストのGiven節でデフォルトのエンティティを作成した後、特定のフィールドを変更して目的の状態に設定する際に利用できます。staticメソッドとして宣言しているため、Springコンテキストからの注入なしでどこからでも呼び出せます。
genMoreメソッドは、デフォルトのエンティティ以外に同じ型の追加エンティティが必要であり、1つまたは2つのフィールド変更では対応できない場合に使用する任意のメソッドです。例えば、「2つのPavilionが存在する状況」をテストする場合、デフォルトのPavilionはgenPavilion()で、追加のPavilionはgenMorePavilion()で生成できます。パラメータとして識別値(sequence、osidなど)を受け取り、デフォルトのエンティティと衝突しないようにします。
createメソッドは、genメソッドで生成したデフォルトのエンティティを実際のDBに保存します。内部でgenメソッドを呼び出した後、Storeを通じて保存するため、テストではworkspaceFixture.createPavilion()の1行だけでDBにデフォルトのPavilionを準備できます。ほとんどのテストではこのメソッドだけで十分であり、データをカスタマイズする必要がある場合にのみgenメソッドを直接使用します。
このように役割を分けた理由は、テストコードの簡潔さと柔軟性を同時に確保するためです。単純なテストはcreateメソッドの1行で完了し、複雑なシナリオではgenメソッドでオブジェクトを作成して自由に操作した後、直接保存できます。
@Component
@RequiredArgsConstructor
public class WorkspaceFixture {
// workspace aggregate domain store
private final PavilionStore pavilionStore;
// gen
public static Pavilion genPavilion() {
Pavilion pavilion = new Pavilion();
pavilion.setId(FixtureDefaults.PAVILION_ID);
// ...
return pavilion;
}
// genMore
public static Pavilion genMorePavilion(String sequence, String osid) {
String pavilionId = FixtureDefaults.SQUARE_CODE + “:” + sequence;
Pavilion pavilion = new Pavilion();
pavilion.setId(pavilionId);
pavilion.setOsid(osid);
// ...
return pavilion;
}
// create
public void createPavilion() {
Pavilion pavilion = genPavilion(); // FixtureDefaults 기반 기본값
pavilionStore.create(pavilion);
}
}
5. 実際のテストでのFixtureの活用
テストコードはGiven-When-Thenパターンで作成しています。このパターンにおいてFixtureはGiven段階、つまりテストに必要な前提データを準備する役割を担います。実際のテストコードを通じて、Fixtureがどのように活用されるのか見ていきましょう。
5.1 createメソッドの活用 — 基本シナリオ
@BeforeEach
void setUp() {
workspaceFixture.createPavilion();
workspaceFixture.createCineroom();
workspaceFixture.createStage();
subscriptionFixture.createSubscription();
subscriptionFixture.createEpisode();
subscriptionFixture.createAssignedEpisode();
subscriptionFixture.createStagedEpisode();
}
@Test
@DisplayName("revokeEpisode는 assignedEpisode와 관련된 stagedEpisode를 모두 제거한다")
void revokeEpisode_removesAssignedAndStagedEpisodes() {
// Given
// When
flow.revokeEpisode(FixtureDefaults.ASSIGNED_EPISODE_ID);
// Then — Store로 DB 상태 직접 검증
assertThat(assignedEpisodeStore.exists(FixtureDefaults.ASSIGNED_EPISODE_ID))
.isFalse();
assertThat(stagedEpisodeStore.exists(FixtureDefaults.STAGED_EPISODE_ID))
.isFalse();
}
最も一般的なケースです。@BeforeEachで共通の前提データをcreateメソッドで準備し、各テストのGiven節でそのテストにのみ必要な追加データをcreateします。Fixture導入前であれば、setUpメソッドに各エンティティを直接生成し、フィールドを設定するコードが数十行にわたって並んでいたことでしょう。createメソッドを利用すると、このデータ準備のプロセスをメソッド呼び出し1行にまとめられるため、setUpが「どのようなデータが準備されるのか」を宣言的に示す一覧になります。また、FixtureDefaultsに定義された定数を基にエンティティを生成するため、IDのタイプミスや階層関係の設定漏れといったミスなく、安全かつ正確にデータを設定できます。
このように共通データの設定がsetUpで完了すると、個別テストのGiven節には、そのテスト固有の条件だけを残すか、完全に空にすることができます。上の例のようにGiven節が空であれば、「setUpのデフォルト状態がそのままこのテストの前提条件である」ことが一目で分かり、テストの核心であるWhen-Thenに自然と集中できるようになります。
5.2 genメソッドの活用 — データのカスタマイズ
@Test
@DisplayName("Dormant 상태의 subscription에 subscribed 이벤트가 오면 Active로 변경한다")
void subscribed_reactivatesSubscription_whenDormantState() {
// Given — gen으로 만들고 상태를 커스터마이징
Subscription subscription = SubscriptionFixture.genSubscription();
subscription.setState(SubscriptionState.Dormant); // 기본값(Active)을 변경
subscriptionStore.create(subscription);
// When
flow.subscribed(createSubscribeRequestSdo(FixtureDefaults.PAVILION_ID));
// Then
assertThat(subscriptionStore.retrieve(FixtureDefaults.SUBSCRIPTION_ID).isActive())
.isTrue();
}
デフォルト値とは異なる状態のデータが必要な場合は、genメソッドでオブジェクトを生成した後、必要な値を設定して直接保存します。このようにgenメソッドは、「基本的な骨格はFixtureが提供し、テストシナリオに合わせた細かな調整はテストコードで直接行う」という柔軟性を提供します。
5.3 genMoreメソッドの活用 — 複数データのシナリオ
同じ型のエンティティが複数必要なテストでは、genMoreメソッドを使用します。
@Test
void stageEpisode_stagesNewAndStashesRemovedStagedEpisodes() {
subscriptionFixture.createAssignedEpisode();
subscriptionFixture.createStagedEpisode();
// Given — 추가 Episode 생성
Episode otherEpisode = SubscriptionFixture.genEpisodeMore("E00AB");
episodeStore.create(otherEpisode);
AssignedEpisode otherAssigned =
SubscriptionFixture.genAssignedEpisodeMore(
FixtureDefaults.CINEROOM_ID, otherEpisode);
assignedEpisodeStore.create(otherAssigned);
// When — 새 Episode로 교체
flow.stageEpisode(FixtureDefaults.STAGE_ID,
List.of(otherAssigned.getId()));
// Then — 기존 것은 제거되고 새로운 것만 존재
assertThat(stagedEpisodeStore.exists(FixtureDefaults.STAGED_EPISODE_ID))
.isFalse();
assertThat(stagedEpisodeStore.exists(
StagedEpisode.genId(FixtureDefaults.STAGE_ID, otherAssigned.getId())))
.isTrue();
}
genMoreはパラメータとして識別値を受け取り、デフォルトFixtureとIDが衝突しないエンティティを生成します。Episodeドメインでは、IDの変更が多くのフィールドに影響するため、先ほどの5.2の方法ではなく、genMoreメソッド方式を選択しました。これにより、「既存データと新しいデータが共存する状況」を簡単に作成できます。
6. まとめ
最初はFixtureと定数データを構成する作業に負担を感じるかもしれません。しかし、一度セットアップしてしまえば、テストコードの作成に大きく役立ちます。初期にFixtureDefaultsとドメイン別Fixtureの仕組みを整える前は、テストコードを書くたびにデータ設定ロジックをコピーし、データを調整するためにかなりの時間を費やしていました。しかし、Fixtureの仕組みを整えてからは、前提データの準備に時間をかける必要がなくなりました。
最近では、構成したFixtureを活用し、AIツールの支援を受けながら主要なFlow/Seekに対するテストコードを一括で作成し、少しずつ精緻に改善しています。今後も新機能の開発時には、Fixtureベースのテスト構造を継続的に拡張していく予定です。
Rosalyn