JAVA 테스트 코드 작성 - 2. 테스트 코드에서 주의할 사항들(TIL)

2024. 4. 10. 19:01테스트코드

 

 해당 게시글은 최범균님의 테스트 주도 개발 시작하기와 박우빈님의 실용적인 테스트 강의 내용 중 테스트 코드 작성 주의사항을 정리한 내용입니다. 되게 테스트 상황에 맞는 예시를 생각해내기 어려워서 강의 내용, 개인 테스트 코드, 블로그에서 코드를 가져왔음을 알립니다.

 

1. 테스트 코드에서 순서는 보장되어 있지 않을 수 있다.

 

 테스트 코드를 짜다보면 성공할 때도 있고 실패할 때도 있는 테스트가 있다. 랜덤 혹은 시간이라는 변수와 관련이 없다면 테스트 코드 혹은 동작하는 코드의 순서 때문에 발생하는 문제일 가능성이 크다.

 

 테스트 코드의 순서 문제로 발생하는 것은 보통 DB와 연관이 있다. 데이터를 제 때 지워주지 않아서 예상했던 조회와 다르다던가하는 문제가 발생한다. 그런 문제가 발생할 때에는 보통 @AfterEach를 사용해 테스트 종료마다 값을 지워주면 된다.

@AfterEach
void tearDown(){
    shopRepository.deleteAllInBatch();
}

 

 

 위는 그 예시이며, deleteAllInBatch() 메서드를 사용한 이유는 N+1 문제와 비슷하다. SimpleJpaRepository 코드에서 deleteAll이 구현된 방식을 보면 각각의 찾은 요소 별로 개별적으로 삭제를 한다. 그래서 날아가는 쿼리를 보면 select 문으로 DB 내용을 조회해 각각 ID에 맞게 delete 쿼리를 날린다. 그러니 IN절로 한 번에 지워버리는 deleteAllInBatch를 사용하자.

@Override
@Transactional
public void deleteAll() {

    for (T element : findAll()) {
       delete(element);
    }
}

 

 

 동작하는 코드의 순서가 보장되지 않은 예시는 검증(validation)이 있다. 아래의 코드를 보면 가격에 따라 설정된 조건이 많다. validation 스펙상 검증 순서가 어떻게 돌아가는지 정해져 있지 않다. 따라서 예측하지 못한 오류가 나올 수 있는데 이 부분은 @GroupSequence를 사용해 순서를 보장해주거나 검증 관련 테스트는 해당 테스트만 작동을 여러번 해보는 것을 추천한다.

@NotNull(message = "최소값 설정은 필수입니다.")
@Range(min = 0, max = 20000, message = "최소값은 0-20000까지 설정이 가능합니다.")
private Integer priceMin;

@NotNull(message = "최대값 설정은 필수입니다.")
@Range(min = 0, max = 20000, message = "최대값은 0-20000까지 설정이 가능합니다.")
private Integer priceMax;

@NotNull(message = "실시간 설정은 필수입니다.")
private Boolean realTime;

@AssertTrue(message = "최소값은 최대값을 넘을 수 없습니다.")
public boolean isValidPrice() {
    if (priceMin == null || priceMax == null)
        return true;

    return priceMin < priceMax;
}

2. 테스트 코드에서 @BeforeEach로 공용 설정을 만들면 안된다.

 

 데이터를 조회해와서 검색 조건이 잘 맞는지 확인하는 테스트를 한 적이 있었다. 이 테스트를 진행하면서 고려해야 하는 부분이 가격, 위치, 영업 시간 등의 조건이 있었다. 만약, 테스트 코드를 작성함에 있어 @BeforeEach로 모든 메서드가 똑같이 자원이 설정되어있다면 엄청나게 많은 문제가 발생할 것이다.

 

 아래는 모든 테스트 메서드가 공용으로 사용할 수 있게 @BeforeEach를 통해 데이터를 저장해놓았다.

