Spring

Spring, 그리고 Test

Tommy__Kim 2023. 10. 11. 18:28

개요

개인적으로 개발을 함에 있어 테스트 코드는 매우 중요하다고 생각합니다. 

제가 테스트 코드를 중요하게 생각하는 이유는 다음과 같습니다. 

 

개발자가 작성한 코드에 대한 검증 

 

프로젝트가 작거나 클래스가 맡은 기능이 적은 경우에는 그 역할들이 정상적으로 돌아가는지 파악하기 쉽습니다.

하지만 프로젝트가 점점 커질수록, 단일 클래스의 역할이 늘어날 수록 그 역할들이 정상적으로 돌아가는지 파악하기 어렵습니다. 

테스트 코드는 프로젝트의 규모에 상관없이 개발자가 작성한 코드가 개발자의 의도와 동일하게 흘러감을 보장할 수 있습니다. 

 

협업

 

프로젝트의 경우 혼자 진행하는 경우는 드뭅니다.

다른 개발자와 팀을 이루어 개발을 진행하는데 다른 개발자가 작성한 코드에 대해서 어떠한 기능을 하는 지 테스트 코드를 통해 쉽게 파악할 수 있습니다. 

 

이번 글에서는 테스트코드에 대해 간략히 알아본 후, Spring에서는 테스트를 어떻게 진행하면 좋을 지 알아보고자 합니다. 

 

수동 테스트 vs 자동화 테스트

수동 테스트

수동 테스트란 사람이 눈으로 직접 보고 검증하는 테스트를 의미합니다.

[수동 테스트 예시]

@Test
void manualTest() {
    Cart cart = new Cart();
    cart.add("Onion");
    cart.add("Apple");

    System.out.println("카트에 담긴 물품 수 : " + cart.getCartList().size());
    System.out.println("카트 물품 목록 : " + getList(cart.getCartList()));
}

예시코드를 확인해 보면 Cart에 물품을 담는 로직을 검증하는데 있어 Console에 출력하는 방식을 채택하고 있습니다. 

이러한 코드로 테스트를 진행할 경우 테스트가 제대로 돌아가는 지 검증하기 위해서는 매번 눈으로 검증을 해야한다는 단점이 존재합니다. 

자동화 테스트

자동화 테스트란 사람이 외부 도구를 통해 소프트웨어를 테스트 하는 방식을 의미합니다.

[자동화 테스트 예시]

@Test
void autoTest() {
    Cart cart = new Cart();
    cart.add("Onion");
    cart.add("Apple");

    List<String> cartList = cart.getCartList();
    Assertions.assertThat(cartList).hasSize(2)
            .containsExactlyInAnyOrder("Onion", "Apple");
}

예시코드를 확인해 보면 assertj의 Assertions를 통해 테스트를 진행하는 것을 확인할 수 있습니다. 

이러한 코드로 테스트를 할 경우 개발자가 매번 눈으로 검증하지 않아도 되고, 테스트가 성공한다면 해당 명세를 잘 지킨다는 것을 보장할 수 있습니다. 

단위 테스트 vs 통합 테스트

단위 테스트

단위 테스트는 클래스 혹은 메서드 단위를 독립적으로 검증하는 테스트를 의미합니다. 

이러한 테스트의 경우 검증 속도가 빠르고, 안정적인 특징을 지닙니다.

Cart 라고하는 class가 있고 그 클래스에서 add method를 검증한다고 가정해보겠습니다. 

@Getter
public class Cart {

    private List<String> cartList = new ArrayList<>();

    public void add(String product) {
        cartList.add(product);
    }

}

이러한 경우 Cart 클래스를 제외한 외부 모듈에 대해서는 검증할 필요가 없습니다. 

따라서 다음과 같이 테스트 코드를 작성할 수 있습니다.

@Test
void autoTest() {
    Cart cart = new Cart();
    cart.add("Onion");
    cart.add("Apple");

    List<String> cartList = cart.getCartList();
    Assertions.assertThat(cartList).hasSize(2)
            .containsExactlyInAnyOrder("Onion", "Apple");
}

통합 테스트

통합 테스트는 여러 모듈이 협력하는 기능을 통합적으로 검증하는 테스트입니다. 

