Code

[단위 테스트] 3. 단위 테스트 구조

noahkim_ 2025. 1. 7. 11:59

블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.


1. AAA 패턴

  • 테스트를 준비, 실행, 검증 세 부분으로 나누어 구성하는 방법

 

구조

  • 준비: SUT와 의존성 셋팅
  • 실행: SUT에서 메서드 호출 및 의존성 전달
  • 검증: 결과 검증. (반환 값이나 SUT나 협력자의 상태, SUT가 호출한 메서드 등)

 

장점

  • 일관성: 스위트 내 모든 테스트가 단순하고 균일한 구조를 갖는 데 도움이 됨

 

2. 권장 사항

여러 개의 준비, 실행, 검증 구절 피하기
  • 여러 개의 실행 구절은 여러 개의 동작 단위를 검증하는 테스트임을 의미함
  • 이러한 구조는 단위 테스트가 아닌 통합 테스트임을 의미함

 

테스트 내 if 문 피하기
  • 한번에 너무 많은 것을 검증한다는 표시
    • 분기가 있어서 얻는 이점이 없음
    • 테스트를 읽고 이해하는 것을 더 어렵게 함
  • 반드시 여러 테스트로 나눠야 함

 

구절이 길어지면, 별도의 비공개 메서드로 캡슐화하기
  • 불변 위반 주의하기
    • 한 결과를 위해 여러 메서드의 호출이 필요하다면 모순이 발생할 수 있음
    • 한 메서드의 호출로 결과가 도출되도록 캡슐화 필요

 

검증 구절에는 검증문이 얼마나 있어야 하는가
  • 검증 단위는 코드가 아닌 동작의 단위
  • 그러나 검증문이 너무 커진다면 좋지 않음
  • 동등 멤버를 정의하여 처리하는 방식이 권장됨

 

종료 단계는 어떤가
  • 테스트에 사용된 자원을 정리하는 단계
  • 단위 테스트에선 사용할 일 없음 (통합 테스트의 단계)

 

테스트 대상 시스템 구별하기
  • SUT는 애플리케이션에서 호출하고자 하는 동작에 대한 진입점을 제공함
  • SUT와 의존성을 구분하여 직관적으로 테스트문을 읽을 수 있도록 하기

 

3. 테스트 간 픽스처 재사용

  • 테스트 실행 대상 객체 관련해서, 테스트 간 코드를 재사용 시 사용되는 부분 (의존성, 셋팅 등)
  • 별도의 메서드나 클래스로 도출

 

장점

  • 테스트 코드 양을 크게 줄일 수 있음

 

단점

class OrderServiceTest {
    private static DatabaseConnection sharedConnection; // 공유 상태 (안티패턴)
    private OrderService orderService;

    @BeforeAll
    static void setupDatabase() {
        // 테스트 간 공유되는 DB 연결 (안티패턴)
        sharedConnection = new DatabaseConnection("test-db-url");
    }

    @BeforeEach
    void setup() {
        // 생성자를 통한 의존성 설정 (안티패턴)
        orderService = new OrderService(new OrderRepository(sharedConnection));
    }

    @Test
    void testPlaceOrder() {
        Order order = orderService.placeOrder("item1", 2);
        assertNotNull(order.getId());
    }

    @Test
    void testCancelOrder() {
        Order order = orderService.placeOrder("item1", 2);
        boolean success = orderService.cancelOrder(order.getId());
        assertTrue(success);
    }
}
테스트 간의 높은 결합도는 안티 패턴이다
  • 공유 상태를 가질 경우 모든 테스트가 서로 결합됨
  • 테스트를 수정해도 다른 테스트에 영향을 주어선 안됨
  • 즉, 공유 상태를 두어선 안됨

 

테스트 가독성을 떨어뜨리는 생성자 사용
  • 테스트 코드가 무엇을 검증하는지 보려면 다른 부분도 봐야 함

 

해결책

class OrderServiceTest {
    private OrderService orderService;

    @BeforeEach
    void setup() {
        // 비공개 팩토리 메서드를 통해 OrderService 생성
        orderService = createOrderService();
    }

    private OrderService createOrderService() {
        OrderRepository orderRepository = createDependency(OrderRepository.class);
        return new OrderService(orderRepository);
    }

    private <T> T createDependency(Class<T> type) {
        if (type == DatabaseConnection.class) {
            return (T) new InMemoryDatabaseConnection(); // 독립적 테스트 환경
        } else if (type == OrderRepository.class) {
            DatabaseConnection connection = createDependency(DatabaseConnection.class);
            return (T) new OrderRepository(connection);
        }
        throw new IllegalArgumentException("Unsupported dependency type: " + type.getName());
    }

    @Test
    void testPlaceOrder() {
        Order order = orderService.placeOrder("item1", 2);
        assertNotNull(order.getId());
    }

    @Test
    void testCancelOrder() {
        Order order = orderService.placeOrder("item1", 2);
        boolean success = orderService.cancelOrder(order.getId());
        assertTrue(success);
    }
}
비공개 팩토리 메서드 두기
  • 테스트 코드르 짧게 하기
  • 테스트 진행 상황에 대한 전체 맥락을 유지함

 

4. 단위 테스트 명명법

  • 엄격한 명명 X
  • 문제 도메인에 익숙한 비개발자에게 시나리오를 설명하듯

 

5. 매개변수화된 테스트 리팩터링하기

  • 테스트 하나로 동작 단위를 완벽하게 설명하기 힘든 경우가 많음
  • 여러 구성 요소를 포함하며, 각 구성 요소는 자체 테스트로 캡처해야 함
  • 입력과 예상 결과만 다른데도 분리됨

 

매개변수화된 테스트

class NumberUtilsTest {
    @ParameterizedTest
    @CsvSource({
        "2, true",   // 짝수
        "3, false",  // 홀수
        "0, true",   // 특수 케이스 (0은 짝수로 간주)
        "-4, true",  // 음수 짝수
        "-5, false"  // 음수 홀수
    })
    void testIsEven(int number, boolean expected) {
        assertEquals(expected, NumberUtils.isEven(number));
    }
}
  • 유사한 테스트를 묶는 기능
  • 테스트 코드 양이 줄어들음
  • 단, 긍정적이거나 중요한 테스트는 독립적으로 도출해서 명명하는 것이 좋음