개발 공부 기록하기/20. 일반

단위 테스트 - 단위 테스트의 목표와 고전파, 런던파

lannstark 2022. 2. 14. 23:50

1부. 더 큰 그림

1. 단위 테스트의 목표

  • 단위 테스트에 시간을 투자할 때는 항상 최대한 이득을 얻도록 노력해야 하며, 테스트에 드는 노력을 가능한 줄이고 그에 따르는 이득을 최대화 해야 한다.
  • 단위 테스트의 목표 : SW 프로젝트의 지속 가능한 성장을 가능하게 하는 것
  • 지속 가능한 프로젝트 성장을 위해서는 '고품질' 테스트에만 집중해야 한다.

 

코드 커버리지

  • 코드 커버리지가 너무 적을 때는 테스트가 충분하지 않다는 좋은 증거이다. 그러나 반대의 경우는 그렇지 못하다. 100% 커버리지라고 해서 반드시 양질의 테스트 스위트라고 보장하지는 않는다. 높은 커버리지의 테스트 스위트도 품질이 떨어질 수 있다.
  • 테스트 스위트의 품질을 결정하는 데 어떤 커버리지 지표도 의존할 수 없는 이유는 다음과 같다.
    • 테스트 대상 시스템의 모든 가능한 결과를 검증한다고 볼 수 없다.
    • 외부 라이브러리 코드 경로를 고려할 수 있는 커버리지 지표는 없다.
      • 커버리지 지표가 외부 라이브러리의 코드 경로를 고려해야 한다는 것이 아니라, 해당 지표로는 단위 테스트가 얼마나 좋은지 나쁜지를 판단할 수 없다는 것
  • 코드 커버리지를 측정하는 것은 품질 테스트 스위트로 가는 첫걸음일 뿐이다.

 

성공적인 테스트 스위트의 세 가지 특성

  • 개발 주기에 통합돼 있다.
    • 이상적으로는 코드가 변경될 때마다 아무리 작은 것이라도 실행되어야 한다.
  • 코드베이스에서 가장 중요한 부분만을 대상으로 한다.
    • 시스템의 가장 중요한 부분에 단위 테스트 노력을 기울이고, 다른 부분을 간략하게 또는 간접적으로 검증하는 것이 좋다.
    • 가장 중요한 부분은 비즈니스 로직(도메인 로직)이 있는 부분이다.
    • 이 지침을 따르려면 도메인 모델을 코드베이스 중 중요하지 않은 부분과 분리해야 한다. 도메인 모델을 다른 애플리케이션 문제와 분리해야 단위 테스트에 대한 노력을 도메인 모델에만 집중할 수 있다.
  • 최소한의 유지비로 최대의 가치를 끌어낸다.
    • 가치 있는 테스트(더 나아가, 가치가 낮은 테스트)를 식별하고 가치 있는 테스트를 작성해야 한다.

2. 단위 테스트란 무엇인가?

단위 테스트에는 많은 정의가 있다. 가장 중요한 세 가지 속성은 다음과 같다.

단위 테스트는

  • 작은 코드 조각을 검증하고
  • 빠르게 수행하고
  • 격리된 방식으로 처리하는 자동화된 테스트이다.

사람들의 의견이 크게 다른 것은 세 번째 속성이다. 이는 고전파와 런던파를 구분할 수 있게 해주는 근원적 차이에 속한다. 두 분파간의 모든 차이는 격리가 정확히 무엇인지에 대한 의견 차이 하나로 자연스럽게 시작되었다.

격리 문제에 대한 런던파의 접근

런던파에서 격리된 방식으로 검증한다는 것은 무엇을 의미하는가?

  • 테스트 대상 시스템을 협력자에게서 격리하는 것을 의미한다.
  • 즉, 하나의 클래스가 다른 클래스 또는 여러 클래스에 의존하면 이 모든 의존성을 테스트 대역으로 대체해야 한다.

 

런던파 접근의 이점

  1. 테스트가 실패하면 코드베이스의 어느 부분이 고장 났는지 확실히 알 수 있다
  2. 객체 그래프를 분할할 수 있다
  3. 프로젝트 전반적으로 한 번에 한 클래스만 테스트하라는 지침을 도입하면 전체 단위 테스트 스위트를 간단한 구조로 할 수 있다