보통 스프링을 사용하는 경우 Controller, Service, Repository 레이어가 정상 동작하는 지 검증하기 위해 통합 테스트를 자주 작성합니다.

Order 라고하는 Entity가 있고 이에 해당하는 Repository 영역에 대해 테스트를 하고자 할 때, Spring 컨테이너를 띄우고 테스트를 진행하게 되는데, 이러한 경우에 대해 통합 테스트라고 말합니다. 

 

만약 Repository에 다음과 같은 메서드를 하나 생성했다고 한다면 

public interface OrderRepository extends JpaRepository<Order, Long> {
    Optional<Order> findById(Long id);
}

이것이 정상 작동하는지 검증을 해주어야 합니다. 

그렇기 때문에 다음과 같이 테스트 코드를 작성할 수 있습니다.

@SpringBootTest
class OrderRepositoryTest {

    @Autowired
    OrderRepository orderRepository;

    @DisplayName("아이디를 통해 Order를 조회할 수 있다.")
    @Test
    void findById() {
        LocalDateTime testDateTime = LocalDateTime.of(2023, 10, 11, 13, 00, 00);
        // given
        Order order = Order.builder()
                .price(1000)
                .orderDate(testDateTime)
                .build();
        orderRepository.save(order);

        // when
        Order savedOrder = orderRepository.findById(order.getId()).get();

        // then
        assertThat(savedOrder).isEqualTo(order);
    }


}
여기서 LocalDateTime의 경우 LocalDateTime.now() 가 아닌 직접 명시해서 사용하는 것을 확인 할 수 있습니다. 
테스트는 외부 환경에 종속적이지 않아야 합니다. 
만약 Order가 특정 시간에만 생성가능하다는 조건이 붙는다면 
LocalDateTime.now()를 사용할 경우 테스트가 실행되는 환경의 시간에 따라서 테스트가 실패할 수도 성공할 수도 있습니다. 
그렇기 때문에 직접 날짜를 명시해주어 사용하는 것을 권장합니다. 

Spring에서는 과연 어떻게 테스트 코드를 짜야 하는가? 

해당 부분에 대해 많은 고민이 있었습니다. 
테스트코드의 중요성을 인지하고 있으며, 메서드에 대해서 테스트를 최대한 짜려고 하지만 Spring 프레임워크와 결합했을 때는 어떻게 짜야할 지 매번 방황을 했었습니다. 
수많은 방황, 그리고 고민, 그리고 공부를 통해 제가 생각하는 framework와 결합한 테스트 코드의 방향성에 대해서 적어나가고자 합니다.
물론 제가 이번 글에서 작성하는 방향이 100% 정답이 아닌, 한가지의 방향성으로 봐주시면 좋을 것 같습니다. 

Repository Layer

Repository 영역에 대해서는 개발자가 생성한 메서드에 대해 검증을 해주는 것이 좋다고 생각합니다. 

만약 JpaRepository 를 사용한다면, Jpa에서 기본으로 제공하는 메서드 (save, findAll, findBy ...) 에 대해서 검증하는 것이 아닌 추가적으로 작성한 Query들에 대해서 검증을 하는 것이 좋다고 생각합니다. 

public interface OrderRepository extends JpaRepository<Order, Long> {
    Optional<Order> findById(Long id);

    @Query("select o from Order o where o.orderDate > :orderDate")
    List<Order> findAfterMonth(@Param("orderDate") LocalDateTime orderDate);
}

Repository 에 다음과 같은 메서드 두개를 추가했다고 가정을 한다면 두가지 메서드에 대한 검증이 필요합니다. 

  • findById
  • findAfterMonth
@SpringBootTest
class OrderRepositoryTest {

    @Autowired
    OrderRepository orderRepository;

    @DisplayName("아이디를 통해 Order를 조회할 수 있다.")
    @Test
    void findById() {
        LocalDateTime testDateTime = LocalDateTime.of(2023, 10, 11, 13, 00, 00);
        // given
        Order order = Order.builder()
                .price(1000)
                .orderDate(testDateTime)
                .build();
        orderRepository.save(order);

        // when
        Order savedOrder = orderRepository.findById(order.getId()).get();

        // then
        assertThat(savedOrder).isEqualTo(order);
    }

