Post

[Book - JUnit IN ACTION 3판] 6. 테스트 품질

테스트 커버리지 도구를 이용해서 테스트가 커버한 코드와 커버하지 못한 코드가 얼마나 되는지를 확인하고, 해당 테스트가 얼마나 유용한지를 계산해 보자.
그리고 TDD가 무엇인지 간단하게 알아보고 어떻게 하면 테스트하기 쉬운 코드를 작성할 수 있는지를 알아보자.

테스트 커버리지 측정하기

단위 테스트를 작성하면 애플리케이션을 변경하고 리팩터링할 때 확신을 가질 수 있다.

테스트 커버리지는 그 자체로 코드의 품질을 어느 정도 보증한다.
하지만 높은 테스트 커버리지가 테스트의 질을 보장하지는 않는다.

테스트 커버리지란 무엇인가

블랙박스 테스트를 사용하여 애플리케이션의 public API를 커버하는 테스트를 만들 수 있다.
소스 코드를 잘 모르는 상태에서 설계 문서만을 가이드로 사용하기 때문에 특별한 조건에 맞는 특별한 파라미터가 필요한 테스트를 만들지는 못한다.

메서드가 어떻게 구현되어 있는지 자세히 알고 있다면 단위 테스트를 작성할 수 있다.
테스트 대상 메서드에 분기문이 있을 경우 분기마다 하나씩 단위 테스트를 작성해야 한다.
단위 테스트를 만들기 위해서는 테스트 대상 메서드를 확인해야만 하므로 이러한 유형의 테스트는 화이트박스 테스트에 속한다.

일반적으로 화이트박스 테스트(단위 테스트)를 활용하면 더 높은 테스트 커버리지를 얻을 수 있다.
더 많은 메서드에 접근할 수 있을뿐더러 각 메서드에 대한 입력과 보조 객체(스텁 or 모의객체)의 동작을 제어할 수 있기 때문이다.
화이트박스 테스트는 protected, package-private, public 메서드에도 실행할 수 있으므로 블랙박스 테스트보다 일반적으로 코드 커버리지가 더 높게 나온다.

테스트 커버리지 지표는 테스트 묶음을 실행하고 코드를 분석하는 도구로 파악할 수 있다.

코드 커버리지를 측정하는 도구

IntelliJ IDEA에서는 [Run … with Coverage]로 테스트 커버리지를 측정할 수 있다.

명령 프롬프트에서 코드 커버리지 테스트를 실행하는 것을 권장한다.
이는 CI/CD를 이루는 일이기도 하다.
이를 위해서는 JaCoCo 플러그인을 사용하는 것이 좋다.


테스트 커버리지를 측정해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class CalculatorTest {
    Calculator calculator = new Calculator();

    @Test
    void testAdd() {
        double sum = calculator.add(10, 50);
        assertEquals(60, sum, 0);
    }

    @Test
    void testSqrt() {
        double sqrt = calculator.sqrt(2);
        assertEquals(1.41421356, sqrt, 0.000001);
    }

    @Test
    void testDivide() {
        double quotient = calculator.divide(1, 3);
        assertEquals(0.33333333, quotient, 0.000001);
    }

    @Test
    void expectIllegalArgumentException() {
        assertThrows(IllegalArgumentException.class, () -> calculator.sqrt(-1));
    }

//    @Test
//    void expectArithmeticException() {
//        assertThrows(ArithmeticException.class, () -> calculator.divide(1, 0));
//    }
}

위 클래스에서 기존 테스트 중 일부를 제외한 다음 테스트를 실행하면 코드 커버리지가 100%가 아니다.
[Run … with Coverage]로 테스트 커버리지를 측정해서 나온 리포트에서 [Generate Coverage Report]를 클릭해 보자.
아래 html 형식의 리포트를 확인해 보자.

Test Coverage Html

이 리포트를 확인해서 단위 테스트 중 빠진 부분을 확인할 수 있어서 도움이 될 것 같다.


테스트하기 쉬운 코드 작성하기

