픽스처가 두 번째 도메인이 되지 않으려면

테스트가 지옥이 되는 첫 순간은, 의외로 실행이 느려질 때가 아니다. Given을 다섯 번째 쓸 때다.

게시글 댓글 테스트 하나를 추가하는데, 댓글을 쓰려면 게시글이 있어야 하고, 게시글이 있으려면 작성자가 있어야 하고, 작성자는 OAuth provider가 있어야 하고… 이 체인이 테스트 본문을 50줄 잠식한다. 그리고 다음 테스트도, 그 다음 테스트도 똑같다.

@Test
fun `댓글이 정상 추가된다`() {
    val author = User(
        email = "test@example.com",
        nickname = "tester",
        password = "encoded",
        role = Role.USER,
        provider = AuthProvider.LOCAL,
        createdAt = LocalDateTime.now(),
        updatedAt = LocalDateTime.now(),
    )
    val post = Post(
        author = author,
        title = "제목",
        content = "내용",
        category = Category.TECH,
        viewCount = 0,
        likeCount = 0,
        createdAt = LocalDateTime.now(),
    )
    // 진짜 검증하려는 건 여기 아래에 있는데, 여기까지 도달하기 전에 지친다.
    // ...
}

카카오페이 블로그에서 본 "Given 지옥"이라는 표현이 정확하다(아래 참고에 링크).

흔한 첫 대응은 헬퍼 함수다. createTestUser(), createPost() 같은 걸 모듈 안에 만든다. 며칠은 살 만해진다. 근데 두 가지가 곧 부딪힌다. 우선 멀티 모듈 환경에서는 app-api와 app-batch가 같은 도메인을 만지는데도 헬퍼는 따로 만든다. 다음으로 헬퍼가 점점 도메인을 흉내내기 시작한다. "관리자면 Notice 카테고리, 아니면 General" 같은 분기가 슬며시 헬퍼 안으로 들어온다.

이 글은 그 다음 단계, 헬퍼를 졸업하고 픽스처를 구조로 잡을 때 우리 팀이 정한 규칙을 정리한 글이다.

 

java-test-fixtures의 위치

가장 먼저 정해야 할 건 위치다. 헬퍼처럼 메인 소스 옆에 떠다니지 않고, 별도 소스셋에 둔다. Gradle이 제공하는 java-test-fixtures 플러그인을 쓰면 자동으로 src/testFixtures/kotlin 디렉토리가 생긴다.

// app-api/build.gradle.kts
plugins {
    id("java-test-fixtures")
}

dependencies {
    testFixturesImplementation("org.springframework.boot:spring-boot-starter")
    testFixturesImplementation(project(":domain"))
}

다른 모듈에서는 이렇게 끌어다 쓴다.

// app-batch/build.gradle.kts
testImplementation(testFixtures(project(":app-api")))

H2처럼 테스트 시점에만 필요한 런타임 의존성도 비슷한 방식으로 전파할 수 있다 (testFixturesRuntimeOnly + testRuntimeOnly(testFixtures(...))).

flowchart LR
    subgraph api[":app-api"]
        api_main["src/main"]
        api_fix["src/testFixtures<br/>PostFixture / UserFixture"]
        api_test["src/test"]
        api_test -.uses.-> api_fix
    end
    subgraph batch[":app-batch"]
        batch_test["src/test"]
    end
    domain[":domain"]
    api_main --> domain
    api_fix -.depends on.-> domain
    batch_test -.testFixtures(:app-api).-> api_fix

이 한 단계가 왜 중요하냐면, 헬퍼는 메인 코드에서 실수로 import해도 빌드가 깨지지 않는다. 픽스처는 별도 소스셋이라 메인이 부르려고 하면 컴파일 에러가 난다. 즉 "이건 테스트 전용이다"가 빌드 시스템 차원에서 강제된다. 작은 차이 같지만, 시간이 지나서 픽스처가 100개를 넘기면 이 강제가 실수를 줄여준다.

 

