Code/Test

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

noahkim_ 2025. 1. 7. 11:59

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


1. AAA 패턴

  • 테스트 코드를 세 단계로 구분하여 작성하는 패턴
  • 스위트 내 모든 테스트가 단순하고 균일한 구조를 갖게함
단계 설명
Arrange
테스트 대상(SUT) 및 의존성 설정
Act
테스트 대상 메서드 실행
Assert
결과 확인 (반환 값, 상태 변화, 호출 여부 등)

 

2. 권장 사항

항목 권장 사항 이유 및 설명
구절 수 제한 한 테스트에는 한 act만 단일 동작 검증
조건문 회피 분기문 피하기 (if, switch 등)
복잡도 증가 (한 테스트에 여러 경우를 넣지 말고 분리해야 함)
메서드 캡슐화 복잡한 검증 로직 분리 (private 메서드로)
반복 제거
가독성 향상
목적 명확화
불변 위반 방지 여러 호출로 결과 만들지 말 것
불변성 깨질 수 있음 (한 동작 = 한 결과로 캡슐화하기)
적정 검증 수 assert는 많아도 됨  
동등 멤버 정의 복잡한 검증은 equals() 또는 Comparable 정의
객체 상태 비교를 쉽게 할 수 있음
종료 구문 tearDown() 거의 불필요
자원 정리 불필요 (통합 테스트일 때 해당됨)
SUT 구분 명확히 테스트 대상(SUT)과 의존성 구분
어떤 객체가 테스트 대상인지 직관적으로 파악 가능해야 함

 

예제) 구절 수 제한

더보기

@Test
void testMultipleActionsInOneTest() {
    OrderService service = new OrderService();
    
    // Act & Assert 1
    String result1 = service.order("item1");
    assertEquals("Order item1", result1);

    // Act & Assert 2
    String result2 = service.order("item2");
    assertEquals("Order item2", result2);
}

 

@Test
void testOrderItem1() {
    OrderService service = new OrderService();
    String result = service.order("item1");
    assertEquals("Order item1", result);
}

@Test
void testOrderItem2() {
    OrderService service = new OrderService();
    String result = service.order("item2");
    assertEquals("Order item2", result);
}

 

예제) 조건문 피하기

더보기

@Test
void testOrderConditional() {
    OrderService service = new OrderService();
    String item = "item1";
    
    if (item.equals("item1")) {
        assertEquals("Order item1", service.order(item));
    } else {
        assertEquals("Order item2", service.order(item));
    }
}

 

@Test
void testOrderItem1() {
    OrderService service = new OrderService();
    assertEquals("Order item1", service.order("item1"));
}

예제) 메서드 비공개 캡슐화

더보기

@Test
void testOrder() {
    OrderService service = new OrderService();
    String result = service.order("item1");
    assertOrderResult(result, "Order item1");
}

private void assertOrderResult(String actual, String expected) {
    assertEquals(expected, actual);
}

 

예제) 불변 위반 방지

더보기

@Test
void testPriceCalculation() {
    OrderService service = new OrderService();
    service.addItem("item1", 10);
    service.addItem("item2", 20);
    service.addItem("item3", 30);

    int total = service.calculateTotal();
    assertEquals(60, total);
}

 

@Test
void testCalculateTotalWithSingleCall() {
    OrderService service = new OrderService();
    service.addItems(List.of("item1", "item2", "item3"), List.of(10, 20, 30));
    int total = service.calculateTotal();
    assertEquals(60, total);
}

 

예제) 적정 검증 수

더보기

@Test
void testOrderContainsInfo() {
    OrderService service = new OrderService();
    String result = service.order("item1");

    assertTrue(result.contains("Order"));
    assertTrue(result.contains("item1"));
}
  • 하나의 동작을 다양한 측면에서 검증할 때는 괜찮음.

 

예제) 동등 멤버 정의

더보기

@Test
void testUserEquality() {
    User expected = new User("noah", 30);
    User actual = userRepository.findByName("noah");

    assertEquals(expected, actual); // equals() 오버라이드 되어 있어야 함
}

 

