💻 개발 이야기/Unit test

[단위 테스트] 단위 테스트의 목표

Jinseong Hwang 2022. 7. 5. 03:29

 

안녕하세요. 황진성입니다.

이번 글에서는 "단위 테스트 1장"을 읽고 내용 정리와 제 생각을 정리해보겠습니다.

 

"단위 테스트"를 배우는 것은, 언어나 프레임워크를 배우는 것과 마찬가지로 하나의 기술을 익히는 것에 불과합니다.

단위 테스트를 작성하는 기술을 익혔다면, 잘 적용해야 합니다.

 

  1. 유지 보수가 필요 없으며 끊임없이 변화하는 요구사항에 유연하게 대응할 수 있는 프로젝트
  2. 늘 많은 버그와 유지비로 진행이 점점 느려지는 프로젝트

똑같이 단위 테스트를 적용했다고 하더라도, 단위 테스트를 적용하는 기술의 차이에 따라 1번과 2번으로 나뉘게 됩니다.

1번 프로젝트로 진행되게 하기 위해서는 단위 테스트 기술을  '잘' 익히는 것이 중요합니다.

 

차근차근 단위 테스트 기술에 대해 알아가 봅시다.

 

 

 

1. 단위 테스트 현황


이미 많은 회사에서 단위 테스트를 적용하도록 강요하다시피 하고 있으며, 대부분의 회사에서 필수로 간주될 정도입니다. 또한, 대부분의 프로그래머는 단위 테스트의 필요성을 이미 알고 있습니다. 따라서 단위 테스트를 적용해야 하는지는 더 이상 논쟁거리가 아닙니다.

 

하지만 단위 테스트를 '잘' 작성하지 못한다면, 개발자는 테스트를 신뢰할 수 없으며 테스트가 있는 메서드에서 예외가 발생할 수도 있습니다. 이러한 상황은 오히려 테스트가 없는 것보다 상황을 더 악화시킬 수 있습니다.

 

 

2. 단위 테스트의 목표


소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 하는 것.

 

단위 테스트가 없다면, 프로젝트 초기에는 개발 속도가 더 빠릅니다. 하지만 시간이 갈수록 개발 속도가 현저히 느려집니다. 심지어 프로젝트 진행이 아예 불가능해질 수도 있습니다. 개발 속도가 빠르게 감소하는 현상을 보고 "소프트웨어 엔트로피(software entropy)"라고도 합니다. 엔트로피는 무질서도의 정도를 나타냅니다. 지속적으로 리팩터링 마저도 진행하지 않는다면 엔트로피는 급격하게 증가합니다.

출처 : https://velog.io/@mabr2845/단위-테스트-1장

 

단위 테스트는 이러한 경향을 뒤집을 수 있습니다. 안전망 역할을 하며, 대부분 회귀에 대한 보험을 제공하는 도구라고 볼 수 있습니다. 새로운 요구 사항에 맞게 변경이 일어난 후에도, 잘 동작하는지 확인하는 데 도움이 됩니다.

 

이 까지만 보면, 장점만 있는 것 같은데요. 한 가지 단점이 존재합니다. 프로젝트 초기에 테스트를 작성하는 데 노력이 많이 필요하다는 점입니다.

 

다시 돌아가서, 그렇다면 테스트가 많은 것이 무조건 좋을까요? 그건 아닙니다. 요구사항이 변경되면 Production 코드가 변경됩니다. Production 코드가 변경되면 Test 코드 역시 변경되어야 합니다. 기능 변경뿐만 아니라, 리팩터링 할 시에도 함께 해줘야 하니 시간이 더 많이 소요됩니다. 하지만 고품질의 테스트를 계속해서 유지하기 때문에 시스템의 규모가 커지더라도 작업 시간을 비슷하게 유지할 수 있습니다.

 

 

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


이번에는 두 가지 커버리지 지표에 대해 알아보겠습니다. 이를 어떻게 계산하고 사용하는지도 알아보고, 테스트 커버리지 숫자를 목표로 했을 때 어떤 문제점이 있는지도 함께 알아보겠습니다.

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

 

