spring

토비의 스프링 정복하기 4편 - 테스트 작성하는 법

ri5 2026. 1. 17. 19:32

테스트 코드를 왜 작성하는 것일까?

테스트를 하는 이유는 우리가 작성한 코드에 대해 의도한대로 동작하는지 테스트하기 위함입니다. 테스트 코드를 작성은 여러 팀원들과 함께 일할 때 빛을 발하는데 예를들어 우리가 모든 팀원들이 올린 PR을 모두가 전부 리뷰를 할 수 있을까요? 불가능할 것입니다.

 

그럼 이런 상황에서 어떻게 안정적으로 우리가 만든 어플리케이션이 동작하는지 보장할 수 있을까요? 바로 그것이 테스트 코드입니다. 스프링은 어플리케이션을 개발하는 것 뿐만 아니라 어플리케이션을 테스트하는데도 다양한 기능을 제공해주는데 그 기능들을 알아봅시다.

좋은 단위 테스트는 무엇일까?

단위 테스트에서 가져야할 특징을 이야기하면 회귀 방지, 리팩토링 내성, 빠른 피드백, 유지보수성을 이야기합니다.

 

좀 더 쉽게 예를들면 단위 테스트에서 회귀 방지는 우리가 테스트를 할 때 잘못된 수정(기존의 기능을 깨트리는 수정)을 했을 때 알림으로 알려주는 것을 의미합니다. 아래의 예시를 살펴봅시다.

 

시나리오: 할인 계산 로직

@Service
public class DiscountService {

    public BigDecimal calculateDiscount(BigDecimal price, int quantity) {
        // 10개 이상 구매 시 10% 할인
        if (quantity >= 10) {
            return price.multiply(BigDecimal.valueOf(0.10));
        }
        return BigDecimal.ZERO;
    }
}

단위 테스트

@ExtendWith(MockitoExtension.class)
class DiscountServiceTest {

    DiscountService discountService = new DiscountService();

    @Test
    @DisplayName("10개 이상 구매 시 10% 할인이 적용된다")
    void calculateDiscount_over10_returns10Percent() {
        BigDecimal discount = discountService.calculateDiscount(
            BigDecimal.valueOf(10000), 10
        );
        
        assertThat(discount).isEqualByComparingTo(BigDecimal.valueOf(1000));
    }

    @Test
    @DisplayName("10개 미만 구매 시 할인이 없다")
    void calculateDiscount_under10_returnsZero() {
        BigDecimal discount = discountService.calculateDiscount(
            BigDecimal.valueOf(10000), 9
        );
        
        assertThat(discount).isEqualByComparingTo(BigDecimal.ZERO);
    }
}

회귀 발생 상황

신입 개발자가 "20개 이상만 할인"으로 잘못 수정:

public BigDecimal calculateDiscount(BigDecimal price, int quantity) {
    // ❌ 잘못된 수정: 10 → 20
    if (quantity >= 20) {
        return price.multiply(BigDecimal.valueOf(0.10));
    }
    return BigDecimal.ZERO;
}
```

## 테스트 실행 결과
```
❌ calculateDiscount_over10_returns10Percent

org.opentest4j.AssertionFailedError: 
expected: 1000
 but was: 0

 

그 다음에 리팩토링 내성은 코드를 말끔하게 정리만하였을 때 빨간 불을 키는 테스트 코드인가에 대해 묻는 이야기 입니다. 결과값이 아니라 내부 구현에 의존하여 테스트의 결과를 체크하는 경우를 의미합니다.

시나리오: 주문 금액 계산

@Service
public class OrderCalculator {

    public BigDecimal calculateTotal(List<OrderItem> items) {
        BigDecimal total = BigDecimal.ZERO;
        for (OrderItem item : items) {
            total = total.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())));
        }
        return total;
    }
}

 

내부 구현에 의존하는 테스트:

@Test
void calculateTotal_checksInternalLoop() {
    List<OrderItem> items = List.of(
        new OrderItem("A", 2, BigDecimal.valueOf(1000)),
        new OrderItem("B", 1, BigDecimal.valueOf(2000))
    );
    
    // ❌ 내부에서 for문을 사용하는지 검증 (구현 의존)
    // ❌ 중간 계산 과정을 검증
    OrderCalculator spyCalculator = spy(new OrderCalculator());
    spyCalculator.calculateTotal(items);
    
    // 내부 메서드 호출 횟수 검증
    verify(spyCalculator, times(2)).addItemPrice(any(), any());
}

 

