BOOK 7 - 단위 테스트(1)
1장 - 단위 테스트의 목표
단위 테스트에 시간을 투자할 때는 항상 최대한 이득을 얻도록 노력해야 하며, 테스트에 드는 노력을 가능한 한 줄이고 그에 따르는 이득을 최대화해야 한다.
이 책에서 다루는 내용은 어떤 단위 테스트 기술이 좋은지를 구별하는 데 도움이 될 것이다.
테스트에 대한 비용 편익 분석 방법을 배우고 특정 상황에서 적절한 테스트 기술을 적용할 수 있을 것이다.
또한 공통적인 안티 패턴을 피하는 방법도 배운다.
이 장에서는 소프트웨어 산업에서 단위 테스트가 어떠한 상황에 있는지 간략히 살펴보고 테스트 작성과 유지 보수의 목표를 설명하며 테스트 스위트(test suite)를 잘 작성할 수 있는 방법을 소개한다.
1. 단위 테스트 현황
대부분의 프로그래머는 단위 테스트를 실천하고 중요성을 알고 있다.
기업용 애플리케이션 개발 프로젝트는 거의 모두 단위 테스트가 적용돼 있다. 제품 코드와 테스트 코드의 비율은 1:1에서 1:3 정도 된다. 때로는 그 비율이 훨씬 높아서 1:10 수준에 이르기도 한다.
모든 새로운 기술과 마찬가지로 단위 테스트도 계속 발전하고 있다. 논쟁은 ‘단위 테스트를 작성해야 하는가?’에서 ‘좋은 단위테스트를 작성하는 것은 어떤 의미인가?’로 바뀌었다.
많은 프로젝트에는 자동화된 테스트가 있으며 심지어 많은 테스트가 실행된다.
그러나 테스트를 해도 개발자들이 원하는 결과를 얻지 못하는 경우가 많다. 도움이 될 것이라 생각한 단위 테스트는 이러한 상황에 전혀 도움이 되지 않고 오히려 상황을 더 악화시킬 수도 있다.
어떤 것이 단위 테스트를 좋게 만드는지에 대한 논쟁은 매우 중요하다.
테스트 작성에 그치지 않고 노력 대비 최대의 이익을 끌어내는 방식으로 단위 테스트를 수행하는 것이 다음 단계이다.
이 책은 이상적인 단위 테스트에 대해 정확하고 과학적인 정의를 다룬다.
이 정의가 실제 사례에서 어떻게 적용되는지 살펴본다. 특정 프로젝트에서 테스트를 많이 수행했는데도 왜 어긋나는지를 이해하고, 그 과정을 바로잡는 법을 이해할 수 있길 바란다.
2. 단위 테스트의 목표
코드베이스에 대해 단위 테스트 작성이 필요하면 일반적으로 더 나은 설계로 이어진다.
하지만 이게 단위 테스트의 주목표는 아니다. 더 나은 설계는 단지 좋은 부수 효과일 뿐이다.
그럼 단위 테스트의 목표는 무엇인가?
소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 하는 것이다.
지속 가능하다는 것이 핵심이다. 프로젝트가 상당히 쉽게 성장할 수 있다. 하지만 시간이 지나면서 이렇게 계속 성장하기는 훨씬 어렵다.
개발 속도가 빠르게 감소하는 현상을 소프트웨어 엔트로피라고도 한다.
소프트웨어에서 엔트로피는 품질을 떨어뜨리는 코드 형태로 나타난다. 코드베이스에서 무언가를 변경할 때마다 무질서도는 증가한다.
- 지속적인 정리와 리팩터링 등과 같은 적절한 관리를 하지 않고 방치하면 시스템이 점점 더 복잡해지고 무질서해진다.
- 버그를 수정하면 더 많은 버그를 양산하고, 소프트웨어의 한 부분을 수정하면 다른 부분들이 고장 난다.
결국 코드베이스를 신뢰할 수 없게 된다. 그리고 제일 안 좋은 것은 안정화가 어렵다는 것이다.
테스트로 이러한 경향을 뒤집을 수 있다.
테스트는 안전망 역할을 하며, 대부분의 회귀에 대한 보험을 제공하는 도구라 할 수 있다.
테스트는 새로운 기능을 도입하거나 새로운 요구 사항에 더 잘 맞게 리팩터링한 후에도 기존 기능이 잘 작동하는지 확인하는 데 도움이 된다.
코드베이스를 지속적으로 검증하는 테스트 없이는 소프트웨어 개발이 쉽게 확장되지 않는다.
지속성과 확장성이 핵심이며, 이를 통해 장기적으로 개발 속도를 유지할 수 있다.
2.1 좋은 테스트와 좋지 않은 테스트를 가르는 요인
단위 테스트가 프로젝트 성장에 도움이 되는 것은 맞지만, 테스트를 작성하는 것만으로는 충분하지 않다.
잘못 작성한 테스트는 여전히 같은 결과를 낳는다.
모든 테스트가 똑같이 작성되지는 않는다.
일부 테스트는 아주 중요하고 소프트웨어 품질에 매우 많은 기여를 한다.
그 밖에 다른 테스트는 그렇지 않다. 잘못된 경고가 발생하고, 회귀 오류를 알아내는 데 도움이 되지 않으며, 유지 보수가 어렵고 느리다.
프로젝트에 도움이 되는지 여부를 명확하게 파악하지 않고 단위 테스트를 작성하는 데만 빠져들기 쉽다.
테스트의 가치와 유지 비용을 모두 고려해야 한다.
비용 요소는 다음과 같은 다양한 활동에 필요한 시간에 따라 결정된다.
- 기반 코드를 리팩터링할 때 테스트도 리팩터링하라.
- 각 코드 변경 시 테스트를 실행하라.
- 테스트가 잘못된 경고를 발생시킬 경우 처리하라.
- 기반 코드가 어떻게 동작하는지 이해하려고 할 때는 테스트를 읽는 데 시간을 투자하라.
지속 가능한 프로젝트 성장을 위해서는 고품질 테스트에만 집중해야 한다. 고품질 테스트만이 테스트 스위트에 남을 만한 테스트 유형이다.
3. 테스트 스위트 품질 특정을 위한 커버리지 지표
가장 널리 사용되는 두 가지 커버리지 지표를 어떻게 계산하고 어떻게 사용하는지를 살펴보고 관련된 문제점도 알아본다.
- 코드 커버리지
- 분기 커버리지
커버리지 지표는 각기 다른 유형이 있으며, 테스트 스위트의 품질을 평가하는 데 자주 사용된다.
일반적으로 커버리지 숫자가 높을수록 더 좋지만, 커버리지 지표는 중요한 피드백을 주더라도 테스트 스위트 품질을 효과적으로 측정하는 데 사용될 수 없다.
즉, 커버리지 지표는 괜찮은 부정 지표이지만 좋지 않은 긍정 지표다.
코드 커버리지가 너무 적을 때는 테스트가 충분치 않다는 좋은 증거다.
그러나 100% 커버리지라고 해서 반드시 양질의 테스트 스위트라고 보장하지는 않는다.
3.1 코드 커버리지 지표에 대한 이해
가장 많이 사용되는 커버리지 지표로 코드 커버리지가 있으며, 테스트 커버리지로도 알려져 있다.
이 지표는 하나 이상의 테스트로 실행된 코드 라인 수와 제품 코드베이스의 전체 라인 수의 비율을 나타낸다
예제 - 메서드를 부분적으로 다루는 테스트
public static bool IsStringLong(string input)
{
if (input.Length > 5)
return true;
return false;
}
public void Test() {
bool result = IsStringLong("abc");
Assert.Equal(false, result);
}
예제 메서드는 입력 매개변수로 제공된 문자열이 5자 이상이면 길다고 판단하여 true 를 반환하는 메서드이다.
테스트는 “abc”를 사용해 메서드를 점검하고 이 문자열이 길지 않다고 판별하는지 확인한다.
메서드 전체 라인수는 5이고, 테스트가 실행하는 라인 수는 4이다.
따라서 코드 커버리지는 4/5 = 80% 이다.
메서드를 리팩터링하여 불필요한 if 문을 한 줄로 처리하면 어떻게 될까?
public static bool IsStringLong(string input)
{
return input.Length > 5;
}
코드 커버리지가 바뀌게 된다.
테스트는 이제 코드 세 줄을 모두 실행하기 때문에 코드 커버리지가 100%로 증가했다.
테스트가 검증하는 결과 개수는 여전히 같지만 코드 커버리지가 증가했다.
이 예제는 커버리지 숫자에 대해 얼마나 쉽게 장난칠 수 있는지 보여준다.
3.2 분기 커버리지 지표에 대한 이해
또 다른 커버리지 지표는 분기 커버리지다.
분기 커버리지는 코드 커버리지의 단점을 극복하는 데 도움이 되므로 코드 커버리지보다 더 정확한 결과를 제공한다.
분기 커버리지 지표는 if 문과 switch 문과 같은 제어 구조에 중점을 둔다.
테스트 스위트 내 하나 이상의 테스트가 통과하는 제어 구조의 수를 나타낸다.
이전의 예제에서 IsStringLong 메서드에 두 개의 분기가 있다.
테스트는 이런 분기 중 하나에 대해서만 적용되므로 분기 커버리지 지표는 1/2 = 50% 이다.
if 문을 사용하든 더 짧은 표기법을 사용하든, 테스트 코드는 어떻게 작성해도 상관없다.
분기 커버리지 지표는 분기 개수만 다루며, 해당 분기를 구현하는 데 얼마나 코드가 필요한지 고려하지 않는다.
3.3 커버리지 지표에 관한 문제점
테스트 스위트의 품질을 결정하는 데 어떤 커버리지 지표도 의존할 수 없는 이유는 다음과 같다.
- 테스트 대상 시스템의 모든 가능한 결과를 검증한다고 보장할 수 없다.
- 외부 라이브러리의 코드 경로를 고려할 수 있는 커버리지 지표는 없다.
첫번째 문제
단지 코드 경로를 통과하는 것이 아니라 실제로 테스트하려면, 단위 테스트에는 반드시 적절한 검증이 있어야 한다.
다시 말해, 테스트 대상 시스템이 낸 결과가 정확히 예상하는 결과인지 확인해야 한다.
더구나 결과가 여러 개 있을 수 있다.
따라서 커버리지 지표가 의미가 있으려면 모든 측정 지표를 검증해야 한다.
예제 - 마지막 결과를 기록하는 IsStringLong
public static bool WasLastStringLong { get; private set; }
public static bool IsStringLong(string input)
{
bool result = input.Length > 5;
WasLastStringLong = result; // 첫번째 결과
return result; // 두번째 결과
}
public void Test()
{
bool result = IsStringLong("abc");
Assert.Equal(false, result);
}
IsStringLong 메서드에는 이제 값을 반환하는 명시적인 결과와 속성에 새로운 값을 쓰는 암묵적인 결과가 있다.
두번째 암묵적인 결과를 검증하지 않더라도 커버리지 지표는 100% 코드 커버리지와 50% 분기 커버리지의 동일한 결과를 보여준다.
커버리지 지표는 기반 코드를 테스트했다고 보장할 수 없으며 일부 실행된 것만 보장한다.
예제 - 검증이 없는 테스트는 언제나 통과한다.
public void Test()
{
bool result1 = IsStringLong("abc");
bool result2 = IsStringLong("abcdef");
}
이 테스트에서는 코드 커버리지와 분기 커버리지가 둘 다 100%를 나타내고 있다.
그러나 아무것도 검증하지 않기 때문에 전혀 쓸모가 없다.
두번째 문제
모든 커버리지 지표가 테스트 대상 시스템이 메서드를 호출할 때 외부 라이브러리가 통과하는 코드 경로를 고려하지 않는다.
예제
public static int Parse(string input)
{
return int.Parse(input);
}
public void Test()
{
int result = Parse("5");
Aseert.Equal(5, result);
}
분기 커버리지 지표는 100%로 표시되며, 테스트는 메서드 결과의 모든 구성 요소를 검증한다.
하지만 이 테스트는 완벽하지 않다.
.NET 프레임워크의 int.Parse 메서드가 수행하는 코드 경로는 고려하지 않는다.
빌트인 정수 타입에는 메서드의 입력 매개변수를 변경하면 다른 결과로 이어질 수 있고 테스트로부터 숨어있는 분기가 많다.
다음은 정수로 변환할 수 없는 몇 가지 가능한 인수다.
- null 값
- 빈 문자열
- “정수가 아님”
- 너무 긴 문자열
수 많은 예외 상황에 빠질 수 있지만, 테스트에서 모든 예외 상황을 다루는지 확인할 방법이 없다.
이는 커버리지 지표가 외부 라이브러리의 코드 경로를 고려해야 한다는 것이 아니라, 해당 지표로는 단위 테스트가 얼마나 좋은지 나쁜지를 판단할 수 없다는 것을 보여준다.
3.4 특정 커버리지 숫자를 목표로 하기
100%, 90%, 심지어 중간 정도인 70%까지 특정 커버리지 숫자를 목표로 삼기 시작하면 위험 영역으로 이어질 수 있다.
커버리지 지표를 보는 가장 좋은 방법은 지표 그 자체로 보는 것이며, 목표로 여겨서는 안 된다.
특정 커버리지 숫자를 목표로 하는 것은 단위 테스트의 목표와는 반대되는 그릇된 동기 부여가 된다.
4. 무엇이 성공적인 테스트 스위트를 만드는가?
테스트 스위트의 품질을 어떻게 측정해야 하는가?
믿을 만한 방법은 스위트 내 각 테스트를 하나씩 따로 평가하는 것 뿐이다.
테스트 스위트가 얼마나 좋은지 자동으로 확인할 수 없다. 개인 판단에 맡겨야 한다.
성공적인 테스트 스위트는 다음과 같은 특성을 갖고 있다.
- 개발 주기에 통합돼 있다.
- 코드베이스에서 가장 중요한 부분만을 대상으로 한다.
- 최소한의 유지비로 최대의 가치를 끌어낸다.
4.1 개발 주기에 통합돼 있음
모든 테스트는 개발 주기에 통합돼야 한다.
이상적으로는 코드가 변경될 때마다 아무리 작은 것이라도 실행해야 한다.
4.2 코드베이스에서 가장 중요한 부분만을 대상으로 함
모든 테스트가 똑같이 작성되지 않은 것처럼 단위 테스트 측면에서 코드베이스의 모든 부분에 똑같이 주목할 필요는 없다.
시스템의 가장 중요한 부분에 단위 테스트 노력을 기울이고, 다른 부분은 간략하게 또는 간접적으로 검증하는 것이 좋다.
대부분의 애플리케이션에서 가장 중요한 부분은 비즈니스 로직이 있는 부분이다.
비즈니스 로직 테스트가 시간 투자 대비 최고의 수익을 낼 수 있다.
다른 모든 부분은 세 가지 범주로 나눌 수 있다.
- 인프라 코드
- 데이터베이스나 서드파티 시스템과 같은 외부 서비스 및 종속성
- 모든 것을 하나로 묶는 코드
이 중 일부는 단위 테스트를 철저히 해야 할 수 있지만, 일반적으로 도메인 모델에 관심을 더 많이 갖는 것이 옳다.
이 지침을 따르려면 도메인 모델을 코드베이스 중 중요하지 않은 부분과 분리해야 한다.
도메인 모델을 다른 애플리케이션 문제와 분리해야 단위 테스트에 대한 노력을 도메인 모델에만 집중할 수 있다.
4.3 최소 유지비로 최대 가치를 끌어냄
가치가 유지비를 상회하는 테스트만 스위트에 유지하는 것이 중요하다.
이 마지막 속성은 두 가지로 나눌 수 있다.
- 가치 있는 테스트 식별하기
- 가치 있는 테스트 작성하기
가치가 높은 테스트를 식별하려면 기준틀이 필요하다.
반면에 가치 있는 테스트를 작성하려면 코드 설계 기술도 알아야 한다.
단위 테스트와 기반 코드는 서로 얽혀 있으므로 코드베이스에 노력을 많이 기울이지 않으면 가치 있는 테스트를 만들 수 없다.
새로운 테스트를 작성하는 것은 기존 테스트를 시험하는 것보다 더 많은 노력이 드는데, 대부분 아무것도 없는 상태에서 테스트를 작성하지 않고 기반 코드를 고려해야 하기 때문이다.
5. 이 책을 통해 배우는 것
테스트 스위트 내의 모든 테스트를 분석하는 데 사용할 수 있는 기준틀을 설명한다.
그러고 나서 새로운 관점에서 많은 테스트를 볼 수 있으며, 어떤 것이 프로젝트에 기여하고 어떤 것을 리팩터링해야 하거나 완전히 제거해야 하는지 알 수 있을 것이다.
기준틀 외에 다음 내용도 다룬다.
- 제품 코드와 관련 테스트 스위트를 리팩터링하는 방법
- 단위 테스트를 다양한 스타일로 적용하는 방법
- 통합 테스트로 시스템 전체 동작 검증하기
- 단위 테스트 안티 패턴을 식별하고 예방하기