애플리케이션이 얼마나 복잡한지에 따라 테스트 코드 작성의 난이도가 다르다.
가장 좋은 사례는 가독성이 좋고 테스트하기 편하도록 가능한 한 소스 코드를 단순하게 작성하는 것이다.

public API는 정보 제공자와 정보 사용자 간의 계약이다.

public 메서드의 시그니처를 변경했다면 애플리케이션이나 단위 테스트의 메서드 호출을 전부 변경해야 하니 항상 주의해야 한다.

특히 TDD로 작업한다면 아직 사용자가 없는 초기 개발 단계에서 API를 리팩터링하는 것도 좋다.
본격적으로 사용자가 생겨나면 상황은 달라질 것이다.

double 타입의 파라미터를 받아 거리를 계산하는 public 메서드가 있다고 하자.
그리고 해당 메서드를 블랙박스 테스트로 검증한다고 생각해 보자.
특정 시점에서 파라미터의 단위가 마일에서 킬로미터로 변경되었을 때, 코드는 여전히 컴파일은 되지만 런타임에서 에러가 날 것이다.
어디가 어떻게 잘못되었는지 알려 주는 단위 테스트가 없다면 소스 코드를 디버깅하는데 시간을 허비해야 할 수 있다.

단위 불일치 사례는 왜 모든 public 메서드를 테스트해야 하는지를 잘 보여 준다.
물론 public이 아닌 메서드도 자세히 분석해서 화이트박스 테스트를 수행해야 한다.

의존성 줄이기

단위 테스트는 코드를 격리된 상태에서 검증한다는 점을 명심해야 한다.

단위 테스트는 테스트 대상 클래스를 인스턴스화한 다음 테스트 대상 단위를 사용해 보고 나서 정확성을 검증한다.
그러므로 테스트 케이스는 단순해야 한다.

테스트하기 쉬운 코드를 작성하려면 의존성을 최대한 줄여야 한다.
클래스가 인스턴스화되고 특정한 상태로 설정해야 하는 다른 클래스에 많이 의존하는 경우, 테스트하기가 매우 복잡해지며 복잡한 모의 객체를 만들어야 할 수도 있다.

의존성을 줄이려면 메서드를 분리해야 한다.
특히 객체를 인스턴스화하는 메서드(팩터리 메서드)와 비즈니스 로직을 갖고 있는 메서드를 분리하는 것이 중요하다.

1
2
3
4
5
6
7
8
9
public class Vehicle {

    Driver d = new Driver();
    boolean hasDriver = true;

    private void setHasDriver(boolean hasDriver) {
        this.hasDriver = hasDriver;
    }
}

위 코드는 Vehicle 객체가 생성될 때 Driver 객체도 같이 만들어지므로
두 객체가 강하게 결합되어 있고, Vehicle 클래스가 Driver 클래스에 의존하는 문제가 있다.

따라서 아래 코드로 변경해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class VehicleDI {

    Driver d;
    boolean hasDriver = true;

    VehicleDI(Driver d) {
        this.d = d;
    }

    private void setHasDriver(boolean hasDriver) {
        this.hasDriver = hasDriver;
    }
}

위 코드는 이제 의존성 주입(dependency injection)을 통해 의존성이 필요한 시점에 그에 맞는 의존성을 전달할 수 있다.

Raw of Demeter

최소 지식의 법칙으로도 알려져 있는 (디미터, 데메테르)의 법칙은 클래스는 알아야 할 만큼의 정보만 가져야 한다는 것이다.

객체를 요구하되 객체 안에서 다시 찾지 않으며 현재 애플리케이션에 꼭 필요한 객체만 요청한다는 것이 핵심이다.

객체의 내부 구조를 외부로 드러내지 않는 것이 중요하며 이를 준수하기 위해 객체 간 .(dot)를 최대한 쓰지 않도록 해야 한다.
즉, 한번에 A.B.C를 하는 것이 아니라,
C에서 B로, B에서 A로 값을 캡슐화하여 값을 가져오도록 하는 것입니다.