좋은 테스트 코드는 최종 결과만 검증:

@Test
@DisplayName("주문 항목들의 총 금액을 계산한다")
void calculateTotal_returnsCorrectSum() {
    OrderCalculator calculator = new OrderCalculator();
    List<OrderItem> items = List.of(
        new OrderItem("A", 2, BigDecimal.valueOf(1000)),  // 2000
        new OrderItem("B", 1, BigDecimal.valueOf(2000))   // 2000
    );

    BigDecimal total = calculator.calculateTotal(items);

    assertThat(total).isEqualByComparingTo(BigDecimal.valueOf(4000));
}

 

그 다음은 단위 테스트는 빠른 피드백을 주는 테스트여야 합니다. 예를들어 단위 테스트에 레디스나 디비의 의존성을 가지고 있어서 전체 단위 테스트를 한번 돌리는데 5분이 넘는 시간이 걸린다면 어떻게 될까요?

 

그럼 배포시간이 5분이 넘게 딜레이 될 것이고 배포를 많이 하면 할수록 그 시간은 기하급수적으로 늘어나면서 점점 유지보수하기 어려운 구조로 변화하게 될 것입니다. 디비나 외부 네트워크에 의존하는 코드는 mock을 통해 구현하여 내부 비즈니스 로직의 동작만을 보장하는 것을 추천드립니다.

 

@Test
void test1() {
    // 복잡한 셋업 - 무엇을 테스트하는지 알기 어려움
    Member m = new Member();
    m.setId(1L);
    m.setName("홍길동");
    m.setEmail("hong@test.com");
    m.setPhone("010-1234-5678");
    m.setAddress("서울시 강남구");
    m.setGrade(MemberGrade.GOLD);
    m.setCreatedAt(LocalDateTime.now());
    m.setUpdatedAt(LocalDateTime.now());
    m.setLastLoginAt(LocalDateTime.now());
    m.setStatus(MemberStatus.ACTIVE);
    
    PointService ps = new PointService();
    
    // 매직 넘버 - 왜 3000인지 알 수 없음
    int r = ps.calculatePoints(m, 100000);
    assertEquals(3000, r);
}

@Test
void test2() {
    // 위와 동일한 복잡한 셋업 반복...
    Member m = new Member();
    m.setId(2L);
    m.setName("김철수");
    // ... 생략
}

 

그다음은 유지보수하기 어려운 코드입니다. 이해하기 어려운 테스트 코드는 유지보수성을 낮추고 다른 개발자와 나의 개발 리소스를 많이 들이는 테스트 코드는 그 테스트가 깨지더라도 이해하는 것을 포기하게 만듭니다.

class PointServiceTest {

    PointService pointService = new PointService();

    @Test
    @DisplayName("GOLD 등급은 구매금액의 3%가 적립된다")
    void goldMember_gets3PercentPoints() {
        // given - 테스트에 필요한 것만 셋업
        Member goldMember = createMember(MemberGrade.GOLD);
        int purchaseAmount = 100_000;

        // when
        int points = pointService.calculatePoints(goldMember, purchaseAmount);

        // then - 의도가 명확한 검증
        assertThat(points).isEqualTo(3_000); // 100,000 * 3%
    }

    @Test
    @DisplayName("SILVER 등급은 구매금액의 2%가 적립된다")
    void silverMember_gets2PercentPoints() {
        Member silverMember = createMember(MemberGrade.SILVER);
        
        int points = pointService.calculatePoints(silverMember, 100_000);

        assertThat(points).isEqualTo(2_000); // 100,000 * 2%
    }

    @Test
    @DisplayName("BRONZE 등급은 구매금액의 1%가 적립된다")
    void bronzeMember_gets1PercentPoints() {
        Member bronzeMember = createMember(MemberGrade.BRONZE);
        
        int points = pointService.calculatePoints(bronzeMember, 100_000);

        assertThat(points).isEqualTo(1_000); // 100,000 * 1%
    }

    // 테스트에 필요한 최소한의 객체만 생성
    private Member createMember(MemberGrade grade) {
        return Member.builder()
            .grade(grade)
            .build();
    }
}

 

