Code/Test

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

noahkim_ 2025. 1. 7. 11:59

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


1. AAA 패턴

  • 테스트를 준비, 실행, 검증 세 부분으로 나누어 구성하는 방법
  • 스위트 내 모든 테스트가 단순하고 균일한 구조를 갖는 데 도움이 됨
단계 설명
Arrange
테스트를 위한 객체(SUT) 및 의존성 설정
Act
테스트 대상 메서드 호출 (동작 실행)
Assert
결과 확인 (반환 값, 상태 변화, 호출 여부 등 검증)

 

2. 권장 사항

항목 권장 사항 이유 및 설명
구절 수 제한 한 테스트 내 여러 Arrange, Act, Assert 구절 피하기 단일 동작 단위만 검증해야 함
여러 실행은 단위 테스트가 아닌 통합 테스트임
조건문 회피 테스트 내 if, switch 등 분기문 피하기
복잡도 증가
테스트 이해도 저하
한 테스트에 여러 경우를 넣지 말고 분리해야 함
메서드 캡슐화 구절이 길어질 경우, private 메서드로 분리
반복 코드 제거
가독성 향상
테스트 목적 파악이 쉬워짐
불변 위반 방지 여러 호출로 한 결과를 만드는 구조 피하기
불변성 깨질 수 있음
한 동작 = 한 결과로 캡슐화 필요
적정 검증 수 assert는 많을 수 있지만, 동작 단위는 하나여야
한 테스트 = 한 동작 단위 원칙 유지
동등 멤버 정의 복잡한 검증은 equals() 또는 Comparable 정의
assertEquals 등에서 객체 상태 비교를 단순화할 수 있음
종료 구문 단위 테스트에서는 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));
    }
}