8 minute read

단위 테스트는 단순히 테스트를 작성하는 것보다 더 큰 범주다. 단위 테스트에 시간을 투자할 때는 항상 최대한 이득을 얻도록 노력해야 하며, 테스트에 드는 노력을 가능한 한 줄이고 그에 따르는 이득을 최대화해야 한다. 두 가지를 모두 달성하기란 쉬운 일이 아니다.

1.1 단위 테스트 현황

대부분 프로그래머는 단위 테스트를 실천하고 중요성을 알고 있다. 단위 테스트를 적용해야 하는지는 더 이상 논쟁거리가 아니다. 그냥 쓰고 버리는 프로젝트가 아니면, 단위 테스트는 늘 적용해야 한다.

많은 프로젝트에는 자동화된 테스트가 있으며 심지어 많은 테스트가 실행된다. 그러나 테스트를 해도 개발자들이 원하는 결과를 얻지 못하는 경우가 많다.

어떤 것이 단위 테스트를 좋게 만드는지에 대한 논쟁은 매우 중요하다. 하지만 오늘날 소프트웨어 개발 업계에서 많이 논의되지는 않는다.

이 책은 기업용 애플리케이션을 작업하는 경우에 최대로 활용할 수 있지만, 몇몇 아이디어는 어떤 소프트웨어 프로젝트에서든 적용할 수 있다.

기업용 애플리케이션이란?

기업용 애플리케이션은 조직 내부 프로세스를 자동화하거나 지원하기 위한 응용프로그램이다. 다양한 형태이지만, 일반적으로 다음과 같은 특성이 있다.

  • 높은 비즈니스 복잡도
  • 긴 프로젝트 수명
  • 중간 크기의 데이터
  • 낮은 수준이나 중간 수준 정도의 성능 요구

1.2 단위 테스트의 목표

흔히 단위 테스트 활동이 더 나은 설계로 이어진다고 한다. 이는 사실이다. 하지만 단위 테스트의 주목표는 아니다. 더 나은 설계는 단지 좋은 부수 효과일 뿐이다.

그럼 단위 테스트의 목표는 무엇인가? 소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 하는 것이다.

그림 1.1은 테스트가 없는 일반 프로젝트의 성장 추이를 보여준다. 처음에는 발목을 잡을 것이 없으므로 빨리 시작할 수 있다. 그러나 시간이 지나면서 점점 더 많은 시간을 들여야 처음에 보여준 것과 같은 정도의 진척을 낼 수 있다.

그림 1.1 테스트 유무에 따른 프로젝트 간 성장 추이의 차이, 테스트가 없는 프로젝트의 경우 시작은 유리하지만, 이내 진척이 없을 정도로 느려진다.

하나의 버그를 수정하면 더 많은 버그를 양산하고, 소프트웨어의 한 부분을 수정하면 다른 부분들이 고장 난다. 테스트는 안전망 역할을 하며, 새로운 기능을 도입하거나 새로운 요구 사항에 더 잘 맞게 리팩터링한 후에도 기존 기능이 잘 작동하는지 확인하는 데 도움이 된다.

지속성과 확장성이 핵심이며, 이를 통해 장기적으로 개발 속도를 유지할 수 있다.

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

단위 테스트가 프로젝트 성장에 도움이 되는 것은 맞지만, 테스트를 작성하는 것만으로는 충분하지않다. 잘못 작성한 테스트는 여전히 같은 결과를 낳는다.

그림 1.2 테스트가 좋은지 나쁜지에 따른 프로젝트 간 성장 추이의 차이. 테스트가 잘못 작성된 프로젝트는 초반에는 테스트가 잘 작성된 프로젝트의 속성을 보여주지만, 결국 침체 단계에 빠진다.

일부 테스트는 아주 중요하고 소프트웨어 품질에 매우 많은 기여를 한다. 그 밖에 다른 테스트는 그렇지 않다. 잘못된 경고가 발생하고, 회귀 오류를 알아내는 데 도움이 되지 않으며, 유지 보수가 어렵고 느리다.

제품 코드 대 테스트 코드

