테스트 의존성 관리로 높은 품질의 테스트 코드 유지하기

테스트 의존성 관리로 높은 품질의 테스트 코드 유지하기

양권성 · 토스페이먼츠 Server Developer
2022년 6월 9일

테스트 코드는 애플리케이션 코드 못지 않게 높은 품질을 유지해야 합니다.

낮은 품질(이해하기 어려운 코드, 여기저기 깨져있는 테스트)의 테스트는 유지보수가 어렵고 기술부채에 못지 않은 부채로 다가옵니다.

그래서 테스트 코드의 높은 품질을 유지하기 위해 다양한 Builder, Helper 클래스들이 나오게 되고, 테스트 전용으로 의존성을 추가하기도 합니다. 하지만 이 또한 관리의 대상이며 제대로 관리하지 않으면 중복 코드와 얼기설기 얽힌 의존성 지옥을 맛보게 됩니다.

이 포스트에서는 Gradlejava-test-fixtures 플러그인을 사용하여 위 문제를 해결하는 방법에 대해 설명합니다.

TL;DR

  1. Gradle의 java-test-fixtures 플러그인을 사용하면 테스트용으로 작성한 Builder, Helper 클래스 등등을 다른 모듈과 공유할 수 있습니다.
  2. 추가적으로 해당 모듈의 테스트 전용 의존성까지 전파시킬 수 있어 각 모듈마다 불필요한 테스트 전용 의존성들을 일일이 추가할 필요가 사라집니다.

프로젝트 구조

예제를 이해하기 쉽게 하기 위해 프로젝트 구조(멀티 모듈)를 가정하고 이야기를 진행하겠습니다.

  • domain 모듈: 핵심 비즈니스 로직에만 관심이 있는 모듈, 외부(써드파티 라이브러리, DB, HTTP 등등)에 의존하지 않고 온전히 비즈니스 로직에만 관심을 갖고 있는 모듈로써 어떠한 의존성도 가지지 않습니다.
  • db 모듈: 데이터의 CRUD(저장, 조회, 수정, 삭제)에만 관심이 있는 모듈, 클라이언트의 요구사항을 처리하기 위해 domain 모듈에 의존(implementation)하고 있습니다.

이미지 출처: Gradle Docs

// db 모듈의 build.gradle.kts
dependencies {
    implementation(project(":domain"))
    // 기타 디펜던시들...
}
  • application 모듈: 클라이언트의 요청을 받아 처리하는 모듈, 클라이언트의 요구사항을 처리하기 위해 domain 모듈에 의존(implementation)하고 있으며, application 모듈에 main 함수가 존재하기 때문에 데이터 조작(저장, 조회 등등)을 위해 db 모듈에도 의존(runtimeOnly)하고 있습니다.

이미지 출처: Gradle Docs

// application 모듈의 build.gradle.kts
dependencies {
    implementation(project(":domain"))
    runtimeOnly(project(":db"))
    // 기타 디펜던시들...
}

테스트 전용으로 작성한 클래스를 다른 모듈에게 노출시키기

domain 모듈에 아래와 같은 객체가 있다고 가정해보겠습니다.

class Order(
    val id: String,
    val description: String,
    val amount: Long
)

테스트에서 위 클래스를 사용해야할 때 객체를 생성하려고 생각하면 매우 번거로워집니다. (공감이 되지 않는다면 파라미터가 10개 정도 된다고 생각해보면 됩니다.)

이 때 모든 파라미터에 기본값을 넣는 절충안도 존재하는데, 객체의 필수값이 기본값으로 채워진 채 객체가 생성되면 불안정하게 동작할 수 있습니다. 누군가의 실수로 프로덕션에서 객체의 필수값 중 일부가 기본값으로 생성된다면 의도치 않은 동작을 하게 될 수도 있기 때문입니다.

따라서 테스트에서 사용할 목적으로 디폴트 값이 들어간 빌더 객체를 만들게 됩니다.

data class OrderBuilder(
    val id: String = "",
    val description: String = "",
    val amount: Long = 0L
) {
    fun build(): Order {
        return Order(
            id = id,
            description = description,
            amount = amount
        )
    }
}

