안녕하세요. 커뮤니케이션 앱 LINE의 모바일 클라이언트를 개발 하고 있는 Ishikawa입니다.
저희 회사는 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰고 있습니다. 이를 위해 다양한 노력을 하고 있는데요. 그중 하나가 Review Committee 활동입니다.
Review Committee에서는 머지된 코드를 다시 리뷰해 리뷰어와 작성자에게 피드백을 주고, 리뷰하면서 얻은 지식과 인사이트를 Weekly Report라는 이름으로 매주 공유하고 있습니다. 이 Weekly Report 중 일반적으로 널리 적용할 수 있는 주제를 골라 블로그에 코드 품질 개선 기법 시리즈를 연재하고 있습니다.
이번에 블로그로 공유할 Weekly Report의 제목은 '문을 없애고 테스트하기'입니다.
문을 없애고 테스트하기
다음과 같이 add
로 입력된 정숫값의 합계를 유지하다가 flush
가 호출되면 현재 합계를 0으로 리셋하고 리셋되기 직전의 합계를 반환하는 IntAdder
가 있습니다.
val adder = IntAdder()
adder.add(1)
adder.add(2)
println(adder.flush()) // Shows 3
adder.add(100)
adder.add(200)
println(adder.flush()) // Shows 300
IntAdder
를 다음과 같이 구현했다고 가정해 봅시다.
class IntAdder {
private var currentSum: Int = 0
fun add(value: Int) {
currentSum += value
}
fun flush(): Int {
val result = currentSum
currentSum = 0
return result
}
}
여기까지는 코드가 사양을 충족하며 다른 문제는 없다 고 가정하겠습니다.
다음으로 어떤 개발자가 IntAdder
에 대한 단위 테스트를 만들려고 currentSum
의 가시성을 private
에서 internal
로 변경했다고 가정해 봅시다. 이렇게 테스트를 위해서만 가시성을 변경했을 때는 Guava 라이브러리의 @VisibleForTesting
등을 사용해 '프로덕션 코드용 가시성이 아님'을 명시할 수 있습니다. 또한 테스트용 가시성은 린트(lint) 도구 등을 사용해 검증할 수도 있습니다.
class IntAdder {
var currentSum: Int = 0
@VisibleForTesting internal get
private set
이 currentSum
을 사용해 다음과 같이 현재 합계가 올바른지 검증하는 단위 테스트를 만들었습니다.
// Unit test code
adder.add(100)
assertEquals(100, adder.currentSum)
adder.add(200)
assertEquals(300, adder.currentSum)
assertEquals(300, adder.flush())
assertEquals(0, adder.currentSum)
이 단위 테스트 코드에 문제가 있을까요?
열린 창문만 이용해 테스트하기
단위 테스트에서는 내부의 세부적인 작동보다는 관찰 가능한 작동이 사양과 일치하는지를 테스트하는 것이 더 바람직합니다. 즉, 단위 테스트에서 확인해야 할 것은 주로 다음 두 가지 사항입니다.
- 함수의 반환값 및 예외
- 외부에서 제공되는 객체와의 상호작용(실제 인수로 입력된 객체나 생성자 인수로 입력된 객체 혹은 주입된 객체 등)
반환값 테스트
앞서 설명한 IntAdder
구현에서는 현재 합계를 수치로 유지했습니다. 하지만 이는 사양 변경에 따라 바뀔 수도 있습니다. 예를 들어, '값 변경 내역'을 가져와야 하는 경우 var currentSum: Int
라는 속성은 val valueHistory: MutableList<Int>
와 같은 형태로 바뀔 수 있습니다. 이런 경우에도 add
와 flush
에 대한 테스트는 변함없이 유효해야 하지만, 실제로는 currentSum
이라는 속성 자체가 변경됐기 때문에 기존 테스트는 컴파일할 수 없게 됩니다.
이 문제를 해결하려면 currentSum
의 값을 들여다보는 테스트를 작성하는 대신 아래와 같이 함수의 반환값을 테스트하는 것이 좋습니다.
// Unit test code
adder.add(100)
adder.add(200)
assertEquals(300, adder.flush())
assertEquals(0, adder.flush())
외부에서 제공되는 객체와의 상호작용 테스트
외부에서 제공되는 객체와의 상호작용 테스트의 예시를 위해 IntAdder
에 '트랜잭션'이라는 새로운 기능을 추가하겠습니다. 트랜잭션을 구현하기 위해 생성자 인수로 입력되는 transactionLogger
객체를 사용했습니다.
class IntAdder(private val transactionLogger: TransactionLogger) {
...
fun inTransaction(action: Adder.() -> Unit) {
... // Use `transactionLogger` here
}
}
이 transactionLogger
와의 상호작용을 테스트하려면 transactionLogger
의 mock
(테스트 더블의 표현으로는 테스트 스파이)을 준비해 이에 대한 함수 호출을 검증하면 됩니다.
val logger: TransactionLogger = mock()
val adder = IntAdder(logger)
adder.inTransaction { add(100) }
inOrder(logger) {
verify(logger).write(100)
verify(logger).commit()
}
물론 외부에서 제공되는 객체를 모두 mock
으로 만들 필요는 없습니다. 해당 객체가 테스트 환경 내에 포함돼 있고 테스트 대상 함수를 관찰하는 것으로 충분하다면 mock
이 아닌 객체를 전달해도 문제없습니다.
한 줄 요약: 단위 테스트에서는 내부의 세부 작동보다는 사양에 맞게 작동하는지 확인하는 것이 더 바람직하다.
키워드:
unit test
,dependency
,specification