사람들은 종종 제품 코드와 테스트 코드가 다르다고 생각한다. 테스트는 제품 코드에 추가된 것으로 간주되며 소유 비용이 없다. 또한 사람들은 종종 테스트가 많으면 많을수록 좋다고 생각한다. 하지만 그렇지 않다. 코드는 자산이 아니라 책임이다. 코드가 더 많아질수록, 소프트웨어 내의 잠재적인 버그에 노출되는 표면적이 더 넓어지고 프로젝트 유지비가 증가한다. 따라서 가능한 한 적은 코드로 문제를 해결하는 것이 좋다. 테스트도 역시 코드다. 특정 문제를 해결하는 것, 즉 애플리케이션의 정확성을 보장하는 것을 목표로하는 코드베이스의 일부로 봐야 한다. 다른 코드와 마찬가지로 단위 테스트도 버그에 취약하고 유지보스가 필요하다.

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

가장 널리 사용되는 두 가지 커버리지 지표(코드 커버리지와 분기 커버리지)를 어떻게 계산하고 어떻게 사용하는지 살펴본다.

프로그래머가 특정 커버리지 숫자를 목표로 하는 것이 해로운 이유와 테스트 스위트 품질을 결정하는 데 커버리지 지표에 의존할 수 없는 이유를 알아본다.

커버리지 지표는 테스트 스위트가 소스 코드를 얼마나 실행하는지를 백분율로 나타낸다.

커버리지 지표는 각기 다른 유형이 있으며, 테스트 스위트의 품질을 평가하는 데 자주 사용된다. 일반적으로 커버리지 숫자가 높을수록 더 좋다. 커버리지 지표는 중요한 피드백을 주더라도 테스트 스위트 품질을 효과적으로 측정하는 데 사용될 수 없다. 커버리지 지표를 높이기 위해서 테스트를 아무렇게나 작성한다면 커버리지를 100%로 만들수 있기 때문이다.

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

우선 가장 많이 사용되는 커버리지 지표로 코드 커버리지(Code Coverage)가 있으며, 테스트 커버리지(Test Coverage)로도 알려져 있다.

\[코드\ 커버리지(테스트\ 커버리지) = {실행\ 코드\ 라인 수\over전체\ 라인\ 수}\]

예제 1.1에 IsStringLong() 메서드와 이를 다루는 테스트가 있다. 메서드는 입력 매개변수로 제공된 문자열이 긴지 여부를 판별한다(여기서 ‘길다’는 표현은 다섯 자보다 긴 문자열을 의미한다).

예제 1.1 메서드를 부분적으로 다루는 테스트
1
2
3
4
5
public void Test()
{
    bool result = isStringLong("abc");
    Assert.Equal(false, result);
}

메서드 전체 라인 수는 (중괄호를 포함해) 5이다. 테스트가 실행하는 라인 수는 4이다. 테스트는 true를 반환하는 구문을 제외한 모든 코드 라인을 통과한다. 따라서 코드 커버리지는 4/5 = 0.8 = 80%이다.

이제 메서드를 리팩터링하고 불필요한 if 문을 한 줄로 처리하면 어떻게 될까?

1
2
3
4
5
6
7
8
9
10
public static bool IsStringLong(string input)
{
    return input.Length > 5;
}

public void Test()
{
    bool result = IsStringLong("abc");
    Assert. Equal(false, result);
}

테스트는 이제 코드 세 줄(반환문과 중괄호 두개)을 모두 점검하기 때문에 코드 커버리지가 100%로 증가했다. 이 간단한 예제는 커버리지 숫자에 대해 얼마나 쉽게 장난칠 수 있는지 보여준다.

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

분기 커버리지는 코드 커버리지의 단점을 극복하는 데 도움이 되므로 코드 커버리지보다 더 정확한 결과를 제공한다. 원시 코드 라인 수를 사용하는 대신 if 문과 Switch 문과 같은 제어 구조에 중점을 둔다.

\[분기\ 커버리지 = {통과\ 분기\over전체\ 분기\ 수}\]

분기 커버리지 지표를 계산하려면 코드베이스에서 모든 가능한 분기를 합산하고 그중 테스트가 얼마나 많이 실행되는지 확인해야 한다. 이전의 예를 다시 보자.

1
2
3
4
5
6
7
8
9
10
public static bool IsStringLong(string input)
{
    return input.Length > 5;
}

public void Test()
{
    bool result = IsStringLong("abc");
    Assert. Equal(false, result);
}