Fixture의 네이밍

규칙은 단순한데, 안 지키기도 쉽다.

엔티티 픽스처는 PostFixture처럼 클래스명에 Fixture 접미사를 붙이고, 안에는 create()와 (필요할 때만) createWithId()를 둔다.

object PostFixture {
    const val DEFAULT_TITLE = "기본 제목"

    fun create(
        author: User = UserFixture.create(),
        title: String = DEFAULT_TITLE,
        content: String = "본문",
        category: Category = Category.TECH,
    ): Post = Post(author, title, content, category)

    fun createWithId(id: Long, title: String = DEFAULT_TITLE): Post =
        create(title = title).also { ReflectionTestUtils.setField(it, "id", id) }
}

createWithId는 일부러 분리한다. ID는 일반 생성 시점에 노출하지 않는 게 좋고, ID가 필요한 테스트(거의 Repository 테스트뿐이다)만 명시적으로 호출하게 만든다. 이렇게 하면 "왜 이 테스트가 ID에 의존하지?"가 코드 읽는 사람에게 바로 보인다.

요청 DTO 픽스처는 메서드명이 의도를 말해주게 한다.

object PostRequestFixture {
    fun createRequest(title: String = "새 글", content: String = "본문") =
        PostCreateRequest(title, content)

    fun updateRequest(title: String = "수정") = PostUpdateRequest(title)

    // Validation 검증 시 자주 부른다. 이름 자체가 "왜 invalid한지"를 말해야 한다.
    fun invalidRequest_titleEmpty() =
        PostCreateRequest(title = "", content = "본문")

    fun invalidRequest_titleTooLong() =
        PostCreateRequest(title = "x".repeat(201), content = "본문")
}

invalidRequest_titleEmpty()라는 이름 하나로, 컨트롤러 테스트의 given이 한 줄로 끝난다.

@Test
fun `제목이 비어있으면 400`() {
    val request = PostRequestFixture.invalidRequest_titleEmpty()
    mockMvc.post("/posts") { jsonContent(request) }
        .andExpect { status { isBadRequest() } }
}

응답 DTO에는 픽스처를 잘 안 만든다. 만들기 시작하면 테스트가 응답 구조 변경에 둔감해지는 일이 종종 있어서, 우리는 "같은 구조의 응답 검증이 4번 이상 반복될 때"를 임계로 잡았다. 그 전까진 그냥 assertThat(response.title).isEqualTo(...)로 직접 푼다.

 

도메인 테스트에서는 픽스처를 쓰지 않는다

이 규칙이 처음엔 좀 어색했다. 픽스처를 만들어두고 안 쓰는 게 낭비처럼 느껴졌으니까. 그런데 한 번 사고가 난 적이 있어서 규칙으로 못 박았다.

Post 엔티티의 좋아요 카운트 증가 메서드에 사실은 버그가 있었는데, PostFixture.create()도 같은 생성자 호출을 쓰고 있어서 픽스처가 만든 Post 객체가 그 버그 상태를 그대로 갖고 있었다. 테스트는 픽스처가 만든 객체를 받아 메서드를 호출하고 결과를 확인하니까, 두 군데가 같이 망가진 채로 그린이 떴다. 정확히 며칠을 잃었는지는 기억이 안 난다.

그래서 이렇게 정했다.

@Test
fun `좋아요 시 likeCount가 1 증가한다`() {
    val author = UserFixture.create()  // 협력자는 픽스처 OK
    val post = Post(author, "t", "c", Category.TECH)  // SUT는 직접

    post.like()

    assertThat(post.likeCount).isEqualTo(1)
}

 

핵심은 "SUT는 직접, 협력자는 픽스처". Post의 생성자가 변경되면 이 테스트는 깨진다. 그게 좋은 신호다. 픽스처 뒤에 숨어 있으면 그 신호를 놓친다.

다른 레이어는 어떻게 쓰는지를 표로 정리하면 대충 이렇다.