예제) 종료 구문

더보기

@Test
void testSimple() {
    OrderService service = new OrderService();
    assertEquals("Order item1", service.order("item1"));
}
  • 단위 테스트에선 생략

 

예제) SUT 구분 명확히

더보기

@Test
void testSomething() {
    new OrderService(new FakeRepo()).order("item1"); // 뭐가 SUT인지 불명확
}

 

@Test
void testOrder() {
    OrderRepository fakeRepo = new FakeOrderRepository();
    OrderService sut = new OrderService(fakeRepo);

    String result = sut.order("item1");
    assertEquals("Order item1", result);
}

 

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

  • 테스트 간 공통 객체나 셋업을 재사용해서 중복을 줄이는 방식 (의존성, 셋팅 등)

 

장점

항목 설명
코드량 감소
여러 테스트에서 공통 객체(픽스처)를 재사용하여 테스트 코드 양을 줄일 수 있음.
중복 제거
유사한 객체를 반복적으로 생성하지 않아도 되어 깔끔한 코드 작성 가능.

 

예제) 장점

더보기

class OrderServiceTest {

    @Test
    void testOrderTotalPrice() {
        OrderItem item1 = new OrderItem("Book", 2, 10000);
        OrderItem item2 = new OrderItem("Pen", 3, 2000);
        Order order = new Order(List.of(item1, item2));

        int total = order.calculateTotalPrice();

        assertEquals(10000 * 2 + 2000 * 3, total);
    }

    @Test
    void testOrderContainsItem() {
        OrderItem item1 = new OrderItem("Book", 2, 10000);
        OrderItem item2 = new OrderItem("Pen", 3, 2000);
        Order order = new Order(List.of(item1, item2));

        assertTrue(order.containsItem("Pen"));
    }
}

 

class OrderServiceTest {

    Order order;

    @BeforeEach
    void setUp() {
        OrderItem item1 = new OrderItem("Book", 2, 10000);
        OrderItem item2 = new OrderItem("Pen", 3, 2000);
        order = new Order(List.of(item1, item2));
    }

    @Test
    void testOrderTotalPrice() {
        int total = order.calculateTotalPrice();
        assertEquals(10000 * 2 + 2000 * 3, total);
    }

    @Test
    void testOrderContainsItem() {
        assertTrue(order.containsItem("Pen"));
    }
}

 

단점

항목 설명
높은 결합도 픽스처를 공유할 경우, 테스트 간 의존성 발생 (다른 테스트에 영향을 줄 수 있음)
공유 상태 위험
상태 변경 시, 다른 테스트에서 예기치 않게 변경된 상태를 사용할 수 있음
디버깅 어려움 테스트 실패 원인을 찾기 어려워짐 (원인이 자신이 아닌 다른 테스트에 있음)
가독성 저하
테스트가 무엇을 검증하는지 바로 파악하기 어려움
테스트 코드 이해 어려움
테스트 목적과 픽스처 간 연결이 명확하지 않음

 

예제) 단점

더보기
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. 단위 테스트 명명법

  • 도메인 언어 기반
  • 엄격한 규칙보다는 가독성과 명확성이 중요 (메서드명_상황_기대결과)
  • “이런 조건에서 이렇게 동작해야 한다”는 시나리오 기반이 바람직함 (Given/When/Then 형식)

 

예시) 도메인 언어 기반

더보기
@Test
void 직원이_근무시간을_초과하면_초과수당이_계산된다() {
    Payroll payroll = new Payroll();
    int result = payroll.calculateOvertime(50); // 기준: 40시간
    assertEquals(10 * OVERTIME_RATE, result);
}

 

예시) 시나리오 기반

더보기
@Test
void add_WhenTwoPositiveNumbers_ReturnsTheirSum() {
    assertEquals(5, calculator.add(2, 3));
}

@Test
void login_WithCorrectCredentials_ReturnsTrue() {
    assertTrue(authService.login("user", "pass"));
}

@Test
void login_WithInvalidPassword_ReturnsFalse() {
    assertFalse(authService.login("user", "wrongpass"));
}

 

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));
    }
}