코드 커버리지가 너무 낮을 때(ex. 10%)는 테스트가 충분하지 않다는 증거입니다. 하지만 반대의 경우에는 증거가 되지 않습니다. 코드 커버리지가 100%라고 해서 반드시 양질의 테스트 스위트라고 볼 수 없습니다.

 

테스트 커버리지는 코드 커버리지분기 커버리지로 구분 됩니다.

각각에 대해 알아봅시다.

 

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

코드 커버리지는 아래 수식으로 계산할 수 있습니다.

 

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

 

아래에 입력된 문자열의 길이가 5 초과이면 긴 문자열이라고 판단하는 isStringLong 메서드가 하나 있습니다.

그리고 isStringLong 메서드를 테스트하는 테스트 코드가 있습니다.

public class Example1_1_v1 {

    public static boolean isStringLong(String input) {
        if (input.length() > 5) {
            return true;
        }
        return false;
    }

    @Test
    void test() throws Exception {
        boolean result = isStringLong("abc");
        assertFalse(result);
    }
}

isStringLong 메서드는 가장 바깥 중괄호를 제외하고 총 4줄의 코드입니다. 하지만 실행하게 되면 true를 반환하는 줄을 제외하고 3줄만 실행됩니다. 이 때는 코드 커버리지가 3/4 = 0.75 = 75% 입니다.

 

그렇다면 isStringLong 메서드를 리팩터링 해서 불필요한 if문을 한 줄로 처리하면 어떻게 될까요?

public class Example1_1_v2 {

    public static boolean isStringLong(String input) {
        return input.length() > 5;
    }

    @Test
    void test() throws Exception {
        boolean result = isStringLong("abc");
        assertFalse(result);
    }
}

이제는 1줄의 코드로 정리되었고, 실행하면 1줄의 코드가 모두 실행됩니다. 이 때는 코드 커버리지가 1/1 = 1 = 100% 입니다.

 

이 상황을 보고 테스트 스위트가 개선되었다고 볼 수 있을까요? 물론 아닙니다.

테스트로 검증하는 상황은 똑같지만, 단순한 if문의 제거로 코드 커버리지가 올라갔습니다.

 

이 예제로 코드 커버리지 숫자로 얼마나 쉽게 장난칠 수 있는지를 보여줍니다. 검증하는 코드 라인 수 만을 가지고 테스트 스위트의 가치를 판단하기 힘들다는 것을 알려줍니다.

 

 

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

분기 커버리지는 아래 수식으로 계산할 수 있습니다.

 

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

 

 

방금 봤던 예제로 분기 커버리지를 계산해봅시다.

public class Example1_1_v2 {

    public static boolean isStringLong(String input) {
        return input.length() > 5;
    }

    @Test
    void test() throws Exception {
        boolean result = isStringLong("abc");
        assertFalse(result);
    }
}

isStringLong 메서드에 2개의 분기가 있는데, 테스트는 2개의 분기 중 하나에만 적용되므로 분기 커버리지 지표는 1/2 = 0.5 = 50% 입니다. 분기 커버리지를 측정할 때는 if를 사용하든 더 짧게 표기를 하든 상관없습니다. 분기 커버리지 지표는 분기의 개수만 다룹니다. 코드를 어떻게 작성해도 상관없다는 것이 단점입니다.

 

 

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

위 경우들을 살펴봤을 때, 분기 커버리지를 사용해서 코드 커버리지보다 좋은 결과를 얻을 수 있었지만, 테스트 스위트의 품질을 결정하는 데 어떤 커버리지 지표도 의존할 수 없다는 것을 알 수 있었습니다. 아래 2가지로 그 이유를 정리해볼 수 있습니다.

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

 

다른 예제로 조금 더 알아봅시다.

 

앞 예제와 다른 것은, 이전에 호출했던 isStringLong 메서드의 결과를 boolean 변수에 저장해둔다는 점입니다.

public class Example1_2 {

    public static boolean wasLastStringLong;

    public static boolean isStringLong(String input) {
        boolean result = input.length() > 5;
        wasLastStringLong = result; // -> 첫 번째 결과
        return result; // -> 두 번째 결과
    }

