Junit에서 테스트 데이터 클리닝 하기

ds_chanin

·

2023. 2. 28. 23:53


들어가며

책 단위테스트에서 통합테스트를 할 경우 공유 의존성의 초기화에 대해 이야기를 하는 부분이 있다.

공유 의존성은 테스트 컨텍스트를 공유하는 여러 테스트를 같이 실행하면 말 그대로 공유하기 때문에 앞서 실행된 테스트가 뒤에 실행되는 테스트에 영향을 미친다는 것이다.

그렇기 때문에 이 의존성에 대한 초기화 작업이 필요하다. 그렇다면 공유 의존성의 초기화 작업은 언제하는게 좋을까?

이에 대한 대답도 책에서 답해주고 있다.

테스트를 수행하기 전에 초기화를 해주는 것이다.

필자는 예전부터 습관적으로 테스트가 끝나고 마지막에 테스트에 사용된 공유 데이터를 지우는 작업을 했다.

테스트를 수행하면서 필요한 셋업데이터를 만들고 테스트 대상을 검증하며 생긴 데이터가 있으니 이후에 생성된 데이터를 지워야 한다는 생각에서 테스트가 끝난 시점에 지운것 같다.

위와 같이 테스트가 끝나는 시점에 공유 의존성을 초기화하면서 경험한 치명적인 단점이 있다.

공유 의존성 초기화를 조금이라도 놓치면 다른 테스트에 영향을 끼치고 어떤 테스트에서 공유 의존성을 완벽하게 초기화 하지 못했는지 역 추적하기 너무 힘들다는 것이다.

반면 초기화를 테스트 수행 전에 해주면 테스트에 영향을 주지 않는 데이터가 남아 있을지언정, 테스트에 영향을 주는 데이터는 없는 상태로 시작하기 때문에 문제가 없다.

 

예시

공유 의존성으로 생각할 수 있는 것은 여러가지가 있다. 파일 시스템도 있고 메세지 버스도 있다. 그래도 가장 대중적이고 가장 흔하게 사용하는 공유 의존성은 뭐니뭐니해도 데이터베이스라고 할 수 있을것 같다.

따라서 이번 포스팅에서 데이터베이스를 예시로 필자가 경험한 방법중 가장 마음에 드는 방법으로 Junit 에서 테스트 데이터를 클리닝 했는지를 공유하고자 한다.

 

1단계 직접 지우기

테스트 데이터를 테스트 수행전에 제거해야 한다. Junit에서는 테스트를 수행하기 전에 항상 수행하는 단계를 BeforeEach로 정의하여 수행할 수 있도록 한다.

@SpringBootTest
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class MemberCreateTest(
    private val sut: MemberService, // system under test
    private val memberRepository: MemberRepository
) {

    @BeforeEach
    fun setUp() {
        memberRepository.deleteAllInBatch()
    }

    @DisplayName("test")
    @Test
    fun test_1() {
        //given
        val memberNumber = "123412341234"

        //when
        val result = sut.create(memberNumber)

        //then
        assertThat(result.memberNumber).isEqualTo(memberNumber)
    }
}

이렇게 하면 테스트 수행전에 테스트 데이터를 매번 제거해줄 수 있다. 그런데 꽤 귀찮다. 매번 테스트 클래스를 작성 할 때 마다 BeforeEach에 위 작업을 반복해야한다. 그리고 테스트 데이터의 대상이 되는 테이블의 수가 한두개가 아니고 매번 다를 수 있다.

그럴때마다 테이블을 청소해줄 코드를 따로 적어주는건 너무 귀찮고 짜증이 나는 반복작업이다. 조금 더 개발자의 신경을 덜 쓸 수 있도록 노력을 해보자.

 

2단계 전부 지우기

이 부분은 같이 토이 프로젝트 한 형이 적용한 방법을 보면서 배웠다. MySQL 기준으로 데이터베이스 테이블의 메타 데이터를 읽어 올 수 있다. 그리고 이 메타데이터에는 테이블 이름이 모두 있다. 테이블 이름만 있으면 반복문을 통해서 테이블 데이터를 전부 제거해버리면 되지 않겠는가?