IsStringLong()에 두 개의 분기가 있는데, 테스트는 이런 분기 중 하나에 대해서만 적용되므로 분기 커버리지 지표는 1/2 = 0.5 = 50%이다. 이전과 같이 if 문을 사용하는 더 짧은 표기법을 사용하든, 테스트 코드는 어떻게 작성해도 50%이다.

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

분기 커버리지로 코드 커버리지보다 더 나은 결과를 얻을수 있지만, 다음과 같은 이유로 어떤 커버리지 지표도 테스트 스위트의 품질을 결정 할 수 없다.

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

각각의 이유를 자세히 살펴보자.

다음 예제는 IsstringLong 메서드의 다른 버전을 보여준다. MasLaststringLong 공개 속성(public property)에 마지막 결과를 기록한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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% 분기 커버리지의 동일한 결과를 보여준다.

1
2
3
4
5
public void Test().
{
    bool result1 = IsStringLong("abc"); // true 반환
    bool result2 = IsStringLong("abcdef"); // false 반환
}

이 테스트에서는 코드 커버리지와 분기 커버리지가 둘 다 100%를 나타내고 있다. 그러나 아무것도 검증하지 않기 때문에 전혀 쓸모가 없다.

고된 작업 이야기

검증 없는 테스트의 개념은 어리석은 생각으로 보일 수 있지만, 현장에서 실제로 일어난다. 모든 테스트를 try-catch 구문으로 감싸고 검증문을 빼면 테스트는 통과를 보증했다. 사람들은 의무적으로 100% 커버리지 요구 사항을 충족하려고 무의식적으로 테스트를 작성하기 시작했다. 이러한 테스트는 프로젝트에 아무런 가치도 부여하지 않았다. 더구나 생산적인 활동에서 벗어나는 모든 노력과 시간 그리고 테스트를 계속 유지하는 데 필오한 유지비 때문에 프로젝트를 망치게 됐다.

그러면 테스트 대상 코드에 대해 각각의 결과를 철저히 검증한다고 가정하자. 이렇게 하면 룬기 커버리지 지표와 함께 신뢰할 수 있는 구조라고 할 수 있는가? 또 테스트 스위트 품질을 결정하는 데 사용할 수 있는가? 안타깝게도 아니다.

두 번째 문제는 모든 커버리지 지표가 테스트 대상 시스템이 메서드를 호출할 때 외부 라이브러리가 통과하는 코드 경로를 고려하지 않는다는 것이다. 다음 예를 들어보자.

1
2
3
4
5
6
7
8
9
10
public static int Parse(string input)
{
    return int.Parse(input);
}

public void Test()
{
    int result = Parse("5");
    Assert. Equal(5, result);
}

분기 커버리지 지표는 100%로 표시되며, 테스트는 메서드 결과의 모든 구성 요소를 검 증한다. .NET 프레임워크의 int.Parse 메서드가 수행하는 코드 경로는 고려하지 않는다. 입력 매개변수를 변경하면 다른 결과로 이어질 수 있고 테스트로부터 숨어있는 분기가 많다.

  • 널값
  • 빈 문자열
  • “정수가 아님”
  • 너무 긴 문자열

수많은 예외 상황eeye case에 빠질 수 있지만, 테스트에서 모든 예외 상황을 다루는지 확인할 방법이 없다.

## 커버리지 지표를 보는 가장 좋은 방법은 지표 그 자체로 보는 것이며, 목표로 여겨서는 안 된다.

특정 커버리지 숫자를 목표로 하는 것은 단위 테스트의 목표와 반대되는 그릇된 동기 부여가 된다. 사람들은 중요한 것을 테스트하는 데 집중하는 대신 인공적인 목표를 달성하기 위한 방법을 찾기 시작한다. 목표를 달성하기 위해서 의미 없는 테스트를 작성하는것처럼 말이다.

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

테스트 스위트가 얼마나 좋은지 자동으로 확인할 수 없다는 것이다. 개인 판단에 맡겨야 한다. 그러나 전체적인 관점에서 성공적인 테스트 스위트는 다음과 같은 특성을 갖고 있다

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

1.4.1 개발 주기에 통합돼 있음

자동화된 테스트를 할 수 있는 방법은 끊임없이 하는 것뿐이다. 모든 테스트는 개발 주기에 통합돼야 한다. 이상적으로는 코드가 변경될 때마다 아무리 작은 것이라도 실행해야 한다.

