027. (Unit Test Principles) 1. 단위 테스트의 목표

1. 단위 테스트의 목표

원제 : The goal of unit testing

단위 테스트란 단순히 테스트를 작성하는 것이 아닌 테스트 프레임워크나 Mock 라이브러리 등을 익히는 것에 그치지않고 더 큰 범주를 의미한다.

따라서 단위 테스트에 리소스를 투입할 때에는 항상 최대한의 이득을 얻도록 노력해야하며, 테스트에 드는 비용을 줄여야 한다.

이 두 가지 균형이 지켜지지 않으면 만약 많은 양의 단위 테스트를 작성하더라도, 많은 버그와 유지보수로 프로젝트의 진척률이 떨어질 수 있다.

이번 포스팅에서는 단위 테스트가 소프트웨어산업에서 어떠한 상태에 놓여있는지, 테스트의 작성과 유지보수의 목표를 정의해보도록 하자.

1.1. 단위 테스트 현황

원제 : The current state of unit testing

오늘날 대부분의 개발자들은 단위 테스트의 중요성에 대해서 인지하고 있다.

따라서 적용에 대한 가부는 무의미하며, 일회성 프로젝트가 아닌 이상 필히 적용해야 한다.

따라서, 단위 테스트 관련 논쟁은 주로 좋은 단위 테스트란 무엇인가? 에 치중되어 있다.

이처럼 많은 소프트웨어 프로젝트에는 자동화된 많은 테스트들이 존재하지만, 정작 개발자들이 원하는 결과는 얻지 못하는 경우가 많다.

이를 방지하기 위해선 이상적인 단위 테스트에 대해 정확하게 정의하고, 어긋난 테스트를 바로 잡는 방법을 실제 프로젝트에서 수행해야 한다.

1.2. 단위 테스트의 목표

원제 : The goal of unit testing

단위 테스트를 정의하기 전에 무엇을 목표로 단위 테스트를 작성해야하는지 고민해보자.

흔히 단위 테스트를 기점으로 프로젝트가 보다 나은 설계로 나아간다고 생각하며, 이는 맞는 말이다.

다만 보다 나은 설계는 단위 테스트의 부수적인 효과이지, 단위 테스트 작성을 위한 목표는 아니다.

단위 테스트와 코드 설계의 관계
코드 조각을 단위 테스트하는 것은 매우 높은 정확도로 저품질 코드를 식별해낸다.
따라서 단위 테스트 적용이 어려운 경우는 코드 개선이 필요하다는 지표로 해석할 수 있는 것이다.

그렇다면 다시 단위 테스트의 목표란 무엇일까?

진정한 목표는 소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 하는 것이다.

테스트가 없는 일반 프로젝트의 성장 추이는 아래와 같이 그려진다.

Figure 1.1

처음엔 빠르게 시작할 수 있지만, 시간이 지날수록 점점 더 많은 시간을 들여야 처음과 같은 속도의 진척도를 보일 수 있다.

이처럼 개발 속도가 빠르게 감소하는 현상을 소프트웨어 엔트로피(software entropy) 라고 한다.

이는 하나의 버그를 수정하며 더 많은 버그가 양산되고, 소프트웨어의 일부분이 고장나는 도미노 현상을 야기하게 된다.

이때 테스트를 안정망으로 사용하여 대부분의 회귀 테스트를 어느정도 보장해주는 것이 좋다.

회귀 테스트에서 알 수 있듯, 기존 코드에 대해서도 테스트 코드가 작성되어야 하므로 초기 비용을 지불해야하는 단점이 존재한다.

따라서 단위 테스트를 작성하면서 얻을 수 있는 안정망을 통해 기존 기능에 대한 지속성을, 새로운 기능에 대한 새로운 단위 테스트로 확장성을 보장하는 것이 핵심이다.

1.2.1. 좋은 테스트와 좋지 않은 테스트를 가르는 요인

