이 글은 단위 테스트 책에 적혀있는 단위 테스트의 세 가지 속성과 테스트의 대표적인 두 분파인 고전파와 런던파에 대해 설명하겠습니다.
단위 테스트의 세 가지 속성
단위 테스트는 아래의 세 가지 속성을 가집니다.
- 작은 코드 조각(단위) 검증
- 빠르게 수행
- 격리된 방식으로 처리하는 자동화된 테스트
1번과 2번은 너무나도 당연한 속성이라 이견이 없지만 3번은 사람마다 다양한 해석이 존재하고, 이로 인해 고전파와 런던파라는 두 분파가 생겼습니다.
고전파
모든 사람이 단위 테스트와 테스트 주도 개발에 원론적으로 접근하는 방식을 따르기를 원한다고 해서 고전파(classic)라고 부릅니다.
켄트 백의 테스트 주도 개발이 주요 저서입니다.
고전파는 테스트를 작성할 때 아래의 규칙을 지키며 작성합니다.
- 의존성은 공유 의존성을 제외하고 전부 실제 객체를 사용합니다
- 격리 주체는 하나의 테스트입니다
- 외부 의존성이 문제가 되는데 테스트 대역으로 대체하라는 의견이 지배적입니다
- 테스트가 서로 영향을 끼치지 않게 되어 동시에 실행이 가능합니다
- 테스트는 클래스 하나를 테스트하는 것이 아닌 하나의 동작을 테스트합니다 (BDD 이론)
그림에서 볼 수 있듯이 파일 시스템, 데이터베이스는 테스트 간에 공유할경우 테스트끼리 영향을 끼치게 되어 있습니다. 고전파는 이러한 공유 의존성을 Mock으로 대체하고, 내부에서 직접 제어가 가능한 의존성들은 실제 객체를 사용합니다.
공유 의존성(shared dependency)은 테스트 간에 공유되고 서로의 결과에 영향을 끼칠 수 있는 수단을 제공하는 의존성입니다. 외부 API, 데이터베이스가 대표적인 예입니다.
비공개 의존성(private dependency)은 공유하지 않는 의존성을 말합니다.
고전파의 방식으로 테스트를 작성하면 아래처럼 작성하게 됩니다. 코드는 scala로 작성하였으니 그 흐름만 이해하셔도 됩니다. 사용하는 테스트 라이브러리는 ScalaTest, 모킹 라이브러리는 Mockito를 사용합니다.
class ClassicTest extends AnyFeatureSpecLike with MustMatchers with GivenWhenThen with MockitoSugar with EitherValues with OneInstancePerTest {
// 데이터베이스 의존성
private val repository = mock[MyRepository]
// 비공개 의존성
private val validationService = ValidationService()
// 테스트 진입점을 제공하는 대상 객체
private val sut = new ClassicService(repository, validationService)
feature("사용자를 조회한다") {
scenario("존재하지 않는 임의의 사용자를 조회할경우 조회에 실패한다") {
Given("임의의 사용자로 1번 사용자를 선택한다")
val id = 1
And("1번 사용자는 존재하지 않는다")
given(repository.findById(1)).willReturn(Future.successful(None))
When("사용자를 조회한다")
val actual = Await.result(sut.findUser(id), Duration.Inf)
Then("조회에 실패한다")
actual.left.value mustBe a[NotFoundException]
}
scenario("사용자 조회에 성공한다") {
Given("임의의 사용자로 1번 사용자를 선택한다")
val id = 1
And("1번 사용자가 존재한다")
given(repository.findById(1)).willReturn(Future.successful(Some(User(1, "name"))))
When("사용자를 조회한다")
val actual = Await.result(sut.findUser(id), Duration.Inf)
Then("1번 사용자가 조회된다")
actual.right.value mustBe User(1, "name")
}
}
}
테스트 코드에서 보이는 것처럼 데이터베이스와 같은 공유 의존성은 mock으로 생성하고, 그 외의 비공개 의존성은 실제 객체를 생성해서 테스트합니다.
위와 같이 작성하면 테스트끼리 서로 격리되어 있으므로 테스트들을 병렬로 실행할 수 있습니다.
실무에서 테스트를 작성하다보면 테스트가 몇백개가 넘는 경우가 허다한데, 이를 모두 순차적으로 실행하면 10분도 간단히 넘을 수 있고, 이런 상황이 반복되면 테스트를 돌려놓고 코드와 관련되지 않은 다른 일을 처리하게 됩니다.
테스트가 병렬로 실행되면 시간도 단축되고 집중력 유지에도 좋으니 될 수 있으면 테스트를 병렬로 실행하는 것을 추천드립니다.
테스트를 병렬로 실행하고 싶다면 ScalaTest에서는 OneInstancePerTest trait를 상속하면 되고, JUnit5 같은 경우에는 @TestInstance 어노테이션으로 인스턴스 생성 단위를 정할 수 있는데, 기본이 method 단위여서 메소드마다 테스트 클래스를 생성해 병렬로 실행할 수 있습니다.
런던파
테스트 대상 시스템(System under test, 줄여서 sut)을 협력자(Collaborator)에게서 격리시켜 테스트를 작성하는 방법을 따르는 분파로, 런던의 프로그래밍 커뮤니티에서 시작되어 런던파라고 부릅니다.
스티브 프리먼과 낵 프라이스의 Growing Object-Orientied Software, Guided by Test가 주요 저서입니다.
런던파는 가장 위에서 설명한 단위 테스트의 세 가지 속성 중 마지막 속성인 격리된 방식으로 처리한다 라는 의미를 위에서 적었듯 SUT를 협력자에게서 격리하는 것으로 이해합니다. 즉, 하나의 클래스가 다른 클래스 또는 여러 클래스에 의존하면 이를 모두 테스트 대역(Test double)로 교체합니다.
런던파는 테스트를 작성할 때 아래의 규칙을 지키며 작성합니다.
- 불변 의존성 외의 모든 의존성은 Mock으로 대체합니다
- 검증 단위는 하나의 클래스 또는 함수입니다
- 테스트는 하나의 클래스 단위로 격리합니다
불변 의존성: 시간에 따라 변하지 않는 의존성. 예를 들면 값 객체.
그림에 나와있듯이 SUT 이외의 모든 대상을 Mock으로 교체하고 테스트를 작성합니다.
런던파의 방식으로 테스트를 작성하면 아래처럼 작성하게 됩니다. 테스트 코드는 동일하게 scala로 작성합니다.
class LondonTest extends AnyFunSuite with MustMatchers with MockitoSugar with EitherValues with OneInstancePerTest {
// 데이터베이스 의존성
private val repository = mock[MyRepository]
// 비공개 의존성
private val validationService = mock[ValidationService]
// 테스트 진입점을 제공하는 대상 객체
private val sut = new ClassicService(repository, validationService)
test("존재하지 않는 사용자를 조회할경우 실패한다") {
// given
val id = 1
given(repository.findById(1)).willReturn(Future.successful(None))
given(validationService.validate(1)).willReturn(true)
// when
val actual = Await.result(sut.findUser(id), Duration.Inf)
// then
actual.left.value mustBe a[NotFoundException]
}
// etc..
}
코드로 볼 수 있듯 SUT를 제외한 모든 의존성을 mock으로 대체하였습니다. 따라서 테스트가 객체 자체를 검증하는데 집중되어 있어 테스트가 실패하면 SUT가 문제인지를 바로 인지하기 쉽습니다.
정리
고전파와 런던파가 단위 테스트의 세 가지 속성을 바라보는 관점을 정리하면 아래처럼 정리할 수 있습니다.
고전파의 단위 테스트
- 단일 동작 단위로 검증하고
- 빠르게 수행하고
- 다른 테스트와 별도로 처리한다
런던파의 단위 테스트
- 클래스 단위로 코드 조각 검증을 진행하고
- 빠르게 수행하고
- 클래스를 격리된 방식으로 처리합니다