좋은 단위 테스트 코드는 그 테스트 코드가 어떤 것을 테스트 하는지 명확하게 이해하기 쉽게 만들고 수정하기 쉽게 만들면서 유지보수성을 높여줍니다. 그외에 스프링에서 테스트 코드를 작성하는데 도움을 주는 여러 기능들을 제공하고 있습니다.

 

공통 작업을 위한 어노테이션

@BeforeAll 클래스당 1회 무거운 리소스 초기화
@BeforeEach 매 테스트 전 테스트 데이터 초기화
@AfterEach 매 테스트 후 테스트 데이터 정리
@AfterAll 클래스당 1회 리소스 해제

 

테스트 기능 지원을 위한 어노테이션과 모듈

@DisplayName 테스트 의도를 한글로 명확히 표현
@Nested 관련 테스트를 계층적으로 그룹화
@ParameterizedTest 여러 케이스를 하나의 테스트로 통합
AssertJ 자연어 스타일의 가독성 높은 검증
픽스처 메서드 테스트 객체 생성 코드 재사용

 

TDD와 AI

 

AI를 통해 바이브 코딩을 하면서 어떻게 그 결과를 보장할 수 있을까? 테스트주도개발 책 저자인 켄트 백은 이와 관련하여 다시 TDD의 중요성에 대해 이야기를 했습니다. TDD는 어플리케이션을 개발하기 전 테스트를 먼저 작성하고 테스트를 통과하도록 로직을 작성하는 것을 의미합니다. 

 

TDD는 작성된 코드에 대한 확신과 빠른 피드백을 주면서 안정적으로 어플리케이션을 구현할 수 있는 장점을 가지고 있지만 현실적으로 더 많은 시간과 개발 비용을 드는 단점이 있어 많은 개발팀에서 이 전략을 채택하지는 않았지만 AI로 인해 기하급수적으로 빨라지는 기능 개발 속도로 인해 AI로 개발하는 것의 안전 장치로써 다시한번 주목을 받고 있습니다.

 

현대적인 스프링 테스트 적용

테스트 코드를 깔끔하게 정리했다면, 이제 스프링 부트가 제공하는 강력한 테스트 인프라를 활용할 차례입니다. 과거에는 XML 설정을 수동으로 로드하고 컨텍스트 생성을 걱정했지만, 현대의 스프링은 이를 훨씬 더 영리하게 처리하도록 변화하였습니다.

테스트 컨텍스트 프레임워크와 캐싱

현대 스프링 테스트의 핵심은 컨텍스트 캐싱(Context Caching)입니다.

  • 문제 해결: 과거에는 @Before에서 컨텍스트를 매번 생성하여 각 테스트를 독립적으로 분리하였지만, 스프링 부트 테스트는 설정(Configuration)이 같다면 여러 테스트 클래스 사이에서도 애플리케이션 컨텍스트를 공유합니다.
  • JUnit 5 적용: 이제 @ExtendWith(SpringExtension.class)는 @SpringBootTest 안에 포함되어 있어 생략 가능합니다.
@SpringBootTest // 모든 빈을 로드하는 통합 테스트
@ActiveProfiles("test") // 테스트 전용 설정(application-test.yml) 적용
class UserDaoTest {
    @Autowired private UserDao userDao; // getBean() 없이 직접 주입
    @Autowired private DataSource dataSource;

    @Test
    void addAndGet() {
        // ... 테스트 로직
    }

테스트 슬라이스(Test Slicing) - "필요한 것만 띄우기"

과거에는 전체 XML을 다 읽어야 했지만, 현대 스프링은 레이어별 테스트를 지원하여 속도를 획기적으로 높입니다.

  • @DataJpaTest: JPA/DB 관련 빈들만 로드합니다. UserDao가 리포지토리 형태라면 이 방식이 가장 빠르고 효율적입니다.
  • @WebMvcTest: 컨트롤러 레이어만 테스트할 때 사용합니다.
  • 장점: 컨텍스트 생성 시간을 줄이고 테스트 목적을 명확히 합니다.

@DataJpaTest 활용 예시

@DataJpaTest
class ProductRepositoryTest {

    @Autowired ProductRepository productRepository;