1.4.2 코드베이스에서 가장 중요한 부분만을 대상으로 함

시스템의 가장 중요한 부분에 단위 테스트 노력을 기울이고, 다른 부분은 간략하게 또는 간접적으로 검증하는 것이 좋다. 비즈니스 로직 테스트가 시간 투자 대비 최고의 수익을 낼 수 있다

1.4.3 최소 유지비로 최대 가치를 끌어냄

테스트를 빌드 시스템에 통합하는 것만으로는 충분하지 않으며, 도메인 모델에 높은 테스트 커버리지를 유지하는 것도 충분하지 않다. 또한 가치가 유지비를 상회하는 테스트만 스위트에 유지하는 것이 중요하다.

요약

  • 코드는 겸점 나빠지는 경향이 있다. 코드베이스에 변경이 생길 때마다 무질서도, 즉 엔트로피가 증가한다. 지속적인 정리와 리팩터링 등과 같은 적절한 관리가 없으면 시스템은 점점 더 복잡해지고 흐트러진다. 테스트로 이러한 경향을 뒤집을 수 있다. 테스트는 안건망 역할을 하며, 대부분의 회귀에 대한 보험을 제공하는 도구라 할 수 있다.
  • 단위 테스트를 작성하는 것이 중요하다. 마찬가지로 좋은 단위 테스트를 작성하는 것도 중요하다. 잘못된 테스트가 있거나 테스트 자체가 없는 프로젝트는 모두 똑같이 침체 단계에 있거나 매 릴리스마다 회귀가 많이 생긴다.
  • 단위 테스트의 목표는 소프트웨어 프로젝트가 지속적으로 성장하게 하는 것이다. 좋은 단위 테스트 스위트는 개발 속도를 지키면서 침체 단계에 빠지지 않게 한다. 이러한 테스트 스위트가 있다면 변경 사항이 회귀로 이어지지 않을 것이라고 확신해도 좋다. 이렇게 하면 코드를 리팩터링하거나 새로운 기능을 추가하는 것이 더 쉬워진다.
  • 모든 테스트를 똑같이 작성할 필요는 없다. 각각의 테스트는 비용과 편익 요소가 있으며, 둘 다 신중하게 따져볼 필요가 있다. 테스트 스위트 내에 가치 있는 테스트만 남기고 나머지는 모두 제거하라. 애플리케이션과 테스트 코드는 모두 자산이 아니라 부채다.
  • 단위 테스트 코드 기능은 좋은 리트머스 시험이지만, 한 방향으로만 작동한다. 좋은 부정 지표(단위 테스트를 할 수 없는 코드는 품질이 좋지 않음)이지만 나쁜 긍정 지표(단위 테스트를 할 수 있다고 품질을 보증하지는 않음)이기도 하다.
  • 마찬가지로 커버리지 지표는 좋은 부정 지표이지만 나쁜 긍정 지표다. 커버리지가 낮다는 것은 문제의 징후이지만, 커버리지가 높다고 해서 테스트 스위트의 품질이 높은 것은 아니다.
  • 분기 커버리지로 테스트 스위트의 완전성에 대해 더 나은 인사이트를 얻을 수 있지만, 테스트 스위트가 충분한지는 여전히 알 수 없다. 검증문이 있는지 신경 쓰지 않고, 코드베이스가 사용하는 서드파티 라이브러리의 코드 경로도 다루지 않는다.
  • 특정 커버리지 숫자를 부과하면 동기 부여가 잘못된 것이다. 시스템의 핵심 부분에 커버리지를 높게 갖는 것은 좋지만, 이 높은 수준을 요건으로 삼는 것은 좋지 않다.
  • 성공적인 테스트 스위트는 다음과 같은 특성을 나타낸다.
    • 개발 주기에 통합돼 있다.
    • 코드베이스 중 가장 중요한 부분만을 대상으로 한다.
    • 최소한의 유지비로 최대의 가치를 끌어낸다.
  • 단위 테스트의 목표를 달성하기 위한 유일한 방법은 다음과 같다.
    • 좋은 테스트와 좋지 않은 테스트를 구별하는 방법을 배운다.
    • 테스트를 리팩터링해서 더 가치 있게 만든다.

Source File: 1-1_goal.md

Updated:

Comments