    @DisplayName("파라미터로 주어진 일자 이후에 생성된 Order에 대해서 조회할 수 있다.")
    @Test
    void findAfterMonth() {
        // given
        LocalDateTime standardDate = LocalDateTime.of(2023, 8, 1, 0, 0, 0);
        LocalDateTime beforeStandard =
                LocalDateTime.of(2023, 7, 31, 23, 59, 59);
        Order order1 = Order.builder()
                .price(1000)
                .orderDate(beforeStandard)
                .build();
        LocalDateTime afterStandard =
                LocalDateTime.of(2023, 8, 1, 0, 0, 1);
        Order order2 = Order.builder()
                .price(1000)
                .orderDate(afterStandard)
                .build();
        orderRepository.saveAll(List.of(order1, order2));

        // when
        List<Order> orders = orderRepository.findAfterMonth(standardDate);

        // then
        assertThat(orders).hasSize(1)
                .extracting("price", "orderDate")
                .contains(Tuple.tuple(order2.getPrice(), order2.getOrderDate()));
    }
}

위와 같이 코드를 짜 내가 의도한 바로 쿼리 조회가 잘 되는지, 혹은 수행되는지 검증을 해야합니다. 

Service Layer

Service Layer의 경우 Repository Layer와 연결지어 테스트를 하는것이 좋다고 생각합니다. 

이 때, Repository 영역에 대해서는 Mocking 처리를 하지 않고 통합테스트를 진행하는 것을 선호합니다. 

Mocking 처리를 하게 되면 조금 더 빠른 테스트 코드의 실행이점 등을 가져갈 수 있지만 Mocking 한 객체의 역할을 100% 제어하는 것은 힘들다고 생각합니다. 그렇기 때문에 Repository 영역까지 함께 테스트로 검증하는 방법을 선호합니다. 

@Transactional(readOnly = true)
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;

    @Transactional
    public OrderResponse create(OrderRequest orderRequest) {
        Order order = Order.createOrder(orderRequest);
        Order savedOrder = orderRepository.save(order);
        return OrderResponse.of(savedOrder);
    }

    public OrderResponse find(Long orderId) {
        Order order = orderRepository.findById(orderId)
                .orElseThrow(
                        () -> new IllegalArgumentException("invalid order id")
                );
        return OrderResponse.of(order);
    }
}

Service Layer에 다음과 같은 메서드가 있다고 가정해 보겠습니다. 

@Transactional
@SpringBootTest
class OrderServiceTest {

    @Autowired
    OrderRepository orderRepository;

    @Autowired
    OrderService orderService;

    @DisplayName("OrderRequest를 통해 주문을 생성할 수 있다.")
    @Test
    void create() {
        // given
        LocalDateTime orderDate = LocalDateTime.of(2023, 10, 11, 10, 20, 50);
        OrderRequest orderRequest = new OrderRequest(10000, orderDate);

        // when
        OrderResponse orderResponse = orderService.create(orderRequest);

        // then
        Order savedOrder = orderRepository.findById(orderResponse.id()).get();
        assertAll(
                () -> assertThat(savedOrder.getId()).isEqualTo(orderResponse.id()),
                () -> assertThat(savedOrder.getOrderDate()).isEqualTo(orderDate)
        );
    }

    @DisplayName("order id를 통해 order를 조회할 수 있다.")
    @Test
    void find_OK() {
        // given
        LocalDateTime testDateTime = LocalDateTime.of(2023, 10, 11, 13, 00, 00);
        Order order = Order.builder()
                .price(1000)
                .orderDate(testDateTime)
                .build();
        Order savedOrder = orderRepository.save(order);

        // when
        OrderResponse orderResponse = orderService.find(savedOrder.getId());

        // then
        assertAll(
                () -> assertThat(orderResponse.id()).isEqualTo(savedOrder.getId()),
                () -> assertThat(orderResponse.orderDate()).isEqualTo(savedOrder.getOrderDate())
        );
    }

    @DisplayName("잘못된 order id를 조회할 경우 예외가 발생한다.")
    @Test
    void find_InvalidId() {
        // given
        LocalDateTime testDateTime = LocalDateTime.of(2023, 10, 11, 13, 00, 00);
        Order order = Order.builder()
                .price(1000)
                .orderDate(testDateTime)
                .build();
        Order savedOrder = orderRepository.save(order);

        // when && then
        assertThatThrownBy(
                () -> orderService.find(savedOrder.getId() + 1)
        ).isInstanceOf(IllegalArgumentException.class)
                .hasMessage("invalid order id");
    }
}