@BeforeEach
void setUp() {
    Shop shop1 = createShop("식당1", 0, RESTAURANT, NSC,
            LocalTime.of(4, 30), LocalTime.of(8, 0));
    Shop shop2 = createShop("식당2", 0, RESTAURANT, NSC,
            LocalTime.of(8, 0), LocalTime.of(16, 0));
    Shop shop3 = createShop("식당3", 0, CAFE, HSSC,
            LocalTime.of(16, 0), LocalTime.of(23, 0));
    Shop shop4 = createShop("식당4", 0, PUB, HSSC,
            LocalTime.of(17, 0), LocalTime.of(4, 0));
    shopRepository.saveAll(List.of(shop1, shop2, shop3, shop4));
}

 

 

 이렇게 코드를 짜면 생기는 첫 번째 문제는 가독성의 저하이다. 아래의 코드를 보자. 일단 given에서 데이터를 줄 때 테스트 조건에서 불필요한 조건을 최대한 없애서 보낼 수 있다. 위와 다르게 정말 딱 테스트 과정에서 가격에만 관심이 있는 테스트임을 알 수 있고 불필요한 조건을 뺄 수 있어서 테스트를 알아보기 좋다.

@DisplayName("설정한 최솟값과 최댓값으로 범위 내의 식당을 찾을 수 있다.")
@Test
void findByPrice(){
    // given
    Shop shop1 = createShopWithOutTime("식당1", 1000);
    Shop shop2 = createShopWithOutTime("식당2", 2000);
    Shop shop3 = createShopWithOutTime("식당3", 3000);
    shopRepository.saveAll(List.of(shop1, shop2, shop3));

    ShopSearchCond cond = ShopSearchCond.builder()
            .priceMin(1000)
            .priceMax(2000)
            .build();

    // when
    List<Shop> shops = shopQuerydslRepository.findAllBySearchCond(cond);

    assertThat(shops).hasSize(2)
            .extracting("name", "price")
            .containsExactlyInAnyOrder(
                    tuple("식당1", 1000),
                    tuple("식당2", 2000)
            );
}

 

 

 또 다른 가독성의 저하를 논하기 전에 이전에 @BeforeEach로 무슨 데이터가 있었는지 기억이 나는지를 묻고 싶다. 글을 작성하는 나 또한 기억이 나지 않으며 뭐가 있었는지 다시 스크롤을 올려 확인했다. 이렇게 테스트 메서드 내에 데이터가 없으면 왔다갔다 해야할 부분이 많아진다.

 

 위에서는 @BeforeEach만 논했지만 sql파일을 만드는 건 더 심각하다. 해당 파일을 찾아 SQL을 이해하고 어떤 데이터가 만들어졌는지 확인해야 하기 때문에 더 불편하다. 테스트 코드를 작성할 때에는 읽는 사람의 입장에서 반드시 생각해보자.

 

 공용 설정이 있다는 게 어떻게 보면 관심사를 분리한 것처럼 좋은 게 아닌가 싶을 수도 있다. 하지만, 테스트 코드에서는 해당되지 않는다. 데이터라는 공통 관심사를 한 곳에서 처리하면 특정한 테스트를 할 때에 문제가 생긴다. 특히 아래와 같이 조회해온 객체 개수를 확인하는 테스트 메서드가 있는 경우를 생각해보자.

assertThat(shops).hasSize(2)
        .extracting("name", "price")
        .containsExactlyInAnyOrder(
                tuple("식당1", 1000),
                tuple("식당2", 2000)
        );

 

 

 다른 테스트 메서드를 작성하다가, 테스트를 위해 공통 부분에 데이터를 추가했는데 하필 위의 조건과 관련이 있다면, 위의 테스트에서 hasSize(2)는 에러가 날 것이다. 즉, @BeforeEach를 사용했을 때 두 번째 문제점은 테스트 공용 값 설정이 테스트 안정성을 깬다는 것이다. 그러니 테스트 코드에서는 메서드 별 데이터를 설정하고 꼭 @AfterEach로 지워주는 방식을 택하자.


3. 테스트 코드는 변수나 필드를 사용해 기댓값을 표현하지 말아야 한다.

 

 이 부분을 보며 상황에 따라 잘 선택해야 하지 않나 생각이 들었다. 클린 코드에서는 매직 넘버를 사용하지 말고 의미있는 이름을 사용하라고 하며 코드 가독성을 높이는 방법을 소개한다. 아래는 그 예시이다.