단, DTO와 자료구조 같은 경우에는 내부 구조를 외부에 노출하는 것이 당연하므로 디미터 법칙을 적용하지 않고,
Stream API를 사용하는 경우에도 객체의 캡슐화와는 관계가 없기 때문에 두 개 이상의 .(dot)을 써도 디미터의 법칙을 위배되지 않는다.

숨은 의존성과 전역 상태 피하기

전역 상태는 매우 주의해서 관리해야 한다.
정말 많은 클라이언트가 전역 객체를 사용할 수 있기 때문이다.
전역 상태를 공유하는 것은 때때로 의도하지 않은 결과를 만들어 낸다.
공유 접근을 고려하지 않는 코드에서 전역 객체가 사용되거나, 클라이언트가 전역 객체가 배타적일 거라 생각하고 접근할 때는 더욱 위헙하다.

전역 상태는 되도록 피하는 게 좋다.
전역 객체에 대한 접근을 허용하면 단순히 전역 객체에만 접근을 공유하는 게 아니라, 그 전역 객체가 참조하는 모든 객체를 공유하게 되기 때문이다.

제네릭 메서드 사용하기 (다형성, Polymorphism)

하나의 객체가 둘 이상의 IS-A 관계를 갖도록 만드는 다형성(polymorphism)을 활용한다면 호출할 메서드가 컴파일 타입에 결정되지 않도록 만들 수 있다.

정적 코드를 남발하거나, 애플리케이션을 개발 시 다형성을 활용하지 못하면 애플리케이션뿐만 아니라 테스트에도 문제가 생긴다.
다형성을 활용하지 않는다는 것은 애플리케이션과 테스트 모두에서 코드를 재사용하지 않는다는 뜻이기 때문이다.
이런 상황은 애플리케이션과 테스트에서 코드 중복이 생길 수 있으므로 반드시 피해야 한다.

파라미터에 구체적인 타입을 명시해야 하는 정적 유틸 메서드가 있다면 반드시 제네릭을 사용해야 한다.

1
2
3
4
5
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
    Set<E> result = new HashSet<>(s1);
    result.addAll(s2);
    return result;
}

제네릭 메서드를 사용하면 컴파일 시 경고가 뜨지도 않고,
타입 안정성이 보장되며,
사용 및 테스트하기가 쉽다.

상속보다는 합성 활용하기

코드 재사용을 위해 상속을 화용하곤 한다.
그러나 상속보다는 합성이 테스트하기 쉽다.

런타임에 상속 구조를 변경할 수는 없지만 객체를 다르게 합성하는 것이 가능하기 때문이다.
결국 목표는 런타임 시 코드를 최대한 유연하게 만드는 것이다.
합성을 사용하면 객체의 상태를 변화시키는 게 쉬워지고 테스트하기 쉬워진다.

상위 클래스와 하위 클래스가 동일한 개발장의 제어하에 있는 패키지 내에 있다면 상속을 활용하는 게 바람직하다.
그러나 패키지 밖의 구체 클래스를 상속하는 것은 위험할 수 있다.

하위 클래스가 상위 클래스의 서브타입일 때 상속을 고려하는 것이 좋다.
클래스 A와 B가 있을 때 이를 연관지을 수 있는지 생각해 보자.
클래스 B가 클래스 A를 상속해야 한다면 두 클래스 간에 IS-A 관계가 있어야 한다.

  • 상속(inheritance) : 상위 클래스에 중복 로직을 구현해두고, 이 abstract를 물려받아 코드를 재사용하는 방법이다.
    • IS-A 관계 : 상속관계로, 자식 클래스가 부모 클래스를 상속받기 위해 사용한다는 의미를 가지고 있다.
      • ex) 카테고리 구별에 사용
  • 합성(composition) : 중복되는 로직들을 갖는 객체를 구현하고, 이 객체를 주입받아 중복 로직을 호출함으로써 public interface를 재사용하는 방법이다.
    • HAS-A 관계 : “~은 ~을 가지고 있다”라는 하위 클래스가 상위 클래스를 포함한다는 의미를 가지고 있다.
      • ex) 특정 기능 생성에 사용