원제 : What makes a good or bad test?

단위 테스트는 작성만 하면 무조건 좋은 것일까?

아니다. 테스트를 잘못 작성한 경우 여전히 잘못된 결과를 낳게 된다.

만약 잘못된 테스트가 포함되어 있는 경우엔 아래와 같은 진척도를 보이게 된다.

Figure 1.2

초반에는 테스트가 잘 작성된 프로젝트와 비슷한 속성을 보이지만, 결국 진척도가 떨어지는 시점이 더 빠리 다가오게 된다.

좋은 테스트는 소프트웨어 품질 유지에 많은 기여를 하지만, 잘못된 테스트는 잘못된 경고를 노출하고, 유지보수도 어렵게 만들게 된다.

즉 단위 테스트가 프로젝트에 도움이 되는지 여부를 명확하게 파악하지않으면 단위 테스트의 작성 자체에만 매몰될 수 있다.

이 테스트의 유지 비용을 고려할 땐 아래와 같은 행위에 대해 필요한 리소스에 따라 결정된다.

  • 기반 코드를 리팩토링할 때, 테스트도 리팩토링해야 한다.
  • 각 코드 변경시 테스트를 실행해야 한다.
  • 테스트가 잘못된 경고를 발생시킬 경우, 이를 해소해야 한다.
  • 기반 코드가 어떻게 동작하는 지 이해하려면, 테스트 코드를 읽는 데 시간을 투자해야 한다.

만약 위의 행위에서 높은 시간 비용이 소모된다면, 지속가능한 테스트를 위해 고품질 테스트에만 집중하도록 해야한다.

1.3. 테스트 스위트 품질 측정을 위한 커버리지 지표

원제 : Using coverage metrics to measure test suite quality

테스트 쪽에서 가장 널리 쓰이는 지표는 크게 두 가지가 있다.

하나는 코드 커버리지(code coverage) 이고, 하나는 분기 커버리지(branch coverage) 이다.

특정 커버리지를 어떻게 계산하고 사용하는지 살펴보고, 이 커버리지를 목표로 삼았을 때 매몰되는 부작용에 대해서도 알아보도록 하자.

커버리지는 소스 코드를 테스트 스위트가 얼마나 실행하는지는 백분율로 표시하므로, 일반적으론 높을수록 좋은 지표이다.

다만 커버리지 지표는 테스트 스위트의 품질이 높은가에 대한 지표는 아니므로 주의해야 한다.

코드 커버리지가 너무 낮은 경우는 테스트가 충분하지않다는 증거가 되지만,

커버리지가 100%라고해서 해당 테스트 스위트가 고품질인 것을 보장해주지는 않는다.

1.3.1. 코드 커버리지 지표에 대한 이해

원제 : Understanding the code coverage metric

가장 많이 사용되는 커버리지 지표로 코드 커버리지(code coverage) 가 있다.

이 지표는 아래와 같은 공식으로 도출된다.

코드커버리지=실행코드라인수전체라인수코드 커버리지 = \frac{실행 코드 라인 수}{전체 라인 수}

이번엔 예제를 통해 살펴보도록 하자.

1
2
3
4
5
fun isStringLong(input: String): Boolean {
if (input.length > 5)
return true
return false
}

위 코드는 인자로 주어진 input의 길이가 5 이상이면 true, 아니면 false를 반환하는 메서드이다.

이 코드에 대응하는 테스트 코드를 아래와 같이 작성해보자.

1
2
3
4
fun test() {
val result = isStringLong("abc")
assertEquals(false, result)
}

실제 코드 라인 수는 5줄이고, 테스트가 실행하는 라인 수는 4줄이다.

따라서 코드 커버리지는 4/5 = 0.8 = 80% 이다.

이번엔 메서드를 리팩토링 해보도록 하자.

1
2
3
fun isStringLong(input: String): Boolean {
return input.length > 5
}