하지만 빌더는 테스트에서만 사용해야하기 때문에 domain/src/test 디렉토리 밑에 생성해야합니다. test가 아닌 main 디렉토리 밑에 존재하게 되면 프로덕션 코드에서 누가 해당 빌더로 온전치 않은 상태의 객체를 생성하고 사용하는 실수를 할 수 있기 때문입니다.

이런 Builder나 Helper 같이 테스트 전용으로 만든 클래스들을 해당 클래스가 존재하는 모듈(domain 모듈)이 아닌 해당 모듈을 의존하고 있는 다른 모듈(domain 모듈에 의존하고 있는 application, db 모듈)의 테스트에서 사용하고 싶다는 니즈가 생겼다고 가정해보겠습니다.

하지만 application과 db 모듈에서 domain 모듈에 의존하고 있다고 할지라도 각 모듈의 테스트에서는 OrderBuilder를 import 할 수 없습니다.

build된 jar 파일의 압축을 해제했을 때 나오는 결과물을 보면 main 디렉토리 밑에 있는 Order 클래스는 포함하고 있지만, test 디렉토리 밑에 있는 OrderBuilder 클래스는 포함하고 있지 않기 때문입니다.

어떻게 생각해보면 당연한 결과입니다.

domain 모듈을 테스트하는데 필요한 정보들은 프로덕션 코드에서는 필요가 없고, 그렇기 때문에 굳이 불필요하게 테스트 전용 클래스들까지 포함시킬 필요는 없기 때문입니다.

이제 문제를 해결하기 위한 간단한 방법 두 가지를 떠올리게 됩니다.

  1. 각 모듈의 test 디렉토리에 빌더를 복사/붙여넣기 합니다. 하지만 이는 코드의 중복을 유발하며 Order 클래스의 변경사항이 생겼을 때 각 모듈에 존재하는 OrderBuilder 클래스를 각각 수정해야한다는 번거로움이 존재합니다.
  2. Builder/Helper를 모아놓은 별도의 test-data 같은 테스트 전용 모듈을 만들고, 각 모듈에서 test-data 클래스에 의존(testImplementation)하게 만듭니다.
// application/db 모듈의 build.gradle.kts
dependencies {
    // 기타 디펜던시들...
    testImplementation(project(":test-data"))
}

하지만 이는 실제 소스코드(Order는 domain 모듈에 존재)와 거리가 멀어지게 만들어(OrderBuilder는 test-data 모듈에 존재) 응집도가 떨어지는 모듈이 나오게 됩니다.

또한 테스트 전용임에도 불구하고 test-data 모듈의 클래스들을 외부에 노출시켜야하기 때문에 test 디렉토리가 아닌 main 디렉토리에 둬야 하는 점도 약간의 혼란(’main 디렉토리에 있으니까 프로덕션 레벨에서 사용하는 건가…?’ 하는 정도의)을 유발할 수 있습니다.

둘 다 좋은 방법은 아니라는 생각이 듭니다. 이 문제를 해결하기 위한 빛과 소금과 같은 존재가 있습니다.

구세주: java-test-fixtures 플러그인

Gradle에는 이런 문제를 해결하고자 java-test-fixtures 플러그인이 존재합니다.

우선 외부에 노출시키고자 하는 Builder나 Helper 클래스가 존재하는 domain 모듈의 build.gradle.kts 파일에 플러그인을 추가해주고 프로젝트를 reload 하면 됩니다.

// domain 모듈의 build.gradle.kts
plugins {
    // 기타 플러그인들...
    `java-test-fixtures`
}

java-test-fixtures 플러그인이 적용된 모듈에서 디렉토리를 생성하려고 하면 IntelliJ IDEA에서는 testFixtures 디렉토리가 자동완성 됩니다.

그럼 아까 생성했던 OrderBuilder 클래스는 test가 아닌 testFixtures 디렉토리로 이동시켜준 후 build를 했을 때 수행되는 Gradle Task들을 보게 되면 testFixture 관련된 task가 추가된 걸 알 수 있습니다.

./gradlew :domain:build

...
> Task :domain:compileTestFixturesKotlin
> Task :domain:compileTestFixturesJava NO-SOURCE
> Task :domain:processTestFixturesResources NO-SOURCE
> Task :domain:testFixturesClasses UP-TO-DATE
> Task :domain:testFixturesJar