    @Test
    @DisplayName("가격 범위로 상품을 조회한다")
    void findByPriceBetween() {
        // given
        productRepository.saveAll(List.of(
            new Product("상품A", 10000),
            new Product("상품B", 20000),
            new Product("상품C", 30000)
        ));

        // when
        List<Product> result = productRepository.findByPriceBetween(15000, 25000);

        // then
        assertThat(result).hasSize(1);
        assertThat(result.get(0).getName()).isEqualTo("상품B");
    }
}

 

@WebMvcTest 사용 예시

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired MockMvc mockMvc;
    @MockBean ProductService productService;  // Service는 Mock 처리

    @Test
    @DisplayName("상품 목록 조회 API")
    void getProducts() throws Exception {
        // given
        when(productService.findAll())
            .thenReturn(List.of(new Product("맥북", 2000000)));

        // when & then
        mockMvc.perform(get("/api/products"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$[0].name").value("맥북"));
    }
}

DI와 Mocking (가짜 객체 활용)

과거에는 운영용 DB를 건드리지 않기위해 수동으로 DataSource를 갈아 끼우던 방식은 이제 Mockito나 테스트 컨테이너가 대신합니다.

  • @MockBean: 컨텍스트 안의 특정 빈을 가짜(Mock) 객체로 교체합니다. 실제 DB 연결 없이 비즈니스 로직만 테스트하고 싶을 때 유용합니다.
  • @DirtiesContext 사용 지양: 이 애노테이션은 캐싱된 컨텍스트를 파괴하여 전체 테스트 속도를 매우 느리게 만듭니다. 현대적인 테스트에서는 Mocking이나 Testcontainers를 통해 컨텍스트 상태를 변경하지 않고 테스트를 격리합니다.
@SpringBootTest
class OrderServiceTest {

    @Autowired OrderService orderService;
    
    @MockBean PaymentGateway paymentGateway;  // 외부 결제 API를 Mock

    @Test
    @DisplayName("결제 성공 시 주문이 완료된다")
    void placeOrder_paymentSuccess() {
        // given - 실제 결제 없이 성공 응답 반환
        when(paymentGateway.charge(anyLong(), any()))
            .thenReturn(PaymentResult.success("tx-123"));

        // when
        Order order = orderService.placeOrder(1L, createOrderItems());

        // then
        assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
    }
}

DB 테스트의 현대적 대안: Testcontainers

과거에는 테스트용 DB를 따로 구축하거나 H2 같은 인메모리 DB를 썼습니다. 하지만 인메모리 DB는 실제 운영 DB(MySQL, PostgreSQL 등)와 문법이 달라 오류가 날 수 있습니다.

  • 현대적 해결책: Testcontainers를 사용하면 도커(Docker)를 이용해 실제 운영 환경과 동일한 DB를 테스트 실행 시점에만 잠깐 띄워 사용할 수 있습니다.
@SpringBootTest
@Testcontainers
class ProductServiceIntegrationTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }

    @Autowired ProductRepository productRepository;

    @Test
    @DisplayName("실제 MySQL에서 쿼리가 정상 동작한다")
    void findProducts_withRealMySQL() {
        // 실제 MySQL 컨테이너에서 테스트 실행
        productRepository.save(new Product("맥북", 2000000));
        
        List<Product> result = productRepository.findByPriceBetween(1000000, 3000000);
        
        assertThat(result).hasSize(1);
    }
}

 

테스트를 익히는 좋은 방법

1. 학습 테스트 (Learning Test): 기술을 정복하는 테스트

학습 테스트는 내가 만든 코드를 테스트하는 것이 아니라, 사용하려는 라이브러리나 프레임워크, 또는 언어의 기능을 익히기 위해 작성하는 테스트입니다. 

@Test
void learningStringSubstring() {
    String val = "ABCDE";
    // 내가 이해하기로 substring(0, 2)는 "AB"가 나와야 해!
    assertThat(val.substring(0, 2)).isEqualTo("AB");
}

2. 버그 테스트 (Bug Test): 버그를 박멸하는 테스트

버그 테스트는 발견된 버그를 재현하기 위해 작성하는 테스트입니다. 실패하는 테스트를 먼저 만들고, 이 테스트가 성공하도록 코드를 수정하는 과정입니다.

버그 테스트의 3단계 (Red-Green)

  1. 실패하는 테스트 작성: 버그가 발생하는 상황을 그대로 테스트 코드로 구현합니다. (Red)
  2. 코드 수정: 테스트가 통과하도록 최소한의 코드를 수정합니다.
  3. 성공 확인: 테스트가 통과(Green)하면 버그가 해결된 것입니다.