런던파 방식 테스트에 대한 이해도를 높이기 위해 예시 코드를 살펴보자. 먼저 고전파의 테스트 코드를 살펴본 이후 런던파의 테스트 코드를 살펴볼 것이다. 아래는 책에 나온 예제와 달리 Junit5, mockk 기반의 Kotlin 코드이다

 

테스트 case는 다음과 같다

  • 상점에 재고가 충분하면 구매 행위가 성공으로 간주되고, 구매 수량만큼 상점의 제품 수량이 줄어든다. 제품이 충분하지 않으면 구매는 성공하지 못하며 상점에 아무 일도 일어나지 않는다
@Test
fun `물건이 충분하면 구매가 성공한다`() {
  // given
  val store = Store()
  store.addInventory(Product.SHAMPOO, 10)
  val customer = Customer()

  // when
  val success = customer.purchase(store, Product.SHAMPOO, 5)

  // then
  assertThat(success).isTrue
  assertThat(store.getInventory(Product.SHAMPOO)).isEqualTo(5)
}

@Test
fun `물건이 부족하면 구매가 실패한다`() {
  // given
  val store = Store()
  store.addInventory(Product.SHAMPOO, 10)
  val customer = Customer()

  // when
  val success = customer.purchase(store, Product.SHAMPOO, 15)

  // then
  assertThat(success).isFalse
  assertThat(store.getInventory(Product.SHAMPOO)).isEqualTo(10)
}

위의 코드는 단위 테스트의 고전 스타일 예시로 테스트는 협력자(Store 클래스)를 대체하지 않고 운영용 인스턴스를 사용한다. Customer과 Store 둘 다 효과적으로 검증하며 테스트에서 두 클래스는 서로 격리되어 있지 않다.

 

이제 런던파의 테스트를 살펴보자

@Test
fun `물건이 충분하면 구매가 성공한다`() {
  // given
  val storeMock = mockk<Store>()
  every { storeMock.hasEnoughInventory(Product.SHAMPOO, 5) } returns true
  val customer = Customer()

  // when
  val success = customer.purchase(storeMock, Product.SHAMPOO, 5)

  // then
  assertThat(success).isTrue
  verify(exactly = 1) { stockMock.removeInventory(Product.SHAMPOO, 5) }
}

@Test
fun `물건이 부족하면 구매가 실패한다`() {
  // given
  val storeMock = mockk<Store>()
  every { storeMock.hasEnoughInventory(Product.SHAMPOO, 5) } returns false
  val customer = Customer()

  // when
  val success = customer.purchase(store, Product.SHAMPOO, 5)

  // then
  assertThat(success).isFalse
  verify(exactly = 0) { stockMock.removeInventory(Product.SHAMPOO, 5) }
}

준비 단계에서 테스트는 Store의 실제 인스턴스를 생성하지 않고 mockk 를 사용했다. 또한 직접 객체의 필드를 설정하는 대신 hasEnoughInventory 메소드 호출에 어떻게 응답해야 하는지 목 객체에 정의했다.

검증 단계 역시 바뀐 상점의 상태를 검증하지 않고 Customer와 Store 간의 상호작용 (removeInventory 가 호출되었는지 여부)을 검사한다.

 

격리 문제에 대한 고전파의 접근

고전적인 방법에서 코드를 꼭 격리하는 방식으로 테스트해야 하는 것은 아니다. 대신 단위테스트는 서로 격리해서 실행해야 한다.

고전파에서 각각의 테스트를 격리하는 것은 여러 클래스가 모두 메모리에 상주하고 공유 상태에 도달하지 않는 한, 여러 클래스를 한 번에 테스트해도 괜찮다는 뜻이다.

이러한 견해는 목과 기타 테스트 대역에 사용에 대한 훨씬 더 평범한 견해를 수반한다. 테스트 대역을 사용할 수 있지만, 보통 테스트 간에 공유 상태를 일으키는 의존성에 대해서만 사용한다.

공유 의존성을 대체하는 또 다른 이유는 테스트 실행 속도를 높이는 데 있다. 공유 의존성에 대한 호출은 비공개 의존성에 대한 호출보다 더 오래 걸린다. 이러한 호출을 포함하는 공유 의존성을 가진 테스트는 단위 테스트 영역에서 통합 테스트 영역으로 넘어간다.