    @Test
    void test() throws Exception {
        boolean result = isStringLong("abc");
        assertFalse(result); // -> 두 번째 결과만 검증
    }
}

이제 isStringLong 메서드는 반환하는 값뿐만 아니라, 외부 변수에 결과를 저장하는 암묵적인 행위도 포함됩니다.

외부로 나가는 것을 검증할 수 없다는 것을 의미합니다. 사실, 면밀히 말하자면 불가능보다는 시스템이 커질수록 불가능에 가까워진다고 표현하고 싶네요.

 

아래 예제는 더욱 극단적인 상황인데요,

public class Example1_3 {

    public static boolean isStringLong(String input) {
        return input.length() > 5;
    }

    @Test
    void test() throws Exception {
        boolean result1 = isStringLong("abc");
        boolean result2 = isStringLong("abcdef");
    }
}

이 테스트는 코드 커버리지와 분기 커버리지가 둘 다 100% 입니다. 그 이유는 검증(Assertion) 코드가 없기 때문입니다.

테스트 코드를 작성했지만, 검증하지 않는다면 아무 쓸모가 없습니다.

 

그 외 다른 경우를 생각해보자면, 아래와 같은 경우가 있습니다.

public static int parse(String input) {
	return Integer.valueOf(input);
}

input으로 아래의 값이 들어가면 어떻게 될까요?

  • null
  • 빈 문자열
  • 정수가 아닌 실수의 문자열 형태
  • 너무 긴 문자열

이 외에도 수많은 엣지 케이스가 존재하겠지만 이를 모두 테스트하는지 확인할 방법이 없습니다. 따라서 커버리지 지표는 목표로 삼는 것보다는 단지 하나의 지표로 바라보는 것이 좋습니다.

 

 

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


그렇다면, 테스트 스위트의 품질은 어떻게 측정할 수 있을까요?

 

책에서는 성공적인 테스트 스위트의 특성을 아래와 같이 3가지로 정의하고 있습니다.

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

 

개발 주기에 통합돼 있다.

모든 테스트는 개발 주기에 통합돼야 합니다. 아무리 작은 코드라도 변경될 때마다 실행되어야 합니다.

 

저는 프로젝트 빌드 도구로 Gradle을 많이 사용하는데요. 빌드를 하기 위해서는 gradlew build 명령어를 사용합니다. 이는 단순히 빌드를 하는 가장 기본적인 명령어이지만, 프로젝트 내의 모든 테스트 코드를 실행해보고 테스트가 성공해야지만 빌드에 성공합니다. Gradle에서 제공하는 기본적인 build 명령을 사용해도 좋을 것 같습니다.

 

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

시스템의 가장 중요한 부분에 단위 테스트 노력을 기울이는 것이 효율적입니다. 가장 중요한 부분은 비즈니스 로직이 있는 부분입니다. 유한한 시간이 있다면 비즈니스 로직의 테스트에 더 많은 노력을 기울이라는 의미지, 다른 부분에 노력을 하지 말라는 의미가 아닙니다. 단, 간략하고 간접적으로 테스트해도 좋습니다.

 

최소한의 유지비로 최대의 가치를 끌어낸다.

고품질의 테스트만 유지해야 합니다. 테스트를 수정하는 데 너무 많은 시간이 소요된다면, 해당 테스트를 유지할지 고민해봐야 합니다. 즉, 테스트의 가치가 이를 유지하는 비용보다 높아야 합니다.

 

 

5. TL;DR


  • 단위 테스트는 처음에는 개발 속도를 느리게 할지 몰라도, 복잡도가 증가할수록 개발 속도를 오히려 더 빠르게 한다.
  • 테스트를 바라보는 기준에 따라 다르지만, 테스트 커버리지가 높다고 좋은 것이 아니다.
  • 테스트하기 어렵다면, production 코드의 설계가 잘못되었을 확률이 크다.

 

 

 

이 글에서 사용된 모든 코드는 여기에서 확인하실 수 있습니다.
더불어, 책에서 사용된 C# 코드를 Java+JUnit5 로 수정하는 작업 진행 중이니 계속 follow up 해주세요 😊

 

감사합니다.