분기문보다는 다형성 활용하기

복잡도를 낮추기 위해서는 switch 문이나 if 문을 길게 늘어진 분기문을 만들지 않으면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class DocumentPrinter {
    ...

    public void printDocument() {
        switch (document.getDocumentType()) {
            case WORD_DOCUMENT:
                printWORDDocument();
                break;
            case PDF_DOCUMENT:
                printPDFDocument();
                break;
            case TEXT_DOCUMENT:
                printTextDocument();
                break;
            default:
                printBinaryDocument();
                break;
        }
    }
    ...
}

위 코드처럼 분기문이 길고 복잡해진다면 다형성을 활용하는 것을 고려해 보자.
다형성은 객체를 여러 작은 클래스로 나누어 길고 복잡한 분기문을 대체할 수 있게 해 주는데,
이는 객체 지향 관점에서도 자연스럽다.
여러 작은 요소를 테스트하는 것은 크고 복잡한 요소를 테스트하는 것보다 훨씬 쉽다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class DocumentPrinter {
    ...
    public void printDocument(Document document) {
        document.printDocument();
    }
}
//
public abstract class Document {
    ...
    public abstract void printDocument();
}
//
public class WordDocument extends Document {
    ...
    public void printDocument() {
        printWORDDocument();
    }
}
//
public class PDFDocument extends Document {
    ...
    public void printDocument() {
        printPDFDocument();
    }
}
//
public class TextDocument extends Document {
    ...
    public void printDocument() {
        printTextDocument();
    }
}

위 코드처럼 다형성을 활용하면 수행해야 하는 코드가 런타임에서 결정되므로,
복잡한 분기문이 필요 없어지고, 소스 코드를 이해하고 테스트하기 쉬워진다.


TDD

TDD는 개발자가 테스트를 먼저 작성한 다음 테스트를 통과하는 코드를 작성하는 프로그래밍 기법이다.
코드를 작성한 다음에는 코드를 검사하고 난잡한 부분을 정리하거나 코드의 질을 높이기 위해 리팩터링한다.
TDD의 목적은 ‘작동하는 클린 코드’를 만드는 것이다.

개발 주기에 적응하기

도메인 코드가 API의 클라이언트가 되는 것처럼 테스트 코드 역시 API의 클라이언트가 된다.

TDD에서의 테스트는 설계를 주도하고, 메서드의 첫 번째 클라이언트가 된다.

  • TDD의 장점
    • 목적이 분명한 코드를 작성할 수 있고, 개발자는 애플리케이션이 필요로 하는 것을 정확하게 개발했다는 확신을 얻을 수 있다.
      • 코드를 설계하는 데 테스트를 사용할 수 있다.
    • 새로운 기능을 더 빨리 적용할 수 있다.
      • 테스트는 개발자가 의도대로 코드르 구현하게 유됴하는 힘이 있다.
    • 테스트는 정상적으로 작동하는 기존 코드에 버그가 생기는 것을 방지할 수 있다.
    • 테스트는 개발 문서의 역할을 한다.
      • 테스트를 따르는 것은 소스 코드가 해결해야 하는 문제를 이해하는 것과 같다.

TDD 2단계 수행하기

실제 TDD는 “테스트한다, 코드를 작성한다, 리팩터링한다, (반복한다)”와 같이 진행된다.

리팩터링은 소프트웨어의 외적 동작을 바꾸지 않고 내부적인 구조만 개선함으로써 시스템을 변경하는 과정을 말한다.
이때 외적 동작이 바뀌지 않았다는 것을 증명하기 위해서 테스트를 사용한다.

  • TDD의 핵심 원리
    • 새 코드를 작성하기 전에 실패하는 테스트를 먼저 작성한다.
    • 테스트를 통과하는 가장 단순한 코드를 작성한다.

