본문 바로가기
Web Development/Testing

[Testing] 단위 테스트 - 좋은 단위 테스트 작성하기

by saltyzun 2024. 8. 11.

주변으로부터 TDD 를 도입해야 한다거나 테스트 커버리지를 높여야 한다는 당위적 주장은 비교적 쉽게 접할 수 있다. 더 나아가 개발자 채용공고에 이러한 내용을 써놓은 회사들도 굉장히 많다. 그러나 이러한 주장에 대한 논리적 근거나 구체적이고 명확한 실천 방법을 접하기는 쉽지 않다. 최근 읽은 ⌜Unit Testing⌟(블라디미르 코리코프) 는 좋은 단위 테스트란 무엇이며, 이를 어떻게 작성할 수 있는지를 체계적이고 명확하게 설명해준다. 


 

1. 단위 테스트란?


책의 저자는 단위 테스트를 정의하기 위해 '런던파'와 '고전파' 두 견해를 소개하며, 앞으로 전개할 내용은 '고전파' 관점에서 정의한 단위테스트를 다루겠다고 이야기 한다. '고전파' 관점에서 해석한 단위 테스트의 중요한 세 가지 속성은 다음과 같다.

 

  • 단일 동작 단위를 검증하고
  • 빠르게 수행하고
  • 다른 테스트와 별도로 처리한다

그리고 이러한 주요 속성 중 한 가지라도 충족하지 못한다면, 해당 테스트는 통합테스트라고 볼 수 있다. 가령, 데이터베이스에 접근하는 테스트는 빠른 수행이 어렵고(동일 메모리 내 객체 간 통신에 비하면 오랜 시간이 걸린다), 데이터베이스 상태에 따라 병렬적인 테스트가 어려울 수 있으므로 단위테스트라고 보기 어렵다. 

 

2. 좋은 단위 테스트란?


좋은 단위 테스트란 다음의 4가지 요소를 충족하는 단위테스트를 의미한다. 개인적으로는 이 4가지 요소를 처음 봤을 때 이들이 정확히 무엇을 의미하는 지 유추해보기 쉽지 않았다. 그래서 이해하기 쉬운 표현으로 함께 정리해보았다.

 

  • 회귀 방지 = 코드 수정 이후 버그가 발생하는 것을 얼마나 막아주는가
  • 리팩터링 내성 = 코드 수정 이후 기능은 잘 동작하고 있음에도 테스트가 깨지는 일이 없는가
  • 빠른 피드백 = 테스트 속도가 빠른가
  • 유지보수성 = 테스트 코드가 얼마나 잘 읽히고, 실행하기 쉬운가

 

3. 최종 결과를 검증하는 테스트를 작성하자


이 4가지 요소 중 저자가 가장 공들여 설명하는 건 리팩터링 내성에 관한 부분이다. 먼저 저자는 리팩터링을 다음과 같이 정의한다.

리팩터링이란 식별할 수 있는 동작을 수정하지 않고 기존 코드를 변경하는 것을 의미한다.

 

번역이 '식별할 수 있는 동작'으로 되어있지만 이해한 바로는 '퍼블릭 메서드에 대해 시그니처 변경 없이 내부 구현을 변경하는 것'으로 풀어볼 수 있다. 결국, 리팩터링 내성이 약한 테스트란 퍼블릭 메서드 내부를 수정했는데, 기능이 정상적으로 동작하고 있음에도 거짓으로 실패하는 테스트이다. 리팩터링 내성이 약한 단위 테스트는 우리가 단위테스트를 작성함으로써 달성하고자 목표를 방해한다. 먼저 우리가 단위테스트를 작성하는 건 다음 두 가지 목표를 달성하기 위함이다.

 

  • 기능이 제대로 동작하지 않을 때 테스트가 조기에 경고를 제공한다
  • 코드 변경에 따른 버그가 발생하지 않을 것이라 확신하게 된다

그러나 리팩터링 내성이 약한 테스트는 다음과 같은 문제를 야기한다. 따라서 리팩터링 내성이 약한 테스트를 작성하는 건 단위 테스트를 작성함으로써 얻을 수 있는 이점을 무력화 한다고 할 수 있다.

 

  • 테스트가 타당한 이유 없이 실패하면 점점 실패에 무뎌진다
  • 테스트에 대한 신뢰가 부족해지면 리팩터링이 줄어든다

이런 문제는 테스트가 테스트 대상 시스템(SUT)의 구현 세부 사항과 많이 결합되어 있기 때문에 발생한다. 이러한 문제를 해결하기 위해서는 테스트 코드의 Then 절에서 SUT가 제공하는 최종 결과를 검증해야 한다. 간단한 예시를 통해 살펴보자.

data class Message(
    val header: String,
    val body: String
)

interface IRenderer {
    fun render(message: Message): String
}

class MessageRenderer : IRenderer {

    val subRenderers: List<IRenderer> = listOf(HeaderRenderer(), BodyRenderer())
    
    //메시지를 전달 받아 HTML 메시지로 렌더링 해준다
    override fun render(message: Message): String {
        return subRenderers
            .map { it.render(message) }
            .reduce { str1, str2 -> str1 + str2 }
    }
}

