본문 바로가기
개발/기타

더 나은 테스트를 위한 지침

by 윤호 2024. 8. 17.

테스트에 정답은 없다. 하지만 좋지 않은 테스트는 있다. 

좋지 않은 테스트를 피하고 테스트를 더 잘 작성하기 위한 방법을 알아보자.

 

1. 한 문단에 한 주제

좋은 글은 한 문단에 하나의 주제만 있다. 테스트코드도 마찬가지다.

테스트 코드도 하나의 글이라고 생각하자. 나와 내 동료가 참고할 수 있는 문서다. 실제로 잘 작성된 테스트는 개발 문서의 역할도 한다.

 

하나의 테스트 안에 반복문이나 조건문 같은 논리 구조를 넣는 것도 주제를 해치는 일이다.

차라리 테스트를 분리한다.

 

테스트 실행 구절 예시

- 상품 리스트를 조회하면 같은 가격은 한장만, 가격이 높은 순서대로 노출한다. (x)
- 상품 리스트를 조회하면 같은 가격 한장만 노출한다. (o)
- 상품 리스트를 조회하면 가격이 높은 순서대로 노출한다. (o)

 

2. 완벽하게 제어하기

테스트하고자 하는 메소드에 제어할 수 없는 값이나 기능이 있어선 안 된다. 예를 들어, 현재 시각이나 외부 서비스 호출 기능을 사용하는 메소드를 그대로 테스트해선 안된다. 테스트 코드를 실행하는 시점에 따라 결과가 바뀌기 때문이다. 이러한 값이나 기능은 상위 계층으로 분리하여 테스트코드를 작성한다.

 

다음은 현재 시각을 확인하고 영업중인지 확인하는 함수다.

LocalDate.now()가 로직에 포함되어 테스트를 호출하는 시점마다 결과가 바뀐다.

    public boolean isWithinBusinessHours() {
        LocalTime start = LocalTime.of(9, 0);  // 오전 9시
        LocalTime end = LocalTime.of(18, 0);   // 오후 6시
        LocalTime now = LocalTime.now();       // 현재 시간

        return !now.isBefore(start) && !now.isAfter(end);
    }

 

다음과 같이 LocalDateTime.now()를 상위 계층으로 분리할 수 있다. 이렇게 분리하면 상위 계층인 테스트 코드에서 현재 시각 now를 정의하고 테스트할 수 있다.

    public boolean isWithinBusinessHours(LocalTime now) {
        LocalTime start = LocalTime.of(9, 0);  // 오전 9시
        LocalTime end = LocalTime.of(18, 0);   // 오후 6시

        return !now.isBefore(start) && !now.isAfter(end);
    }

 

3. 테스트와 테스트 환경의 독립성을 보장하자

테스트 간 독립성 보장

테스트 간 독립성을 보장해야한다. 그렇지 않으면 테스트 순서에 따라 결과가 달라 질 수 있다.

전역 변수를 의존하거나 테스트 후 상태가 바뀌는 경우가 독립성을 보장하지 못한 경우이다.

 

다음을 테스트를 확인해보자 테스트01->테스트02 순서로 실행할 경우 테스트02가 실패한다. 반대로 실행한다면 모두 성공한다.

    def "테스트01: 재고를 추가하면 재고 수가 증가해야 한다"() {
        when: "재고를 10만큼 추가한다"
        inventoryService.addStock(10)

        then: "재고 수가 10이어야 한다"
        inventoryService.getStockCount() == 10
    }

    def "테스트02: 재고 수를 확인하면 초기 재고가 0이어야 한다"() {
        when: "재고 수를 확인한다"
        def count = inventoryService.getStockCount()

        then: "재고 수가 0이어야 한다"
        count == 0 // 테스트 실패!
    }

테스트01을 실행하면서 재고 수량이 바껴서 테스트02가 실패한다.

 

독립성을 보장하기 위해 다음 방법을 고려할 수 있다.

1. 각 테스트를 마칠 때 마다 재고를 테스트 이전 상태로 되돌린다. (Test Fixture 클렌징)

2. 재고의 절대 값이 아닌 변화량을 테스트한다.

 

테스트 환경의 독립성 보장

다른 메소드에 대해 독립적으로 테스트 환경을 구성한다. given 절에서 데이터를 셋팅할 땐, 생성자 사용을 지향한다.

 

다음은 given 절에서 생성자를 사용하지 않고 프로덕션 코드의 메소드를 이용 하여 재고를 0으로 만든 테스트코드다. (억지스로운 예제 죄송합니다)

    // given
    stock.생성(3)
    stock.재고차감(4)

    // when, then
	assertThrows(Exception.class, () -> {
            stock.주문();
    });

처음 테스트를 작성할 당시는 문제 없이 통과할 수도 있다. 하지만 추후 재고차감 기능에서 기존 재고보다 더 많은 값을 차감할 경우 exception을 발생 시키도록 수정할 수 있다. 이 경우 의도하지 않게 테스트를 망가뜨리게 된다.

 

4. 한 눈에 들어오는 Test Fixture 구성하기