그리고 빌드된 결과물을 보면 test-fixtures.jar가 추가된 걸 볼 수 있습니다.

plain.jar는 plain에, test-fixtures.jar는 test에 각각 풀었는데 OrderBuilder는 test에 존재하는 걸 보니 test-fixtures.jar에 존재한다는 걸 알 수 있습니다.

여기서 또 java-test-fixtures 플러그인의 장점이 나오게 되는데 다른 모듈에서 불필요하게 여기는 클래스들(test 디렉토리에 있는 @Test 어노테이션이 붙은 테스트 코드들 등등)은 노출되지 않고, 필요한 클래스들(testFixtures 디렉토리에 있는 Helper나 Builder 클래스 등등)만 노출된다는 점입니다.

하지만 이렇게 했다고 해서 아직 application이나 db 모듈에서 OrderBuilder를 import 할 수 있는 건 아닙니다. application과 db 모듈에서는 plain.jar에 의존하고 있는 것이지, test-fixtures.jar에 의존하고 있는 건 아니기 때문입니다.

따라서 application과 db 모듈에서 test-fixtures.jar에 의존하도록 각 모듈의 build.gradle.kts에 추가해줘야합니다.

// application/db 모듈의 build.gradle.kts
dependencies {
    implementation(project(":domain"))
    testImplementation(testFixtures(project(":domain")))
    // 기타 디펜던시들...
}

위와 같이 의존성을 추가해줘야 비로소 application과 db 모듈의 테스트 코드에서도 domain 모듈의 testFixtures에 존재하는 OrderBuilder를 사용할 수 있게 됩니다.

이해하기 쉽게 모듈 간의 디렉토리 관계를 좀 더 세분화해서 표현해보았습니다.

테스트 전용으로 추가한 의존성을 다른 모듈에게 노출시키기

db 모듈의 통합테스트를 위해 인메모리 DB인 H2를 테스트 전용으로 의존성을 추가했다고 가정해보겠습니다.

이미지 출처: Gradle Docs

// db 모듈의 build.gradle.kts
dependencies {
    // 기타 디펜던시들...
    testRuntimeOnly("com.h2database:h2")
}

이 상태에서 db 모듈의 통합테스트를 돌리게 되면 H2 DB를 사용하여 실제 DB와 격리된 환경에서 테스트가 돌아가는 것을 볼 수 있습니다.

그리고 application 모듈은 아래와 같이 db 모듈에 의존하고 있기 때문에 통합테스트를 작성할 때도 인메모리 DB를 쓸 것이라 희망하게 되는데 실제로 테스트를 짜고 돌려보면 그렇지 않습니다.

// application 모듈의 build.gradle.kts
dependencies {
    // 기타 디펜던시들...
    runtimeOnly(project(":db"))
}

gradle 모듈의 디펜던시를 보게 되면 db 모듈의 testRuntimeClasspath에는 H2가 존재하지만, application 모듈의 testRuntimeClasspath에 존재하는 db 모듈에는 H2가 존재하지 않기 때문입니다.

이 때도 application 모듈의 build.gradle.kts에 H2를 의존성으로 추가하는 방법이 있겠지만 관심사 문제가 있습니다. application 모듈의 관심사는 ‘어떻게 클라이언트와 커뮤니케이션해서 요구사항을 만족시킬 것인가?’이지 세부적인 내용(’저장소는 무엇을 쓸까? 데이터는 어디서 저장하고 어떻게 불러올까?’ 같은)은 관심사가 아닙니다. 따라서 H2를 직접적으로 의존성을 추가하는 순간 관심사 분리가 제대로 되지 않게 됩니다.

이 문제를 해결하기 위해 또 우리의 구세주 java-test-fixtures 플러그인이 필요합니다.

testFixturesComplieClasspath와 testFixturesRuntimeClasspath

우선 외부에 테스트 전용 의존성(H2)을 노출시키고 싶은 db 모듈에 java-test-fixtures 플러그인을 추가하고, testRuntimeOnly로 추가했던 H2 의존성을 testFixturesRuntimeOnly로 변경해줘야 합니다.

// db 모듈의 build.gradle.kts
plugins {
    // 기타 플러그인들...
    `java-test-fixtures`
}