실패하는 테스트를 항상 먼저 작성하면, 테스트에 성공하는 소스 코드만 작성할 수 있게 된다.


행위 주도 개발

2000년대 중반 Dan North가 주창한 BDD는 비즈니스 요구 사항을 직접적으로 만족하는 IT 솔루션을 만드는 데에 집중한다.
BDD의 철학은 비즈니스 전략, 요구사항, 목표가 개발을 주도하며,
이것들이 시나리오에 구체화된 다음에야 IT 솔루션이 만들어진다는 것이다.

TDD가 품질 좋은 소프트웨어를 만드는 데 기여한다면, BDD는 사용자의 문제를 직접적으로 해결하는 소프트웨어를 만드는 데 기여한다.

BDD 방법론을 잘 따르면 정말 중요한 것이 무엇인지 고민하고 그에 집중하는 방식으로 소스 코드를 작성할 수 있다.
그리고 조직에 어떤 기능이 도움이 되는지, 이를 어떻게 효과적으로 구현할지 알 수 있을 것이다.
사용자가 요구하는 것 이상을 보고 사용자가 필요한 것 이상을 구현하게 된다.

BDD 스타일로 표현된 인수 테스트를 보자.

1
2
3
Given X 회사에서 운영하는 항공편에서
When 5월 15일부터 20일 사이에 부쿠레슈티에서 뉴욕으로 가는 가장 빠른 항공편을 찾는다면
Then 부쿠레슈티-프랑크푸르트-뉴욕을 잇는 최단 경로를 보여준다.

돌연변이 테스트 수행하기

어떻게 하면 테스트의 품질을 평가하고, 테스트가 본연의 역할을 제대로 수행했는지 알 수 있을까?
돌연변이 테스트를 사용해 보자.

돌연변이 분석, 돌연변이 프로그램이라고도 하는 돌연변이 테스트는 새 테스트를 설계하고 기존 테스트의 품질을 평가하는 데 사용한다.

돌연변이는 +를 -로 바꾸는 등 기존 연산자를 다른 연산자로 바꾸거나, if와 else의 내용을 바꾸는 등 일부조건을 뒤집는 등의 돌연변이 연산으로 만든다.
만약 돌연변이 테스트를 통과한다면 테스트가 잘못된 것으로 간주할 수 있다.

돌연변이 테스트는 테스트의 신뢰성을 높이거나, 테스트 데이터의 약점을 찾을 수 있으며, 실행 중에 거의 혹은 전혀 접근할 수 없었던 코드의 약점을 찾을 수도 있다.

돌연변이 테스트 역시 화이트박스 테스트의 일종이다.


개발 주기 내에서 테스트하기

테스트는 개발 주기 내에서 수행할 수 있으며 기본적으로 시간과 장소를 가리지 않는다.

아래 그림은 일반적인 애플리케이션 개발 주기를 보여 준다.

CI App Dev Cycle

  • 개발(development)
    • 개발은 주로 개발자의 작업 장소에서 이루어진다.
    • 한 가지 중요한 원칙은 Git같은 소스 코드 관리 시스템에 하루에 여러 번 commit하는 것이다.
  • 통합(integration)
    • 다른 팀에서 개발한 컴포넌트까지 포함하여 애플리케이션을 빌드하고 여러 컴포넌트가 함께 잘 작동하는지 확인한다.
    • 여기서 문제가 자주 발생하기 때문에 정말 중요하다.
    • 이 단계를 자동화하는 것은 또 하나의 중요한 과제이며, 이를 CI라고 한다.

      CI는 Continuous Integration의 약어로 지속적 통합이다.

  • 인수/부하 테스트(acceptance/stress test)
    • 프로젝트에서 리소스가 얼마나 사용 가능한지에 따라 하나 또는 두 단계로 나눌 수 있다.
    • 부하 테스트 단계에서는 애플리케이션에 부하를 주어 애플리케이션이 적절하게 확장하는지 확인한다.
    • 인수 단계는 프로젝트의 고객이 시스템을 인수하는 단계이다.
    • 인수 단계에서는 사용자의 피드백을 받을 수 있기 때문에 가능한 한 자주 배포하는 것이 권장된다.
  • 예비 운영(pre-production)
    • 실제 운영 배포 직전에 수행하는 마지막 검증 단계다.
    • 이 단계는 선택적으로 진행 가능하며, 프로젝트의 중요도에 따라서 진행하지 않고 다음으로 넘어가도 무방하다.