class HeaderRenderer() : IRenderer {
    override fun render(message: Message): String {
        return "<h1>${message.header}</h1>"
    }
}

class BodyRenderer() : IRenderer {
    override fun render(message: Message): String {
        return "<p>${message.body}</p>"
    }
}

 

이 코드에서 식별할 수 있는 동작은 String 을 반환하는 render(messgae) 이다. 이제 리팩터링 내성 관점에서 좋은 단위테스트와 나쁜 단위테스트에 대해 살펴보자.

class MessageRendererUnitTest : FunSpec({

    test("적절한 subRenderers 를 가져야 한다") {
        val sut = MessageRenderer()

        val subRenderers = sut.subRenderers

        subRenderers.size shouldBe 2
        subRenderers[0].shouldBeInstanceOf<HeaderRenderer>()
        subRenderers[1].shouldBeInstanceOf<BodyRenderer>()
    }

    test("전달받은 메시지를 HTML 양식으로 렌더링해야 한다") {
        val message = Message("title", "content")
        val sut = MessageRenderer()

        val result = sut.render(message)

        result shouldBe "<h1>title</h1><p>content</p>"
    }

})

 

처음 두 테스트 코드를 실행해보면 모두 성공한다. 그러나 첫 번째 테스트는 나쁜 테스트다. 식별할 수 있는 동작을 검증하지 않고, 테스트 대상 시스템의 구현 상세 내용을 검증하고 있기 때문이다. 이렇게 테스트를 작성하고 훗날 render() 메서드의 구현을 조금 이라도 변경한다면 이 테스트는 금방 깨지게 될 것이다. 예를 들어 코드를 작성할 때 미래의 확장을 고려해 subRenderers 를 사용했지만, 오랜 시간 새로운 Renderer를 만들 일이 생기지 않아 아래와 같이 구현을 간소화 했다고 생각해보자.

class MessageRenderer : IRenderer {
    override fun render(message: Message): String {
        return "<h1>${message.header}</h1><p>${message.body}</p>"
    }
}

 

render() 의 동작은 이전과 동일에도 불구하고, 이전에 작성한 첫 번째 테스트 코드로 인해 컴파일 오류가 발생한다. 더 이상 SUT 에 subRenderers 가 존재하지 않기 때문이다. 반면 두 번째 테스트는 여전히 성공한다. 따라서 두 번째 테스트 코드는 리팩터링 내성이 강하다.

 

4. 이상적인 테스트를 작성하려면?


좋은 단위 테스트의 처음 세 가지 특성(회귀 방지, 리팩터링 내성, 빠른 피드백)은 상호 배타적이다. 이 세가지 특성 중 두 가지를 극대화하는 테스트를 만들기는 매우 쉽지만, 나머지 특성 한 가지를 희생해야만 가능하다. (중략) 안타깝게도 세 가지 특성 모두 완벽한 점수를 얻어서 이상적인 테스트를 만드는 것은 불가능하다.

 

모든 요소를 충족하는 단위테스트를 작성하는 건 불가능하다. 결국 우리는 리팩터링 내성을 최대화한 상태에서 회귀 방지와 빠른 피드백 두 특성 사이에서 선택적인 테스트를 작성해야 한다. 리팩터링 내성을 포기할 수 없는 이유는 다른 두 특성과 달리 이진 선택이기 때문이다. 즉, 리팩터링 내성은 있거나 없거나 둘 중 하나이지 중간 단계가 없다. 이를 이해하기 쉽게 정리한 책의 그림은 다음과 같다.

 

엔드 투 엔드 테스트의 예로 포스트맨을 이용해 API 테스트를 작성한다고 생각해보자. 이러한 테스트는 리팩터링 내성과 회귀 방지에는 뛰어나지만 피드백을 받기 까지 굉장히 오랜 시간이 걸릴 것이다. 반면 아주 간단한 단위테스트는 리팩터링 내성을 갖고 빠른 피드백을 주겠지만 프로그램 버그를 찾아내기는 어려울 수 있다. 가령 위에 작성한 MessageRenderer 의 테스트가 성공한다고 한들, 실제 기획자가 원한 건 <i>footer</i> 까지 포함되어 있는 HTML 페이지 였다면 우리가 작성한 프로그램은 버그가 존재하는 것이다. 따라서 간단한 단위 테스트는 회귀 방지 기능을 제공하지 않는다. 

 

5. 정리


좋은 단위테스트는 회귀 방지, 리팩터링 내성, 빠른 피드백, 유지보수성 이라는 네 가지 특성을 갖는다. 이 중 가장 중요한 리팩터링 내성을 갖기 위해서는 메서드의 결과를 검증하는 테스트 코드를 작성해야 한다. 만약 테스트 코드가 메서드의 세부 구현을 검증한다면 리팩터링 과정에서 쉽게 깨지는 테스트가 될 것이며, 이는 테스트 코드에 대한 신뢰도를 떨어트리게 될 것이다. 이상적인 테스트란 리팩터링 내성을 최대화 하면서 회귀 방지와 빠른 피드백의 특성을 적절히 선택한 테스트이다. 회귀 방지 특성이 높은 테스트로는 엔드 투 엔드 테스트가 있고, 빠른 피드백 특성이 높은 테스트로는 간단한 단위 테스트가 있다.

반응형

댓글