Test Fixture란? 테스트를 위해 원하는 상태로 고정시킨 일련의 객체

공통의 픽스처는 지양한다

테스트 코드에서 픽스처들이 겹치는 경우가 많다. 개발자라면 중복을 제거하고자 하는 욕망이 생긴다. 이를 못참고 중복을 제거한다면 결합도가 발생하게된다. 이렇게 만들어진 테스트코드를 만나면 테스트 코드를 구성하는 요소(Test Fixture)들을 찾기 위해 이리저리 찾아다녀야한다. 즉, 테스트코드가 문서로서의 역할을 잃게 된다.

 

하나의 테스트에서 동작을 한 번에 이해할 수 있도록 각 테스트 마다 독립적으로 given 절을 만드는 방법이 좋다.

 

같은 맥락에서 data.sql을 통한 given 구성 또한 좋지 않다. 테스트 코드의 파편화를 일으키고 관리포인트를 추가하는 행위다.

Parameterized Test

Paramaeterized Test는 다량의 픽스처를 더 쉽게 만들 수 있도록 도와준다.

 

결합도를 낮추기 위해 픽스처를 각 테스트마다 given절에서 새로 구성하면 코드량이 많아진다. 혹은 그냥 픽스처가 많이 필요할 수도 있다. 이때, Parameterized Test를 사용해볼 수 있다.

 

@Test 대신 @ParameterizedTest로 이를 사용할 수 있다.

어떤 방식으로 파라미터를 넣을지 결정할 수 있다. 다음은 메소드 방식으로 파라미터를 받은 코드이다.

private static Stream<Arguments> provideProductTypesForCheckingStockType() {
	return Stream.of(
		Arguments.of(ProductType.HANDMADE, false),
		Arguments.of(ProductType.BOTTLE, true)
	);
}

@MethodSource("provideProductTypesForCheckingStockType")
@ParameterizedTest
void containsStockType(Product Type productType, boolean expected) {
	// when
	boolean result = ProductType.containsStockType(productType);

	// then
	assertThat(result).isEqualTo(expected);
}

이 때, 파라미터 관련 메소드나 설정들도 테스트코드에서 멀리 떨어지지 않은 곳에 작성하는 것이 문서로써의 테스트에 적합하다.

메소드 방식 말고도 ENUM, NULL, CSV 등 다양한 방식을 제공한다. Junit 공식문서를 참조하자

5. 테스트 수행 환경 통합

테스트는 주기적을 실행할 수 있어야한다. 따라서 테스트의 실행 시간이 길어지면 개발 비용이 높아지진다.

테스트 실행 시간에 가장 큰 영향을 미친는 건 서버가 새로 띄워지는 시간이다. 서버가 한 번 띄워지는 시간 자체는 프로덕션 코드에서 결정되므로 테스트 시에는 서버의 실행 횟수로 비용을 줄일 수 있다.

 

즉, 전체 테스트의 실행 시간은 서버가 새로 띄워지는 횟수에 비례한다.

 

전체 테스트를 수행하며 서버가 띄워지는 횟수를 인지하고 최소화하자. 다음과 같은 방법을 고려할 수 있다.

1. 가능하다면 @SpringBootTest를 사용하는 대신 @DataJpaTest 등을 이용

2. @SpringBootTest를 직접 이용하는 대신 이를 이용하는 상위 클래스를 만들고 이를 상속

    - 상속을 해도 MockBean을 사용한다면 서버가 재실행된다. 가능하면 MockBean도 옮길 상위 계층으로 옮긴다.

 

테스트 성격에 따라 서버를 새로 띄워야할 수 있다. controller테스트는 api를 테스트하므로 service, repository와 성격이 아예 다르다.

완전 한 번만 실행될 수 없다는 것은 인지하고 재실행 횟수의 최소화를 위해 노력하자.

그외

pirvate 메소드는 테스트하지 않는다

테스트는 기능을 사용하는 client 입장에서 이 기능이 잘 동작하는 지 확인하는 것이다. private 메소드를 테스트하는 것은 내부 로직까지 어떻게 해돌아가는지 테스트하는 것이다. 거기까지 관여할 필요가 없다. 그리고 public 메소드를 적절히 테스트하면 거기서 사용한 private 메소드도 검증이 된 것이다.

 

private 메소드가 복잡해져서 테스트가 필요하다는 생각이 든다면, 이를 public 메소드로 분리해야할 시점인지 생각해보자.

테스트에서만 필요한 메소드는 보수적으로 접근한다

프로덕션 코드에선 사용되지 않지만, 테스트에서 DTO의 builder가 필요한 경우가 있다. 이렇게 테스트에서만 필요한 메소드는 만드는 것을 허용하되, 보수적으로 접근하자.

 

미래에 쓰일 수 있는 메소드인지 생각해보던지, 팀내에서 허용 가능한 케이스에 대한 정의하자.

그렇게 하지 않으면, 테스트에 사용되는 메소드가 무분별하게 만들어져 프로덕션 코드의 유지보수에 어려움을 더한다.

 


출처

 

댓글