위와 같이 리팩토링하면 4/3 으로 모든 코드를 점검하므로 100%의 커버리지 지표를 가지게 되었다.

이 예제로 말하고 싶은 부분은 커버리지 숫자라는 게 실제 테스트 로직과 상관없이 얼마나 쉽게 바뀌는 지에 대한 부분이다.

1.3.2. 분기 커버리지 지표에 대한 이해

원제 : Understanding the branch coverage metric

이번엔 분기 커버리지(branch coverage) 에 대해서 알아보자.

분기 커버리지는 코드 커버리지의 단점을 극복하는 데 도움을 주고 보다 정확한 결과를 제공해준다.

분기 커버리지를 도출하는 공식은 아래와 같다.

분기커버리지=통과분기전체분기수분기 커버리지 = \frac{통과 분기}{전체 분기 수}

이 지표는 코드 베이스에서 모든 가능한 분기를 합산하고, 그 중 테스트가 얼마나 많이 실행되는지를 확인해야 한다.

위의 예제를 다시 가져와보자.

1
2
3
fun isStringLong(input: String): Boolean {
return input.length > 5
}

실제 코드에는 input의 길이가 5를 초과하느냐 아니냐의 분기가 존재한다.

즉 분기 커버리지 지표는 1/2 = 50% 가 된다.

Figure 1.5

위의 그림처럼 테스트 코드에서 true인 경우와 false인 경우를 모두 다룬다면 분기 커버리지는 100%가 될 것이다.

1.3.3. 커버리지 지표에 관한 문제점

원제 : Problems with coverage metrics

상술했듯 분기 커버리지로 코드 커버리지보다 더 나은 결과를 얻을 수는 있다.

하지만 테스트 스위트의 품질을 결정하는 데 커버리지 지표를 의존하지 않는 이유는 아래와 같다.

  1. 테스트 대상 시스템의 모든 가능한 결과를 검증한다고 보장할 수 없다.
  2. 외부 라이브러리 코드 경로를 고려할 수 있는 커버리지 지표는 없다.

실제로 단위 테스트 작성시엔 반드시 적절한 검증이 있어야 한다.

즉, 테스트 대상 시스템이 낸 결과가 정확히 예상된 결과인지를 검증해야한다.

이 결과는 복수로 존재할 수 있으므로, 커버리지 지표가 의미를 가지려면 모든 측정 지표에 대해서 검증을 수행해야 한다.

예제를 다시 튜닝해보자.

1
2
3
4
5
6
7
var lastResult: Boolean

fun isStringLong(input: String): Boolean {
val result = input.length > 5
lastResult = result
return result
}

코드를 위와 같이 바꾸면 두 번째 초기화에 쓰인 result에 대해서만 검증하게 된다.

커버리지 지표는 100%지만, 정작 테스트되지않는 부분이 존재하는 것이다.

이것이 커버리지 지표에 의존하면 안되는 이유이다.

이번에는 검증이 없는 테스트에 대해 알아보자.

1
2
3
4
5
6
fun test() {
val result1 = isStringLong("abc")
// assertEquals(false, result1)
val result2 = isStringLong("abcdef")
// assertEquals(true, result2)
}

이 테스트는 코드 커버리지와 분기 커버리지가 전부 100% 이지만, 아무 검증도 하지 않기에 쓸모없는 테스트 코드가 되었다.

두번째 문제인 외부 라이브러리 관련 검증에 대해서도 알아보자.

아래에 외부 참조가 존재하는 코드가 있다.

1
2
3
fun parse(input: String): Integer {
return Integer.parseInt(input)
}

이 코드를 테스트하는 코드도 작성해보자.

1
2
3
4
fun test() {
val result = parse("5")
assertEquals(5, result)
}

이 코드의 커버리지는 100% 이다.

다만 Integer.praseInt는 코틀린 내의 코드로 내부의 분기나 처리 로직에 대해서 모든 경로를 검증하지 않고 있다.