for (int i=0; i<34; i++) {
    s += (t[j]*4)/5;
}

int realDaysPerIdealDay = 4;
int WORK_DAYS_PER_WEEK = 5;
int sum = 0;
for (int j=0; j < NUMBER_OF_TASKS; j++) {
    int realTaskDays = taskEstimate[j] * realDaysPerIdealDay;
    int realTaskWeeks = (realTaskDays / WORK_DAYS_PER_WEEK);
    sum += realTaskWeeks;
}

 

 

 확실히 의미 있는 단어를 사용하니 코드를 읽으면서 무슨 뜻을 하는지 알 수 있었다. 잘 지어진 변수로 코드를 짜면 이는 상황을 잘 설명하는데 도움이 된다. 하지만 테스트 코드에서 검증 부분에 대해서는 오히려 변수를 사용하는 게 알아보기 어렵다. 아래는 그 예시이다.

@Test
void dateFormat() {
    LocalDate date = LocalDate.of(1945, 8, 15);
    String dateStr = formatDate(date);
    assertEquals(date.getYear() + "년 " +
            date.getMonthValue() + "월 " +
            date.getDayOfMonth() + "일", dateStr);
}

@Test
void dateFormat() {
    LocalDate date = LocalDate.of(1945, 8, 15);
    String dateStr = formatDate(date);
    assertEquals("1945년 8월 15일", dateStr);
}

 

 

 아마도 이 부분에 관해서는 사용하며 고민을 해보아야 할 것 같다. 테스트 코드라서 변수나 필드를 사용한 기댓값을 사용하지 말라는 것으로 해석하는 것은 좋지 않아보인다. 왜냐하면, 위의 클린 코드의 상황은 전체 로직에 흩어져있고 아래의 테스트 코드 상황에서는 원래 하나의 값인데 그걸 나눠서 작성하다보니 발생한 것일 수도 있기 때문이다. 따라서 사용해보고 각각에 맞게 변경해보며 실제 사용에 있어서는 고민을 해야 할 것 같다. 


4. 통합 테스트는 필요하지 않은 범위까지 연동하지 않아야 한다.

 

 이 부분도 조금 고민이 필요한 것 같다. 왜냐하면, 강의와 책의 내용이 상반되었기 때문이다. 아마도 이는 기술의 차이인 것 같다. 둘의 의견을 한 번 들어보자.

 

 최범균님의 책에서는 DB를 연동함에 있어 @SpringBootTest 애노테이션을 이용하면 모든 스프링 빈을 초기화해 시간이 길어지니 @JdbcTest를 사용해 이 시간을 절약하라고 이야기한다.

 

 박우빈님의 강의에서는 DB를 연동함에 있어 @DataJpaTest보다는 @SpringBootTest 애노테이션을 이용해서 테스트 환경을 만들라고 한다. 그 이유는 @DataJpaTest 내부에는 @Transactional 애노테이션이 숨겨져있기 때문에 테스트 환경과 배포환경이 다를 수 있기 때문이다.

 

 개인적으로 Jpa로만 코드를 짜보아서 JdbcTemplate으로 코드를 짜면 어떤 문제가 주로 발생하는지 잘 모른다. 하지만 둘의 의견이 다 맞지 않을까 싶다. JdbcTemplate는 영속성 컨텍스트처럼 중간에 다루는 것 없이 실제 SQL처럼 사용할 수 있다. 또한 DataSource로 트랜잭션 범위도 개발자가 직접 관리하기 때문에 문제가 발생하지 않아서 그런게 아닐까  싶기도 하다.

 

 왜냐하면 @DataJpaTest에서 @Transactional을 사용하면 안되는 이유는 변경 감지 때문이다. 코드는 주문을 받으면 해당 카테고리 상품의 재고를 차감하는 코드이다. 이 때, 서비스 내에 트랜잭션이 걸려있지 않았다면  Update 쿼리가 날아가지 않는 것을 테스트 결과에서 확인할 수 있다. 그 이유는 JPA의 변경 감지가 제대로 작동하기 위해서는 트랜잭션 경계 설정을 두고 초기 스냅샷과 종료 시점을 비교해야 하는데, 그럴 경계 설정이 제대로 안되어 있기 때문이다.

