1. Introduction
When progressing through a project, there are many times when we realize that "we need to write test code" but end up postponing it due to scheduling pressures. I was no exception. While developing backend services, initially, I was manually verifying functionalities through Insomnia or screens without any test code. However, as the scale of the service grew and the relationships between domains became more complex, the instances of modifying one functionality affecting another increased. It became increasingly difficult to verify all these paths with manual testing alone, and I often encountered unexpected errors in functionalities after code modifications. This experience prompted me to formally add integration testing, and in this article, I would like to share my experiences, focusing on the design of Fixtures for preparing test data.
2. Overview of the Test Environment
The integration test environment was set up as follows. We utilize @SpringBootTest and H2 in-memory DB to load the entire application context, executing tests without an external DB. By using @MockBean, we isolate dependencies on external services by mocking them. Additionally, we perform TRUNCATE before each test to clear all tables and prevent data interference between tests. We configured a consistent testing environment by having the test classes inherit from this base class.
@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. Reasons for Needing Fixtures
The most cumbersome part of integration testing was preparing the test data. Due to the nature of the service I was responsible for, which manages multi-tenancy information, there were numerous functionalities that would result in errors or could not be executed without the existence of higher-level information. For example, to test the functionality of "assigning roles to actors," all of the following prerequisite data must exist in the DB.
Pavilion → Cineroom → Stage → StageRoleSe
└→ Subscription → Episode → AssignedEpisode → StagedEpisode
If we create this data manually for each test, the test code becomes excessively lengthy, and data creation logic is duplicated. Moreover, since the IDs of each entity were generated based on the IDs of higher entities, it not only took time to manually combine the IDs but also increased the likelihood of errors and reduced readability.
4. Fixture Design
4.1 FixtureDefaults — Centralized Management of Constants
The first thing I did was centralize all IDs and default values used in the tests in one location.
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);
// ...
}
By centrally managing IDs as constants, I could prepare the correct ID data in advance and use it across multiple tests, reducing the time spent on setting up data before tests. Additionally, if there were any changes in the ID system, only one place needed to be modified, which reduced the burden of writing test code. On the other hand, while writing test code, being able to reference `FixtureDefaults.PAVILION_ID` improved readability.
4.2 Domain-specific Fixture Classes
I created Fixture classes for each Aggregate. Each Fixture is registered as a @Component and injected from the Spring context.
The methods in the Fixture class were designed to be divided into three main categories based on their roles.
The gen method generates base entity objects based on FixtureDefaults constants. Since it returns objects without saving them to the DB, it can be utilized in the Given clause of the test to create base entities and then modify specific fields to set them up in the desired state. It is declared as a static method, allowing it to be called from anywhere without the need for Spring context injection.
The genMore method is an optional method for cases where additional entities of the same type are needed beyond the basic entity, but cannot be covered by changing one or two fields. For example, when testing a situation where 'two Pavilions exist', the basic Pavilion can be generated with genPavilion() and the additional Pavilion with genMorePavilion(). It takes parameters such as distinguishing values (sequence, osid, etc.) to avoid collisions with the basic entity.
The create method saves the basic entity created by the gen methods to the actual DB. It internally calls the gen method and saves it through Store, so a single line of workspaceFixture.createPavilion() in the test prepares the basic Pavilion in the DB. In most tests, this method is sufficient, and the gen method is used directly only when data needs to be customized.
The reason for separating these roles is to ensure both conciseness and flexibility of the test code. Simple tests end with a single line of the create method, while complex scenarios can create objects with the gen method, manipulate them as desired, and then save them directly.
@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. Using Fixture in Actual Tests
The test code is written in the Given-When-Then pattern. In this pattern, the Fixture is responsible for the Given stage, preparing the prerequisite data needed for the test. Let's look at how the Fixture is utilized through actual test code.
5.1 Using the create Method - Basic Scenario
@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();
}
This is the most common case. In @BeforeEach, common prerequisite data is prepared using the create method, and additional data needed for each test is created in the Given clause of that test. Before the introduction of Fixture, the setUp method would have had dozens of lines listing code that directly created each entity and set fields. By using the create method, this data preparation process is condensed into a single method call, making setUp a declarative list showing 'what data is prepared'. Additionally, it safely sets the correct data without mistakes like ID typos or missing hierarchical relationships since entities are created based on constants defined in FixtureDefaults.
When common data setup is completed in setUp, only the unique conditions for that test remain in the Given clause, or it can even be left empty. If the Given clause is empty as in the above example, it can be easily seen that 'the basic state of setUp is the premise of this test', allowing you to focus naturally on the core of the test, the When-Then.
5.2 Using the gen Method - Data Customization
@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();
}
When data in a different state from the default is needed, objects are created using the gen method and the desired values are set for saving directly. In this way, the gen method provides the flexibility that 'the basic framework is provided by Fixture, but fine-tuning according to the test scenario is done directly in the test code'.
5.3 Using the genMore Method - Multiple Data Scenarios
In tests where multiple entities of the same type are needed, the genMore method is used.
@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 takes distinguishing values as parameters and creates entities that do not collide with the basic Fixture and ID. The Episode domain affects fields with many ID changes, so the genMore method was chosen instead of the previous 5.2 method. This allows for easily creating situations where 'existing data and new data coexist'.
6. Conclusion
At first, setting up Fixture and constant data can feel cumbersome. However, once set up, it greatly aids in writing test code. Before initially establishing FixtureDefaults and the domain-specific Fixture system, significant time was spent copying data setup logic and adjusting data every time I wrote test code. However, since establishing the Fixture system, there has been no need to spend time preparing prerequisite data.
Recently, we have utilized the Fixture we built to create test codes for major Flow/Seek with the help of AI tools, and we are gradually refining it. In the future, we plan to continuously expand the Fixture-based testing structure when developing new features.
Rosalyn