예를 들어 다른 시스템의 API가 있다면, 이러한 의존성은 휘발성이고 애플리케이션 경계를 벗어나는 것이 사실이지만, 테스트가 반환하는 데이터에 영향을 미칠 수 없기 때문에 공유 의존성은 아니다. 그렇다고 이러한 의존성을 테스트 범주에 포함해야 하는 것은 아니다. 대부분의 경우 테스트 속도를 높이려면 테스트 대역으로 교체해야 한다. 그러나 프로세스 외부 의존성이 충분히 빠르고 연결이 안정적이면 테스트에서 그대로 사용하는 것도 괜찮다.

이러한 격리에 대한 대안적 견해는 또한 단위를 구성하는 것에 대한 다른 견해로 이어진다. 단위가 반드시 클래스에 국한될 필요는 없다. 공유 의존성이 없는 한 여러 클래스를 묶어서 단위 테스트를 할 수도 있다.

의존성의 종류

  • 공유 의존성 : 테스트 간에 공유되고 서로의 결과에 영향을 미칠 수 있는 수단을 제공하는 의존성
    • 공유 의존성의 전형적인 예는 정적 가변 필드이다.
  • 비공개 의존성 : (테스트 간에) 공유하지 않는 의존성
  • 프로세스 외부 의존성 : 애플리케이션 실행 프로세스 외부에서 실행되는 의존성. 아직 메모리에 없는 데이터에 대한 프록시
  • 휘발성 의존성 : 아래 2가지 속성 중 하나를 나타낸다
    • 개발자 머신에 기본 설치된 환경 외에 런타임 환경의 설정 및 구성을 요구 (실제 DB 또는 API 서비스 등)
    • 비결정적 동작을 포함. 난수 생성기 또는 현재 날짜와 시간을 반환하는 클래스등

런던파와 고전파의 비교

격리주체

  • 런던파 : 단위
  • 고전파 : 단위 테스트

단위의 크기

  • 런던파 : 단일 클래스
  • 고전파 : 단일 클래스 또는 클래스 세트

테스트 대역 사용 대상

  • 런던파 : 불변 의존성 외 모든 의존성
  • 고전파 : 공유 의존성

(책의 저자) 개인적으로는 고전파를 선호한다. 그 이유는 취약성에 있다. Mock을 사용하는 테스트는 고전적인 테스보다 불안정한 경향이 있기 때문이다. (자세한 내용은 5장에 나온다) 지금은 런던파 접근의 장점에 대한 이야기를 조금 더 해보자.

한 번에 한 클래스만 테스트 하기

  • 테스트는 코드의 단위를 검증해서는 안된다. 이상적으로는 비즈니스 담당자가 유용하다고 인식할 수 있는 것을 검증해야 한다. 동작 단위를 구현하는 데 클래스가 얼마나 필요한지는 상관없다.
  • 테스트가 단일 동작 단위를 검증하는 한 좋은 테스트다. 이보다 적은 것을 목표로 삼는다면 사실 단위 테스트를 훼손하는 결과를 가져온다. 이 테스트가 무엇을 검증하는지 정확히 이해하기가 더 어려워지기 때문이다. 테스트는 해결하는 데 도움이 되는 문제에 대한 이야기를 들려줘야 하며, 이 이야기는 프로그래머가 아닌 일반 사람들에게 응집도가 높고 의미가 있어야 한다.

예시

  • 우리집 강아지를 부르면, 바로 나에게 온다
  • 우리집 강아지를 부르면 먼저 왼쪽 앞다리를 움직이고, 이어서 오른쪽 앞다리를 움직이고, 머리를 돌리고, 꼬리를 흔들기 시작한다, ...

개별 클래스(다리 / 머리 / 꼬리)를 목표로 할 때 테스트는 아래와 같이 작성되기 시작한다

상호 연결된 클래스의 큰 그래프를 단위 테스트하기

  • 런던파의 방식에서는 단위 테스트에서 준비해야 할 작업량을 크게 줄일 수 있다.
  • 하지만 이 추리 과정은 잘못된 문제에 초점을 맞추고 있다. 상호 연결된 클래스의 크고 복잡한 그래프를 테스트할 방법을 찾는 대신, 먼저 이러한 클래스 그래프를 갖지 않는 데 집중해야 한다. 대개 클래스 그래프가 커지는 것은 코드 설계에 대한 결과이다.
  • 목을 사용하는 것은 이 문제를 감추기만 할 뿐, 원인을 해결하지 못한다.

