실무에서 코드를 작성하다 보면 처음에는 느끼기 힘들지만 시간이 지날수록 테스트 코드의 중요성을 느끼게 됩니다.
초기에는 단순한 기능들을 주로 구현하여 사용할 뿐이지만 시간이 지나면 서비스가 확장되고 점점 복잡한 비즈니스 도메인의 로직들이 코드에 나타나게 되면서 우리의 머리를 아프게 합니다.
만약 테스트 코드가 없다면 이전에 작성한 코드들과 상호작용 하거나 리팩터링과 같은 수정을 진행할 경우 어떠한 영향을 끼칠지 코드를 하나하나 분석하지 않는 이상 전혀 알 수 없기 때문에 시간이 지날수록 테스트의 가치가 높아지게 됩니다.
단위 테스트라는 책을 읽으면서 배운 점과 실무에서 코드를 작성하면서 느낀 점들을 글로 적어보려 합니다. 우선 책에 적힌 기본 지식들을 적어보고 이후에 느낀 점들을 더 적어보겠습니다.
만약 책을 이미 읽으셨다면 스킵하셔도 됩니다.
코드는 scala로 작성할 예정이니 적당히 느낌으로 봐주시면 됩니다.
테스트와 목표
제대로 작성되지 않은 테스트 코드를 보면 테스트가 우리가 원하는 결과를 주지 못하는 경우가 많습니다.
- 테스트가 제대로 검증해주지 못하는 예측하지 못한 버그
- 조금만 수정해도 테스트가 깨지면서 유지보수하기 어려운 코드
- 대체 어떤 기능을 제공하는지 테스트를 보아도 알 수 없는 코드
- 등등
이러한 테스트는 오히려 코드를 작성하는데 방해가 되며 서비스의 성장에도 방해가 됩니다.
문제가 심각해지면 결국 작성된 테스트들을 지워버리는 일도 발생합니다.
이러한 문제를 피하기 위해서 좋은 테스트를 작성하는 것은 취향이 아니라 필수입니다.
흔히 TDD와 관련해서 예시로 드는 대표적인 장점으로 "단위 테스트를 짜면 설계가 좋아진다"라는 말이 있습니다. 테스트를 우선적으로 고려해서 코드를 짜다 보면 분명히 설계가 좋아지지만, 이것도 좋은 테스트를 짤 때의 이야기이지 규칙없이 테스트를 작성하다 보면 이러한 장점을 느낄 수 없습니다.
우리가 테스트를 짜는 이유는 사실 좋은 설계를 위해서는 아닙니다. 코드의 설계가 아무리 좋아도 서비스가 성장하지 않으면 이는 실패한 서비스입니다. 제일 중요한 목표는 서비스가 성장할 수 있는 발판을 만드는 것입니다. 좋은 테스트는 사람이 와도 코드가 아닌 테스트를 통해 비즈니스를 이해할 수 있고, 회귀에 대한 보험을 제공해주며, 리팩터링이 가능하게 보험을 제공해줍니다.
요약하면 테스트는 비즈니스의 지속적인 성장이 목표이지 좋은 설계가 목표는 아닙니다.
테스트 커버리지
잘 작성된 테스트의 대표적인 지표로 테스트 커버리지가 있습니다. 테스트가 실제 코드를 얼마나 커버하는지를 나타내는데 살다보면 가끔 커버리지를 채우는 것이 테스트의 목표가 되는 일이 생깁니다 (팀에서 커버리지를 100%로 달성하는 것을 KPI로 잡는다던가, 코드 구현에 집중해서 테스트를 너무 안 짜서 큰 마음을 먹게 되던가 등등). 테스트 커버리지도 물론 중요하지만 커버리지 만으로 테스트를 평가하는 것은 전혀 바람직하지 않습니다. 예시를 들어서 설명하겠습니다.
테스트 커버리지의 대표적인 예로 Line coverage와 Branch coverage가 존재합니다.
Line Coverage
Line coverage = (실행 코드 라인 수 / 전체 라인 수) 로 계산되며, 작성한 테스트 코드가 작성된 코드 라인을 얼마나 커버하는지 나타냅니다. 예를 들어 아래와 같은 코드가 존재한다고 해봅시다.
def isStringLong(input: String) = {
if (input.length > 5) true
else false
}
"abc" should "짧은 문자열이다" in {
val result = isStringLong("abc")
assertEquals(false, result)
}
테스트 코드를 실행해보면 Line coverage는 1줄 / 2줄 = 50%로 계산됩니다.이를 개선하는 방법으로 5글자 이상의 긴 문자열을 넣는 테스트를 추가로 작성할 수도 있지만 아래와 같은 꼼수도 충분히 가능합니다.
def isStringLong(input: String) = input.length > 5
"abc" should "짧은 문자열이다" in {
val result = isStringLong("abc")
assertEquals(false, result)
}
위의 테스트 코드를 실행해보면 Line coverage는 1줄 / 1줄 = 100%로 계산됩니다. 결과적으로 코드와 테스트 스위트는 개선되지 않았지만 커버리지는 개선되었습니다.
여기서 알 수 있는 사실은 커버리지는 테스트의 가치를 보장해주지 않는다는 사실입니다.
Branch Coverage
Branch coverage = (통과 분기 수 / 전체 분기 수)로 계산되며, 작성한 테스트 코드가 작성된 분기문을 얼마나 커버하는지를 나타냅니다. 위의 수정된 코드를 branch coverage로 보면 50%가 커버되므로 라인 커버리지보다는 낫지만 여전히 문제가 존재합니다.
- 테스트 시스템의 모든 가능한 결과를 검증한다고 보장해주지 않습니다
- 외부 라이브러리의 코드 경로를 고려해주지 못합니다
첫 번째 문제는 아래의 코드를 통해 예시를 들어보겠습니다.
var globalValue = false
def isStringLong(input: String) = {
val result = input.length > 5
globalValue = result
result
}
"abc" should "짧은 문자열이다" in {
val result = isStringLong("abc")
assertEquals(false, result)
}
위의 코드는 globalValue를 수정하면서 결과값을 반환합니다. 작성된 코드는 분기가 없으므로 100%의 커버리지를 보여주지만 globalValue가 원하는대로 수정되었는지는 검증해주지 못합니다.
두 번째 문제는 또 다른 코드로 확인해보겠습니다. 편의를 위해 이번 예제는 scala가 아닌 javascript로 적겠습니다.
function parseNumber(input) {
return Number.parseInt(input)
}
test(`"5" should 5`, () => {
const result = parseNumber(5)
expect(result).toBe(5)
})
위의 예시는 문자열을 숫자로 파싱하는 예제인데 적어둔 테스트 코드로도 100%의 커버리지가 나옵니다. 여기서 문제는 문자열일경우, 숫자일경우, null일경우 등 타입에 따른 코드 경로가 커버리지에 전혀 고려되지 않았다는 것입니다.
커버리지 지표가 외부 라이브러리 코드 경로를 모두 고려해야 된다는 얘기는 아니지만 이러한 지표만으로는 단위 테스트가 얼마나 좋은지 나쁜지 판단할 수 없습니다.
요약하자면 테스트의 목표를 커버리지의 숫자로 잡는 것은 바람직한 목표 설정이 아닙니다. 또, 테스트 코드를 커버리지 숫자로만 판단하는 것도 바람직하지 않습니다.