TestCode

단위 테스트의 함정

djawnstj 2025. 7. 18. 03:55

테스트의 꽃은 단위 테스트라고 생각한다. 그런데 과연 단위 테스트가 만능일까?

흔히 찾아볼 수 있는 테스트 피라미드이다. 단위 테스트가 든든히 받쳐주어야 비용이 비싼 테스트들에서 자원을 아낄 수 있다.

나 역시 단위 테스트에서 대부분 테스트 커버리지를 다 채우고 통합 테스트부터는 해피케이스만 채우는 편이다.

 

class UserService(private val userRepository: UserRepository) {
    fun foo(userName: String, userAge: Int) {
        val exists = userRepository.existsByUserName(userName)
        
        check (!exists)
        
        userRepository.save(User(name = userName, age = userAge))
    } 
}

가령 이런 클래스를 테스트한다고 해보자.

 

class UserServiceTest {
    @Test
    fun `단위 테스트 1`() {
        // val userRepository: UserRepository = mockk()
        val userRepository: UserRepository = FakeUserRepository()
        val cut = UserService(userRepository)

        cut.foo("userName", 20)

        // verify { userRepository.save(/* */) }
        val actual = userRepository.findById(1)
        assertThat(actual).isNotNull
    }

    @Test
    fun `단위 테스트 2`() {
        val userRepository: UserRepository = FakeUserRepository()
        userRepository.save(User("userName", 20))
        val cut = UserService(userRepository)

        assertThatThrownBy { cut.foo("userName", 20) }
    }
}

class UserServiceIntegrationTest(
    private val userService: UserService
) {
    @Test
    fun `해피 케이스 통합 테스트`() {
        assertDoesNotThrow { userService.foo("userName", 20) }
    }
}

이런 형태로 단위 테스트가 내부 행위를 검증 후 통합 테스트로 정상 케이스를 확인할 수 있다.

그런데 이런 테스트만으로 신뢰를 줄 수 있을까?

 

화이트 박스 VS 블랙 박스 테스트

흔히 단위 테스트를 화이트 박스 테스트라고 한다. 내부 동작을 훤히 들여다보듯 세팅 후 원하는 결과를 검증하기 때문이다.

그런 의미에서 위의 단위 테스트는 나쁘지 않다고 생각한다.

문제는 통합 테스트이다.

통합 테스트가 블랙 박스인 이유는 여러 객체와 상호작용을 하면서 결과가 만들어지거나 상태가 변하는데, 테스트하고자 하는 대상 외의 객체는 제어할 수 없기 때문이다.

상호작용하는 객체도 테스트를 진행하기 때문에 내부에서 상호작용은 신뢰해도 된다고 생각했었다. 하지만 실제 런타임에서의 협력 객체가 달라질 수 있고 단위 테스트는 내가 만들어 둔 가정에 의해 결과가 나왔다. 그렇기 때문에 실제 런타임과 최대한 비슷한 환경에서의 테스트도 필요하다고 생각한다.

class UserServiceIntegrationTest(
    private val userService: UserService,
    private val userRepository: UserRepository
) {
    @Test
    fun `통합 테스트 1`() {
        userRepository.save(User("userName", 20))

        assertThatThrownBy { userService.foo("userName", 20) }
    }

    @Test
    fun `통합 테스트 2`() {
        userService.foo("userName", 20)
        
        val actual = userRepository.findById(1)
        assertThat(actual).isNotNull
            .extracting("userName", "age")
            .contains("userName", 20)
    }
}

어쩌면 단위 테스트에서 저장 후 최종 상태 검증이 오히려 무의미할 수 있다는 생각까지 들었다. (물론 여전히 단위 테스트 우선이긴 하다)

 

사견으로 변하지 않는건 기능 요구사항뿐이라고 생각한다. 코드는 언제든지 변할 수 있고 그렇기 때문에 테스트 대역 없이도 언제나 일관된 실패/성공이 이루어지는지 검증이 필요하다고 생각한다.

반응형