fun TransactionTemplate.clear() {
    this.execute {
        val connection = this.dataSource.connection
        val statement = connection.createStatement()

        // Disable FK
        statement.execute("SET FOREIGN_KEY_CHECKS = 0")

        // Find all tables and truncate them
        val tables: MutableSet<String> = HashSet()
        val rs = statement.executeQuery("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES where TABLE_SCHEMA='public'")
        while (rs.next()) {
            tables.add(rs.getString(1))
        }
        rs.close()
        for (table in tables) {
            statement.executeUpdate("TRUNCATE TABLE $table")
        }

        // Enable FK
        statement.execute("SET FOREIGN_KEY_CHECKS = 1")
        statement.close()
        connection.close()
    }
}

(1) 테이블의 연관관계를 신경쓰지 않고 TRUNCATE를 수행하기 위해 연관관계를 체크하지 않도록 설정한다.

(2) INFORMATION_SCHEMA.TALBES에 테이블의 모든 정보가 있다. 전부 불러오도록 하자.

(3) 테이블을 전부 TRUNCATE하고 난 뒤 다시 연관관계를 체크하도록 원복한다.

그러면 아래와 같이 변경할 수 있다.

@SpringBootTest
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class MemberCreateTest(
    private val sut: MemberService, // system under test
    private val memberRepository: MemberRepository,
		private val transactionTemplate: TransactionTemplate,
) {

    @BeforeEach
    fun setUp() {
        transactionTemplate.clearAll()
    }

    @DisplayName("test")
    @Test
    fun test_1() {
        //given
        val memberNumber = "123412341234"

        //when
        val result = sut.create(memberNumber)

        //then
        assertThat(result.memberNumber).isEqualTo(memberNumber)
    }
}

그런데 아직도 불편하다. 테스트를 이해하는데 불필요한 의존성(TransactionTemplate)을 주입해서 똑같은 코드(transactionTemplate.clearAll())를 매번 사용해야 하지 않은가?

 

3단계 간편하게 지우기

Junit는 BeforeEachCallback 를 제공한다.

BeforeEachCallback defines the API for Extensions that wish to provide additional behavior to tests before each test is invoked.

BeforeEach를 정의할 수 있는 Extensions을 제공한다. 따라서 BeforeEachCallBack을 구현하고 @ExtendWith에 적어주면 모든 테스트에서 동일하게 사용할 수 있다.

class DataSweepExtension : BeforeEachCallback {
    override fun beforeEach(context: ExtensionContext?) {
        context?.let { SpringExtension.getApplicationContext(it) }
            ?.getBean("transactionTemplate")
            ?.let { it as TransactionTemplate }
            ?.run { clearAll() }
    }
}
@SpringBootTest
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@ExtendWith(DataSweepExtension::class)
class MemberCreateTest(
    private val sut: MemberService, // system under test
    private val memberRepository: MemberRepository,
) {

    @DisplayName("test")
    @Test
    fun test_1() {
        //given
        val memberNumber = "123412341234"

        //when
        val result = sut.create(memberNumber)

        //then
        assertThat(result.memberNumber).isEqualTo(memberNumber)
    }
}

이렇게 하면 테스트 클래스에 @ExtendWith(DataSweepExtension::class)를 적어줌으로써 불필요한 의존성을 주입할 필요도 동일한 코드를 매번 적어줄 필요도 없다.

 

4단계 조금 더 개선하기

그런데 좀 더 개선하고 싶은 부분이 보인다. @ExtendWith(DataSweepExtension::class)를 매번 적어주는것도 꽤 귀찮다. 이것도 중복이라고 볼 수 있지 않은가?

개발하고 있는 프로덕트를 위한 테스트 컨텍스트 커스텀 어노테이션을 만들어서 사용하도록 하자.

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@SpringBootTest(classes = [FooApplication::class])
@ExtendWith(DataSweepExtension::class)
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
annotation class FooSpringTest

이제 Spring 컨텍스트와 함께 테스트 데이터를 BeforeEach 단계에서 제거할 수 있도록 하나의 어노테이션으로 묶어버리자.

@FooSpringTest
class MemberCreateTest(
    private val sut: MemberService, // system under test
    private val memberRepository: MemberRepository,
) {

    @DisplayName("test")
    @Test
    fun test_1() {
        //given
        val memberNumber = "123412341234"

        //when
        val result = sut.create(memberNumber)

        //then
        assertThat(result.memberNumber).isEqualTo(memberNumber)
    }
}

이제 모든 테스트는 개발자가 만든 어노테이션만 달아주면 된다.