예를 들어 빈 문자열이나 정수로 치환할 수 없는 문자열을 넣는 경우는 커버하지 못하고 있다.

좀 더 정확히.

커버리지 지표를 위해 외부 라이브러리의 경로를 검증하는 것이 아닌, 단위 테스트의 품질 여부를 판정할 수 없음을 인지해야 한다.

1.3.4 특정 커버리지 숫자를 목표로 하기

원제 : Aiming at a particular coverage number

여태까지 파악한 내용으로 테스트 스위트의 품질을 결정하기에 커버리지 지표 하나로는 부족하다는 점을 알게 되었다.

커버리지 지표는 지표는 지표로만 해석하고, 목표로 여겨서는 안된다는 점이다.

만약 커버리지 지표가 목표가 되는 경우엔 중요한 것을 테스트하는 것보다 인공적으로 지표를 높이기 위한 것에 집중하게 될 것이다.

코드 커버리지의 측정은 좋은 테스트 스위트를 만드는 첫 단계 정도로만 취급하도록 하자.

1.4. 무엇이 성공적인 테스트 스위트를 만드는가?

원제 : What makes a successful test suite?

커버리지 지표에 의존하지 않고 테스트 스위트의 품질을 측정하려면 어떻게 해야할까?

가장 단순하고 좋은 방법은 테스트 스위트 내 각 테스트를 하나씩 따로 평가하는 것 뿐이다.

성공적인 테스트 스위트는 아래와 같은 특성을 가진다.

  1. 개발 주기에 통합되어 있다.
  2. 코드 베이스에서 가장 중요한 부분만은 대상으로 한다.
  3. 최소한의 유지비로 최대의 가치를 끌어낸다.

1.4.1. 개발 주기에 통합된 테스트 스위트

원제 : It’s integrated into the development cycle

모든 테스트는 개발 주기에 통합되어 코드가 변경될 때마다 아무리 작은 테스트라도 수행하는 것이다.

즉 자동화 테스트를 주기적으로, 변경시마다 끊임없이 수행하도록 해야한다.

1.4.2. 가장 중요한 부분을 대상으로 하는 테스트 스위트

원제 : It targets only the most important parts of your code base

단위 테스트 측면에서 코드 베이스의 모든 코드에 대해 동일한 비용을 지불할 필요는 없다.

테스트에서 얻을 수 있는 가치는 검증하는 것에 있기 때문에, 시스템의 가장 중요한 부분에 단위 테스트를 중점적으로 하도록 노력하고,

상대적으로 중요하지않은 부분은 간략하게만 작성하거나 간접적으로 검증하는 것이 좋다.

따라서 도메인 모델 내의 비즈니스 로직에 대한 테스트 작성이 시간 대비 최고의 가치를 보이게 된다.

비즈니스 로직을 제외한 다른 부분은 아래와 같이 세 가지로 분류할 수 있다.

  1. 인프라 코드
  2. 데이터베이스나 서드파티 시스템과 같은 외부 서비스 및 종속성
  3. 모든 것을 하나로 묶는 코드

이 중 상대적으로 중요성을 띄는 일부만이 단위 테스트 대상이 될 것이다.

1.4.3. 최소 비용 최대 효과의 원칙

원제 : It provides maximum value with minimum maintenance costs

단위 테스트에서 가장 어려운 부분은 거듭 강조했듯 최소 유지비로 최대 가치를 달성하는 것이다.

단순히 테스트를 빌드 시스템에 통합하는 것만으로는 충분하지 않으며,

도메인 모델에 높은 테스트 커버리지를 유지하는 것도 충분하지 않다.

또한 가치가 유지보수 비용을 앞지르는 경우에만 테스트 스위트에 유지하는 것이 좋다.

따라서 단위 테스트를 할 때는 가치 있는 테스트식별 하고, 작성 하는 것이 맹점이다.