@DisplayName("재고와 관련된 상품이 포함되어 있는 주문번호 리스트를 받아 주문을 생성한다.")
@Test
void createOrderWithStock(){
    // given
    LocalDateTime registeredDateTime = LocalDateTime.now();

    Product product1 = createProduct(BOTTLE, "001", 1000);
    Product product2 = createProduct(BAKERY, "002", 3000);
    Product product3 = createProduct(HANDMADE, "003", 5000);
    productRepository.saveAll(List.of(product1, product2, product3));

    Stock stock1 = Stock.create("001", 2);
    Stock stock2 = Stock.create("002", 2);
    stockRepository.saveAll(List.of(stock1, stock2));

    OrderCreateServiceRequest request = OrderCreateServiceRequest.builder()
            .productNumbers(List.of("001", "001", "002", "003"))
            .build();

    // when
    OrderResponse orderResponse = orderService.createOrder(request, registeredDateTime);

    // then
    List<Stock> stocks = stockRepository.findAll();
    assertThat(stocks).hasSize(2)
            .extracting("productNumber", "quantity")
            .containsExactlyInAnyOrder(
                    tuple("001", 0),
                    tuple("002", 1)
            );
}

 

 

 여기서 테스트 코드에 @Transactional이 달려버리면 테스트 메서드 전체에 트랜잭션 경계가 설정된다. 따라서 서비스에서는 경계가 없어서 안되는데 테스트 코드에서는 경계가 있어서 실제 환경과 테스트 환경이 달라져 버린다. 따라서 테스트는 통과했지만 실제 상황에서는 재고 차감 로직이 제대로 동작하지 않는 문제가 발생할 수 있다. 따라서 JPA를 사용하는 경우, @SpringBootTest를 사용하기를 권장한다고 한다. 그래서 아마도 이 상반된 답변이 기술에 따라 다른 게 아닐까 싶었다.

 

 추가적으로 @SpringBootTest를 쓰면 실제 환경과 비슷하다는 장점을 얻지만 단점도 있다. DB 내부의 무결성 제약조건으로 인해서 @AfterEach에서 제약 조건이 걸리지 않게 순서를 지켜 데이터를 지워주어야 한다. 이 또한 까먹지 말고 기억하자.

@AfterEach
void tearDown(){
    orderProductRepository.deleteAllInBatch();
    productRepository.deleteAllInBatch();
    orderRepository.deleteAllInBatch();
    stockRepository.deleteAllInBatch();
}

5. 테스트 코드에서 스프링 부트 서버가 띄워지는 횟수를 최소한으로 하자.

 

 4번의 내용과 비슷하지만 이는 DB와 관련된 것이 아니라 따로 뺐다. 스프링 서버가 띄워지는 시간은 꽤나 길다. 그런데 테스트 코드가 작성될 수록 설정 차이로 인해 전체 테스트 코드를 돌리는데 스프링 서버가 띄워지는 횟수가 증가한다. 따라서 해당 부분을 통합시켜줄 클래스를 하나 만들고 공통으로 사용할만한 MockMvc, ObjectMapper, MockBean을 추상클래스로 만들어주자. 다음처럼 말이다. 

@WebMvcTest(controllers = {
        OrderController.class,
        ProductController.class
})
public abstract class ControllerTestSupport {
    @Autowired
    protected MockMvc mockMvc;
    
    @Autowired
    protected ObjectMapper objectMapper;
    
    @MockBean
    protected OrderService orderService;
    
    @MockBean
    protected ProductService productService;
}

 

 

 그러면 해당 클래스를 테스트에 상속시키면 상속받은 클래스에 자동적으로 공통에서 사용되는 필드들이 다 주입되며 사용할 수 있다. 또한 스프링 서버가 띄워지는 횟수가 줄어듦을 확인할 수 있다. 따라서 테스트를 작성하고 끝내지말고 꼭 통합 환경을 만들어서 시간을 단축시키는 것을 마지막에 고려해보자.

 

[ 참고 자료 ]

테스트 주도 개발 시작하기 - 최범균

실용적인 테스트 강의 - 박우빈

클린 코드 - 로버트 C.마틴