Fixture 기반 테스트 데이터 구성

Fixture 기반 테스트 데이터 구성

1. 들어가며

프로젝트를 진행하다 보면 "테스트 코드를 작성해야 한다"는 것을 알면서도 일정에 쫓겨 미루게 되는 경우가 많습니다. 저 역시 마찬가지였습니다. 백엔드 서비스를 개발하면서, 초기에는 테스트 코드 없이 Insomnia나 화면을 통해 수동으로 기능을 확인하고 있었습니다. 하지만 서비스의 규모가 커지고 도메인 간의 관계가 복잡해지면서, 하나의 기능을 수정할 때 다른 기능에 영향을 미치는 경우가 점점 늘어났습니다. 수동 테스트만으로는 이 모든 경로를 매번 확인하기 어려웠고, 코드 수정 이후 예상치 못한 기능에 오류가 발생하는 경우를 종종 만나곤 했습니다. 이러한 경험을 계기로 통합 테스트를 본격적으로 추가하게 되었고, 이 글에서는 그중에서도 테스트 데이터를 준비하는 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와 기본값을 한 곳에 모으는 것이었습니다.

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 체계에 변경 사항이 있을 경우, 한 곳만 수정하면 되기 때문에 테스트 코드 작성에 대한 부담이 줄어들었습니다. 한편으로 테스트 코드 작성 시, `FixtureDefaults.PAVILION_ID`와 같이 참조할 수 있어 가독성이 높아졌습니다.

4.2 도메인별 Fixture 클래스

Fixture 클래스는 Aggregate별로 만들었습니다. 각 Fixture는 @Component로 등록하여 Spring 컨텍스트에서 주입받아 사용합니다.

Fixture 클래스의 메서드는 역할에 따라 크게 세 가지로 나누어 설계했습니다.

gen 메서드는 FixtureDefaults 상수를 기반으로 기본 엔티티 객체를 생성합니다. DB에 저장하지 않고 객체만 반환하므로, 테스트의 Given 절에서 기본 엔티티를 만든 뒤 특정 필드를 수정하여 원하는 상태로 세팅할 때 활용할 수 있습니다. static 메서드로 선언하여 Spring 컨텍스트 주입 없이도 어디서든 호출할 수 있습니다.

genMore 메서드는 기본 엔티티 외에 동일 타입의 추가 엔티티가 필요하지만 한두 개의 필드 변경으로 커버가 불가능한 경우를 위한 선택적 메서드입니다. 예를 들어 "두 개의 Pavilion이 존재하는 상황"을 테스트해야 할 때, 기본 Pavilion은 genPavilion()으로, 추가 Pavilion은 genMorePavilion()으로 생성할 수 있습니다. 파라미터로 구분 값(sequence, osid 등)을 받아 기본 엔티티와 충돌하지 않도록 합니다.

create 메서드는 gen 메서드로 생성한 기본 엔티티를 실제 DB에 저장합니다. 내부적으로 gen 메서드를 호출한 뒤 Store를 통해 저장하므로, 테스트에서 workspaceFixture.createPavilion() 한 줄이면 DB에 기본 Pavilion이 준비됩니다. 대부분의 테스트에서는 이 메서드만으로 충분하며, 데이터를 커스터마이징해야 하는 경우에만 gen 메서드를 직접 사용합니다.

이렇게 역할을 분리한 이유는, 테스트 코드의 간결함과 유연함을 동시에 확보하기 위해서입니다. 단순한 테스트는 create 메서드 한 줄로 끝나고, 복잡한 시나리오는 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 메서드를 활용하면 이러한 데이터 준비 과정이 메서드 호출 한 줄로 압축되므로, 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

Site footer