버그 위치 정확하게 찾아내기

  • 고전파 방식에서는 로직을 하나 수정했을 때 여러 테스트가 깨지는 반면, 런던파 방식에서는 클래스 단위로 테스트 대상 범위가 국한되기 때문에 한 테스트 스위트에만 영향이 갈 것이다
  • 하지만 버그가 테스트 하나뿐만 아니라, 많은 테스트에서 결함이 이어진다면, 방금 고장 낸 코드 조각이 큰 가치가 있다는 것을 보여준다. 즉, 전체 시스템이 그것에 의존한다.
  • 이는 유용한 정보이다.

통합 테스트에 대한 관점 차이

런던파와 고전파의 차이점은 아직 끝나지 않는다.

minor하게는 TDD를 통한 시스템 설계 방식과 명세에 대한 차이가 있으며, 통합 테스트에 대한 차이도 존재한다.

런던파는 실제 협력자 객체를 사용하는 모든 테스트를 통합 테스트로 간주한다. 고전 스타일로 작성된 대부분의 테스트는 런던파 지지자들에게 통합 테스트로 느껴질 것이다.

고전파의 관점에서, 단위 테스트 속성의 정의를 다시 해보자

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

통합 테스트는 이러한 기준 중 하나를 충족하지 않는 테스트이다. 예를 들어, 공유 의존성에 접근하는 테스트는 다른 테스트와 분리해 실행할 수 없다. 이런 테스트는 순차적으로 실행해서 각 테스트가 공유 의존성과 함께 작동하려고 기다릴 수 있다. 또다른 예로, 둘 이상의 동작 단위를 검증하는 테스트 역시 통합 테스트이다.

 

통합 테스트는 시스템 전체를 검증해 SW 품질을 기여하는 데 중요한 역할을 한다.

간단히 말해 통합 테스트는 공유 의존성, 프로세스 외부 의존성뿐 아니라 조직 내 다른 팀이 개발한 코드 등과 통합해 작동하는지도 검증하는 테스트이다. end-to-end 테스트는 통합 테스트의 일부이다. end-to-end 테스트라는 명칭은 모든 외부 애플리케이션을 포함해 시스템을 최종 사용자의 관점에서 검증하는 것을 의미한다.

 

end-to-end 테스트는 일반적으로 프로세스 외부 의존성을 모두(또는 거의 대부분) 포함한다. 통합 테스트는 이러한 의존성을 한 가지(또는 두 가지)만 확인한다.

end-to-end 테스트는 유지 보수 측면에서 가장 비용이 많이 들기 때문에 모든 단위 테스트와 통합 테스트를 통과한 후 빌드 프로세스 후반에 실행하는 것이 좋다. 또한 개인 개발자 머신이 아닌 빌드 서버에서만 실행할 수도 있다.

3. 단위 테스트 구조

  • AAA 패턴 : 준비(Arrange) 실행(Act) 검증(Assert) 세 부분으로 테스트를 나누는 것
    • 스위트 내 모든 테스트가 단순하고 균일한 구조를 갖는 데 도움이 된다. 이러한 일관성이 이 패턴의 가장 큰 장점 중 하나다. 익숙해지면 모든 테스트를 쉽게 읽을 수 있고 이해할 수 있다. 결국 전체 테스트 스위트의 유지 보수 비용이 줄어든다.
    • Given-When-Then 패턴과는 테스트 구성 측면에서 차이가 없다
  • 여러 개의 준비 / 실행 / 검증 구절 피하기
    • 검증 구절(어저면 준비 구절)로 구분된 여러 개의 실행 구절을 보면, 여러 개의 동작 단위를 검증하는 테스트를 뜻한다. 이러한 테스트는 더 이상 단위테스트가 아니라 통합 테스트이다. 이러한 테스트 구조는 피하는 것이 좋다.
  • 테스트 내 if문 피하기
    • if문은 테스트가 너무 많은 것을 검증한다는 표시이다.
  • 테스트 준비 구절에서 코드 재사용에 도움이 되는 두 가지 패턴으로는 Object Mother와 Test Data Builder가 있다.
  • 실행 구절은 보통 코드 한 줄이다. 실행 구절이 두 줄 이상인 경우 SUT의 공개 API에 문제가 있을 수 있다.
  • 단위 테스트의 단위는 동작의 단위이지 코드의 단위가 아니다. 단일 동작 단위는 여러 결과를 낼 수 있으며, 하나의 테스트로 그 모든 결과를 평가하는 것이 좋다.
    • SUT 에서 반환된 객체 내에서 모든 속성을 검증하는 대신 객체 클래스 내에 적절한 동등 멤버를 정의하는 것이 좋다.
  • 대부분의 단위 테스트는 종료 구절(Junit의 AfterEach)이 필요 없다.단위 테스트는 프로세스 외부에 종속적이지 않으므로 처리해야 할 부작용을 남기지 않는다. 종료는 통합 테스트의 영역이다.
  • 각 테스트는 이야기가 있어야 한다. 이 이야기는 문제 영역에 대한 개별적이고 원자적인 사실이나 시나리오이며, 테스트가 통과하는 것은 이 사실 또는 시나리오가 실제 사실이라는 증거이다.