아래 그램은 각 개발 주기에서 테스트가 어떻게 적용되는지 보여준다.

Dev Cycle Test

  • 개발 단계
    • 비즈니스 로직에 대해 단위 테스트를 실행한다.
      • 코드에서 변경한 사항이 다른 소스 코드를 망치는 일이 없는지 확인한다.
    • 테스트 코드를 Git에 commit하기 전에 자동화된 빌드에서도 수행할 수 있다.
    • 통합 테스트를 실행할 수도 있다.
      • 이 경우에는 DB나 애플리케이션 서버 등 운영 환경을 모사한 환경이 필요하다.
      • 지양하는 것이 좋을 것 같다.
  • 통합 단계
    • 일반적으로 자동화된 빌드를 수행하여 애플리케이션을 패키징하고 배포한다.
    • 단위 테스트와 기능 테스트를 수행한다.
      • 기능 테스트는 시스템이나 구성 요소가 요구 사항을 잘 지키고 있는지 평가하는 것이다.
      • 기능 테스트는 블랙박스 테스트의 일종으로 시스템이 제공하는 기능을 테스트한다.
      • 일반적으로 이 단계에서는 기능 테스트의 일부만 실행한다.
  • 인수/부하 테스트
    • 통합 단계에서 실행한 것과 동일한 테스트를 수행한다.
    • 소프트웨어의 성능과 견고함을 확인하기 위해서 부하 테스트를 추가적으로 실행하기도 한다.

      모범 사례는 인수 단계에서 실행한 테스트를 예비 운영 단계에서도 실행하는 것으로, 모든 것이 올바르게 설정되었는지를 확인할 수 있으며, 완전성을 검증한다고 볼 수 있다.

테스트는 많이하고 디버깅은 줄이도록 하자.

JUnit 모범 사례: 지속적인 회귀 테스트

개발 주기에서 JUnit이 가지고 있는 강력한 장점은 테스트를 쉽게 자동화할 수 있다는 것이다.

메서드가 변경되면 변경된 메서드를 바로 테스트할 수 있다.
한 테스트가 통과되면 다른 테스트도 실행할 수 있다.
실패한 테스트가 있다면 모든 테스트가 통과될 때까지 소스 코드르 다시 수정하거나 테스트를 수정할 수 있다.

이렇게 회귀 테스트는 추가된 변경 사항 때문에 기존 기능이 망가지는 것을 방지한다.
모든 종류의 테스트가 회귀 테스트로 사용될 수 있다.
그러므로 소스 코드를 변경한 다음에는 단위 테스트를 실행하는 것이 가장 먼저 해야 할 일이다.

회귀 테스트를 수행하는 가장 좋은 방법은 테스트 묶음의 실행을 자동화하는 것이다.


정리

  • 테스트 대상 메서드에 분기문이 있을 경우 분기마다 하나씩 단위 테스트를 작성해야 한다.
  • 테스트하기 쉬운 코드를 작성하려면 의존성을 최대한 줄여야 한다.
    • 따라서 의존성 주입을 통해 메서드를 분리하자.
  • 디미터 법칙을 고려해서 개발해 보자.
  • 전역 상태는 최대한 피하도록 다형성(polymorphism)을 활용해서 코드를 재사용해 보자.
  • TDD와 BDD의 개념을 다시 잡아보자.
  • 돌연변이 테스트로 테스트의 신뢰성, 테스트 데이터의 약점, 코드의 약점을 찾아서 테스트의 품질을 높여보자.
This post is licensed under CC BY 4.0 by the author.

© Yn3. Some rights reserved.