레이어 픽스처 사용도

도메인 단위 (SUT) 안 씀
도메인 협력 객체
Repository 잘 씀 (createWithId 활용)
Service 단위 적극 씀
Controller WebMvc Request DTO 픽스처 위주
통합 테스트 도메인 픽스처 + 실제 Repository

이 표는 가이드일 뿐이고, 사실 팀마다 사정이 다를 거다. 우리도 통합 테스트에서 어디까지 픽스처를 쓸지는 의견이 갈리는 영역이 아직 있다.

 

픽스처가 두 번째 도메인이 되는 신호들

가장 경계해야 할 패턴 몇 가지를 정리해두면 다음과 같다.

비즈니스 규칙이 픽스처 안으로 기어들어오는 경우.

object PostFixture {
    fun create(author: User = UserFixture.create()): Post {
        // "관리자면 Notice"라는 정책이 픽스처 안에 들어옴
        val category = if (author.role == Role.ADMIN) Category.NOTICE else Category.GENERAL
        return Post(author, "...", "...", category)
    }
}

이 시점부터 픽스처 변경은 도메인 정책 변경과 같은 무게가 된다. 픽스처는 "유효한 기본값"까지만 책임진다고 못 박지 않으면, 1년 뒤에 픽스처 안에 if 문이 5개 들어 있는 자기가 짠 코드를 보게 된다(경험담이다).

시나리오를 픽스처에 박는 메가 픽스처도 같은 종류의 함정이다. "댓글 5개에 좋아요 10개 달린 게시글"을 한 메서드로 만들어두면 처음엔 편한데, 어느 테스트가 정확히 무엇을 검증하는지 메서드명만 보고 알 수 없게 된다. 시나리오 조립은 테스트 본문이 직접 해야 한다. 그래야 그 테스트만의 setup이 코드에 그대로 드러난다.

응답 픽스처를 너무 일찍 만드는 것도 위험하다. 위에서 잠깐 말했지만, 응답 구조가 바뀌어도 테스트가 멀쩡히 통과하는 회귀가 생긴다. 응답 픽스처를 만든다면 그건 사실상 "이 API의 스펙 문서"가 되는 셈이고, PR 리뷰에서 함께 변경 대상으로 올려야 한다.

 

한계

이 컨벤션이 다 좋은 건 아니다. java-test-fixtures 소스셋이 추가되면 처음 보는 사람은 디렉토리 구조에 한 번 당황한다. 헬퍼 메서드 하나 만드는 것보다 픽스처 클래스를 처음 만드는 비용도 크다. 작은 단일 모듈 프로젝트라면 굳이 이 단계로 갈 필요가 없을 수도 있다. 우리는 모듈이 두 개 이상에서 같은 도메인을 만지기 시작했을 때를 임계점으로 잡았는데, 이 기준도 절대적인 건 아니다.

레이어별 사용 규칙은 결국 PR 리뷰에서 자주 짚어지는 항목이 된다. 자동화하기 어렵고, 한동안은 사람이 의식해서 지켜야 한다. 솔직히 우리도 아직 잘 안 지켜지는 케이스가 종종 나온다.

 

마지막으로

픽스처를 도입하면 Given이 한 줄로 줄어들고, 테스트 본문이 의도만 말하게 된다. 거기까지는 명확한 이득이다. 그 다음부터는 픽스처가 시간이 지나며 부패하지 않게 관리하는 게 진짜 일이다.

도메인 모델이 바뀌었는데 픽스처 기본값은 그대로인지, "유효한 기본값" 안에 어느새 분기가 슬며시 들어와 있지 않은지, 응답 픽스처가 박제되어 있는지. 한 번 만들고 끝나는 게 아니라, 분기마다 한 번씩 봐줘야 한다.

여기까지 정리하면서도 사실 응답 DTO 픽스처는 우리 팀에서 아직 100% 합의가 안 된 영역이다. 이 부분은 다음 글에서 다시 정리해볼 생각이다.


참고