create 메서드의 경우 Order를 저장하는 역할을 담당합니다. 그렇기 때문에 이에 대한 테스트코드를 작성했습니다.

find 메서드의 경우 데이터베이스에서 orderId를 통해 조회를 해오는 역할을 담당합니다. 추가적으로 잘못된 orderId가 주어졌을 경우에는 예외를 던지도록 설계를 했습니다. 따라서 두가지 경우에 대해서 테스트코드를 작성했습니다.

  • 해피 케이스 : 정상적으로 기능이 동작하는 경우에 대한 테스트
  • 예외 케이스 : 정상적이지 않은 파라미터가 주어졌을 때 예외가 발생하는 케이스

Controller Layer

Controller Layer의 경우 <<사용자의 요청을 잘 검증하는 가>> 를 중점적으로 테스트를 해야한다고 생각합니다. 

그래서 보통 Controller 영역의 경우 사용자 요청에 대해 잘 검증하는지 주로 체크를 합니다.

그렇기 때문에 Controller 테스트의 경우 Mocking 처리를 해서 간단하게 테스트를 하는 편입니다.

@RestController
@RequiredArgsConstructor
public class OrderController {
    private final OrderService orderService;

    @GetMapping("/orders")
    public ResponseEntity<OrderResponse> save(@RequestBody @Valid OrderWebRequest orderWebRequest) {
        LocalDateTime now = LocalDateTime.now();
        OrderResponse orderResponse = orderService.create(new OrderRequest(orderWebRequest.price, now));
        return ResponseEntity.ok(orderResponse);
    }
}

 

@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @MockBean
    OrderService orderService;


    @DisplayName("주문을 저장한다.")
    @Test
    void save() throws Exception {
        // given
        OrderWebRequest orderWebRequest = new OrderWebRequest(10000);

        // when && then
        mockMvc.perform(
                post("/orders")
                        .contentType("application/json")
                        .content(objectMapper.writeValueAsString(orderWebRequest))
        )
                .andExpect(status().isOk());

    }

    @DisplayName("주문저장 시 가격이 음수라면 예외가 발생한다.")
    @Test
    void save_invalidPrice() throws Exception {
        // given
        OrderWebRequest orderWebRequest = new OrderWebRequest(-10000);

        // when && then
        mockMvc.perform(
                        post("/orders")
                                .contentType("application/json")
                                .content(objectMapper.writeValueAsString(orderWebRequest))
                )
                .andExpect(status().isBadRequest())
                ;

    }
}

OrderService의 경우 MockBean 처리를 해서 의존성 주입이 가능하도록 설정 해준 다음 @Valid 어노테이션이 잘 작동하는지 검증을 하는 것을 확인할 수 있습니다. 

 

또한 @SpringBootTest가 아닌 @WebMvcTest를 사용해 테스트를 진행하는 것을 확인할 수 있습니다. 

 

정리 

Spring 프레임워크를 사용해 테스트를 진행할 때 어떠한 방식으로 테스트 코드를 짜야하는가에 대해 알아보았습니다. 

  • Repository
    • @SpringBootTest를 사용해 테스트 실행 
    • Repository에 작성한 메서드에 대해 검증
  • Service 
    • @SpringBootTest를 사용해 테스트 실행 
    • Mocking 처리를 하지 않음
    • Service에 작성한 메서드에 대해 검증
  • Controller 
    • @WebMvcTest를 사용해 테스트 실행 
    • Service영역의 경우 @MockBean을 사용해 Mocking 처리 진행 
    • 사용자 요청에 대해 제대로 검증하는지 확인 

'Spring' 카테고리의 다른 글

[Spring] 테스트 환경 통합  (0) 2024.03.09
[Spring] @Retryable, Event 적용기  (1) 2023.12.04
Spring MVC - 요청과 관련된 사용법 정리  (0) 2023.07.06
Junit에서 TestInstance의 생명주기  (0) 2023.07.03
NamedParameterJdbcTemplate  (0) 2023.07.01