dependencies {
    // 기타 디펜던시들...
    testFixturesRuntimeOnly("com.h2database:h2")
}

그리고 나서 다시 db 모듈의 디펜더시를 보면 기존에 보지 못했던 testFixturesCompileClasspathtestFixturesRuntimeClasspath가 추가된 게 보입니다.

사실 두 가지 클래스패스는 java-test-fixtures 플러그인을 추가하기만 해도 추가되는 클래스패스입니다.

여기서 눈여겨봐야할 것은 기존에는 testRuntimeClasspath에만 존재하던 H2 의존성이 testFixturesRuntimeClasspath에도 추가된 점입니다.

이에 대한 해답은 java-test-fixtures 플러그인 문서를 보다보면 아래와 같은 내용에 나오게 됩니다.

Test fixtures are configured so that: • they can see the main source set classes • test sources can see the test fixtures classes

두 번째로 나와있는 테스트 소스(test 디렉토리에 있는 내용들)에서 test fixture(testFixtures 디렉토리에 있는 내용들)에 있는 내용을 참조(can see)할 수 있도록 구성된다는 내용이 핵심입니다.

따라서 testFixturesRuntimeOnly로만 추가(testFixturesRuntimeClassPath)했지만 testRuntimeOnly로도 추가된 것과 동일한 효과(testRuntimeClasspath에 추가된 효과)를 같이 보게 됩니다. 따라서 db 모듈의 통합테스트를 돌렸을 때는 여전히 H2 DB를 사용하게 됩니다.

하지만 H2를 db 모듈에 testFixturesRuntimeClasspath에 추가했지만, 여전히 application 모듈의 testRuntimeClasspath를 보면 아직도 db 모듈에는 H2 의존성이 추가되지 않은 모습을 볼 수 있습니다.

그 이유는 application 모듈의 build.gradle.kts를 보면 알 수 있습니다.

// application 모듈의 build.gradle.kts
dependencies {
    // 기타 디펜던시들...
    runtimeOnly(project(":db"))
}

이미지 출처: Gradle Docs

바로 정답은 runtimeOnly 키워드에 있습니다.runtimeOnly로 추가한 디펜던시는 testRuntimeClasspath에도 추가됩니다. (물론 runtimeClasspath에도 추가됩니다.)하지만 testRuntimeClasspath에 추가된 의존성은 외부 모듈에 노출되지 않는다는 특성이 있습니다.

따라서 우리는 db 모듈의 testRuntimeClasspath가 아닌 testFixturesRuntimeClasspath에 추가된 의존성들에 주목해야하며 해당 의존성들이 추가되도록 application 모듈의 build.gradle.kts를 수정해야 합니다.

// application 모듈의 build.gradle.kts
dependencies {
    // 기타 디펜던시들...
    runtimeOnly(project(":db"))
    testRuntimeOnly(testFixtures(project(":db")))
}

마지막 부분이 db 모듈의 testFixturesRuntimeClasspath에 있는 의존성을 testRuntimeOnly로 추가(testRuntimeClasspath에 추가)하는 내용입니다.

이제 application 모듈의 testRuntimeClasspath에도 db 모듈의 testFixutresRuntimeClasspath에 있는 H2 의존성이 추가된 걸 볼 수 있습니다.

이 상태에서 application 모듈의 통합테스트를 돌리더라도 H2 DB를 사용하는 걸 볼 수 있습니다.

결론

테스트 코드는 실제 프로덕션에 영향을 미치지 않으므로 신경을 덜 쓰기 마련입니다. 그러다보면 중복이 난무하고 관심사 분리도 제대로 되지 않고 의존성 지옥에 빠지기 십상입니다. 하지만 테스트 코드는 우리의 소프트웨어를 좀 더 나은 설계로 유도하며 안정감도 주기 때문에 품질을 관리해야하는 소프트웨어임에는 분명합니다.

혹시 해당 포스트를 보고 ‘어, 그거 그렇게 하는 거 아닌데…’라는 생각이 들었다면 토스페이먼츠에 와서 신나게 토론할 준비가 되어있으니 언제든 환영합니다!

댓글 0댓글 관련 문의: toss-tech@toss.im
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.