테스트 간 테스트 픽스처 재사용

  • 픽스처 : 테스트 실행 대상 객체. 즉, SUT로 전달되는 인수. DB에 있는 데이터나 하드 디스크의 파일일 수도 있다.

테스트 픽스처를 재사용하는 첫 번째 (올바르지 않은) 방법은 다음과 같이 테스트 생성자에서 픽스처를 초기화하는 것이다.

@BeforeEach
fun setup() {
  // do something
}

@Test
fun `무언가를 테스트 한다 1번 경우`() {
    // given 이 생략
  ...
}

@Test
fun `무언가를 테스트 한다 1번 경우`() {
    // given 이 생략
  ...
}

이 방법으로 테스트 코드의 양을 크게 줄일 수 있으며, 테스트에서 테스트 픽스처 구성을 전부 또는 대부분 제거할 수 있다. 그러나 이 기법은 두 가지 중요한 단점이 있다.

  • 테스트 간 결합도가 높아진다
    • 테스트 준비 로직을 수정하면 클래스의 모든 테스트에 영향을 미친다.
    • 이는 중요한 지침을 위반한다. 테스트를 수정해도 다른 테스트에 영향을 주어서는 안된다.
  • 테스트 가독성이 떨어진다
    • 테스트 메소드가 무엇을 하는지 이해하려면, 클래스의 다른 부분도 봐야 한다.

두 번째 방법은 테스트 클래스에 private factory method를 두는 것이다.

@Test
fun `무언가를 테스트 한다 1번 경우`() {
    // given
  createXXX()
  ...
}

@Test
fun `무언가를 테스트 한다 1번 경우`() {
    // given
  createXXX()
  ...
}

// method 이름이 생성되는 데이터를 더 직관적으로 표시하는데 도움이 되도록 naming
private fun createXXX(): XXX {

}

테스트 픽스처 재사용 규칙에 한 가지 예외가 있다. 테스트 전부 또는 대부분에 사용되는 생성자에 픽스처를 인스턴스화 할 수 있다. 이는 DB와 작동하는 통합 테스트에 종족 해당한다.

단위 테스트의 명명 지침

  • 엄격한 명명 정책을 따르지 않는다. 복잡한 동작에 대한 높은 수준의 설명을 이러한 정책의 좁은 상자 안에 넣을 수 없다. 표현의 자유를 허용하자
  • 문제 도메인에 익숙한 비개발자들에게 시나리오를 설명하는 것처럼 테스트 이름을 짓자. 도메인 전문가나 비즈니스 분석가가 좋은 예다.
  • 단어를 밑줄 표시로 구분한다. 그러면 특히 긴 이름에서 가독성을 향상 시키는데 도움이 된다.

매개변수화 테스트

테스트 코드의 양과 그 코드의 가독성은 서로 상충된다. 경험상 입력 매개변수만으로 테스트 케이스를 판단할 수 있다면 긍정적인 테스트 케이스와 부정적인 테스트 케이스 모두 하나의 메소드로 두는 것이 좋다. 그렇지 않다면 긍정적인 테스트 케이스를 도출하라. 그리고 동작이 너무 복잡하면 매개변수화된 테스트를 조금도 사용하지 말라. 긍정적인 테스트 케이스와 부정적인 테스트 케이스 모두 각각 고유의 테스트 메소드로 나타내라.