본문 바로가기
Spring

TDD.. 테스트 코드는 습관이에요!

by zkdlu 2021. 4. 2.

인터넷에 테스트 코드의 장점을 검색해보면, 안정성을 높인다. side-effect를 줄인다. 마음의 평화가 찾아온다. 등 생산성을 매우 높여준다고 합니다.

하지만 저도 아아 그렇구나 테스트 코드는 좋은거구나.. 하고 신나게 개발만 했습니다. 물론 테스트 했죠. 디버깅하면서 전체 시나리오 테스트를요...

 

그런데 생각해봅시다. 우리는 소프트웨어라는 제품을 만들고 있는데, 제품을 만들 때 테스트를 안한 부품을 믿고 사용 할 수 있을까요?

갤럭시 폴드도 테스트를 하네요

TDD를 공부하면 만나는 키워드가 있습니다. 그건 바로  F.I.R.S.T인데, F.I.R.S.T 원칙을 지키면서 단위테스트를 작성하면 좋은 테스트를 만들 수 있다고 합니다.

프로그래밍 패러다임은 이런 단어로 뜻 만드는 걸 좋아하는거같아요.
SOLID라던가.. ACID라던가.. 

그러면 F.I.R.S.T가 과연 무슨 뜻일까요?

 

Fast

테스트는 빨라야 합니다.

Isolated

테스트는 고립되어야 합니다. 단위 테스트는 다른 단위 테스트를 포함해서는 안됩니다.

Repeatable

테스트는 반복이 가능하여야 합니다. 반복이 가능한 테스트는 실행할 때마다 결과가 같아야 합니다. 테스트에 필요한 외부 환경을 격리 시켜 테스트 코드를 작성합니다. 테스트에 현재 시간과 같은 요소가 포함 된다면 동일한 시간에 테스트를 할 수 있도록 격리 시킵니다.

Self-validating

테스트는 스스로 검증이 가능해야 합니다. 테스트는 결과값을 알 수 있어야 합니다.

Timely

테스트는 적시에 작성해야 아니 지금 작성해야 합니다.

 

F.I.R.S.T 원칙을 지키면서 정해진 시나리오에 맞게 프로젝트를 구성 후 테스트 코드를 작성해보겠습니다.

1. 유저는 가게에서 메뉴를 선택하고 주문을 하면 주문 번호를 발급해준다.
2. 결제대기 상태인 주문에  결제를 하면, 주문은 배송 상태가 된다.
3. 배송이 완료 되면 주문의 상태는 완료가 된다.

// 주문이 될 때 가게가 운영중인지, 최소 주문 금액은 만족했는지, 가게의 메뉴가 변경되진 않았는지 등에 대한 검증을 한다.
// 주문의 상태는 "결제대기", "수락대기", "조리중", "배송중", "완료", "취소" 로 6개가 있고, "취소"를 제외 한 모든 상태는 이전 상태에서 다음상태로만 진행 할 수 있다.
// 주문은 수락 되기 전 "결제대기", "수락대기" 상태에서만 취소가 가능하다.

해당 소스코드는 깃허브에 작성해두었습니다. 

[소스코드]

 

Domain Test

Domain의 관심사는 주어진 비즈니스 로직을 수행하는가? 입니다. 주어진 조건 상황을 연출하여 원하는 기댓값이 나오는지 테스트 합니다.

  1. 주문을 했을 때 정해진 validation을 통과 하는지
  2. Validation을 만족하면 주문의 상태가 결제 대기 상태가 되는지,
  3. 결제 대기 상태에서 결제를 하면 배송 상태가 되는지 / 결제 대기 상태가 아닐 때 결제를 하면 예외가 발생하는지
  4. 배송 완료를 하게 되면 주문이 완료가 되는지 / 배송 상태가 아닐 때 배송 완료를 하면 예외가 발생하는지
  5. 주문의 각 케이스에 취소 요청을 해서 취소 상태로 변환이 되는지
class OrderTest {
    private Shop shop;

    @BeforeEach
    void setUp() {
        shop = new Shop("shop-1", "건s shop", 3000);

        shop.addMenu(new Menu("menu-1", "만두", 1000));
        shop.addMenu(new Menu("menu-2", "라면", 2000));
        shop.addMenu(new Menu("menu-3", "고기", 3000));

        shop.open();
    }

    @Test
    @DisplayName("주문 정보 생성")
    void place() {
        //given
        List<OrderItem> orderItems = shop.getMenus()
                .stream()
                .filter(m -> m.getPrice() <= 2000)
                .map(m -> new OrderItem(m.getId(), m.getName(), m, 1))
                .collect(Collectors.toList());

        Order order = Order.builder()
                .id("order-id")
                .shop(shop)
                .orderItems(orderItems)
                .build();
        //when
        order.place();

        //then
        assertThat(order.getState()).isEqualTo(Order.OrderState.결제대기);
    }
    
    @Test
    @DisplayName("가게가 영업중이 아니고 최소 주문 금액을 만족한다.")
    void placeTest2() {
        //given
        shop.close();
        List<OrderItem> orderItems = shop.getMenus()
                .stream()
                .map(m -> new OrderItem(m.getId(), m.getName(), m, 1))
                .collect(Collectors.toList());

        Order order = Order.builder()
                .id("order-1")
                .shop(shop)
                .orderItems(orderItems)
                .build();
        //when
        //then
        assertThatThrownBy(() -> order.place())
                .isInstanceOf(IllegalStateException.class);
    }
    
    .. 생략
}

 

Service Test

Service에서는 유즈케이스에 해당하는 테스트를 합니다. 보통 Service는 Repository Bean을 주입받아 사용하지만 의존성을 주입받기 위해서는 Spring Context가 로드 되어야 합니다. 이러한 동작은 가볍지 않다고 생각하기 때문에 Repository를 Mock 객체로 만들어 사용합니다.

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock
    private OrderRepository orderRepository;
    @Mock
    private ShopRepository shopRepository;
    @InjectMocks
    private OrderService orderService;

    private Shop shop;
    private OrderDto orderDto;

    @BeforeEach
    void setUp() {
        shop = new Shop("shop-1", "건s shop", 6000);

        shop.addMenu(new Menu("menu-1", "만두", 1000));
        shop.addMenu(new Menu("menu-2", "라면", 2000));
        shop.addMenu(new Menu("menu-3", "고기", 3000));
        shop.open();

        orderDto = new OrderDto();
        orderDto.setId("order-1");
        orderDto.setShopId(shop.getId());
        orderDto.setMenuIds(shop.getMenus().stream().map(Menu::getName).collect(Collectors.toList()));
    }

    Order getOrder() {
        var orderItems = shop.getMenus().stream()
                .map(OrderItem::new)
                .collect(Collectors.toList());

        return Order.builder()
                .id(orderDto.getId())
                .shop(shop)
                .orderItems(orderItems)
                .build();
    }

    @Test
    @DisplayName("shop의 모든 메뉴를 선택하여 주문 요청을 한다.")
    void placeOrder() {
        //given
        given(shopRepository.findById("shop-1")).willReturn(Optional.of(shop));

        //when
        String orderId = orderService.placeOrder(orderDto);

        //then
        assertThat(orderId).isEqualTo("order-1");
    }

    @Test
    @DisplayName("주문번호에 해당하는 주문을 결제한다.")
    void payedOrder() {
        //given
        Order order = getOrder();
        order.place();
        given(orderRepository.findById("order-1")).willReturn(Optional.of(order));

        //when
        String orderId = orderService.payedOrder("order-1");

        //then
        assertThat(order.getState()).isEqualTo(Order.OrderState.수락대기);
    }

    @Test
    @DisplayName("점주는 해당 주문을 수락한다.")
    void acceptOrder() {
        //given
        Order order = getOrder();
        order.place();
        order.payed();
        given(orderRepository.findById("order-1")).willReturn(Optional.of(order));

        //when
        String orderId = orderService.acceptOrder("order-1");

        //then
        assertThat(order.getState()).isEqualTo(Order.OrderState.조리중);
    }

    @Test
    @DisplayName("주문번호에 해당하는 주문 배송을 시작한다.")
    void deliveryOrder() {
        //given
        Order order = getOrder();
        order.place();
        order.payed();
        order.accept();
        given(orderRepository.findById("order-1")).willReturn(Optional.of(order));

        //when
        String orderId = orderService.deliveryOrder("order-1");

        //then
        assertThat(order.getState()).isEqualTo(Order.OrderState.배송중);
    }

    @Test
    @DisplayName("주문자는 물건을 받고 주문 완료를 누른다.")
    void completeOrder() {
        //given
        Order order = getOrder();
        order.place();
        order.payed();
        order.accept();
        order.delivery();
        given(orderRepository.findById("order-1")).willReturn(Optional.of(order));

        //when
        String orderId = orderService.completeOrder("order-1");

        //then
        assertThat(order.getState()).isEqualTo(Order.OrderState.완료);
    }

    @Test
    @DisplayName("결제 전에 주문을 취소한다.")
    void cancelOrder() {
        //given
        Order order = getOrder();
        order.place();
        given(orderRepository.findById("order-1")).willReturn(Optional.of(order));

        //when
        String orderId = orderService.cancelOrder("order-1");

        //then
        assertThat(order.getState()).isEqualTo(Order.OrderState.취소);
    }
}

Test Class 내부에 Repository를 구현한 Fake객체를 생성하면 Service도 POJO로만 구성이 가능하지만, 너무 작업양이 많아지는거같아 Mockito를 사용하였습니다.

 

Service는 유즈케이스를 처리하는 곳입니다. 그렇기 때문에 주문, 결제 배송등을 처리한 후 주문 번호를 반환하는게 프로그램의 유즈케이스라면, 주문번호가 결과값이 일치하는지 테스트 하는게 옳다고 생각합니다.

그러나 개발자의 실수로 Service메서드를 작성하면서, 주문번호 반환을 했는데 주문 처리를 하지 않았다면?

정답을 알려줘..

 

Controller Test

Controller에서는 외부에서 들어오는 요청이 Controller가 요구하는 포맷으로 잘 처리가 되는지가 주요 관심사라고 생각합니다. 대부분의 Controller에서는 Service의 호출결과를 그냥 반환하거나 정해진 타입으로 변형 후 반환을 하는데, 이는 Service를 테스트 하면서 유즈케이스를 만족하는지 테스트 해야 한다고 생각합니다.

 

그렇기 때문에 Controller를 테스트하기 위해 Controller 빈을 등록하여 직접 url로 접근하고 파라미터가 정상적으로 매핑되는지 테스트 해야 한다고 생각합니다.

@WebMvcTest({OrderApi.class})
class OrderApiTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @DisplayName("주문 요청에 필요한 Parameter가 잘 매핑되었나요?")
    void placeOrder() throws Exception {
        //given
        OrderDto orderDto = new OrderDto();
        orderDto.setId("order-1");
        orderDto.setShopId("shop-1");
        orderDto.setMenuIds(Arrays.asList("menu-1","menu-2","menu-3"));
        given(orderService.placeOrder(orderDto)).willReturn("order-1");

        //when
        //then
        mockMvc.perform(
                post("/")
                .content(objectMapper.writeValueAsString(orderDto))
                .contentType(MediaType.APPLICATION_JSON)
        )
                .andDo(print())
                .andExpect(status().isOk());
    }
}

Controller가 빈으로 등록되기 위해 Spring이 실행되기 때문에 위의 테스트보다 속도가 느립니다. 

 

평소 테스트 코드 작성을 잘 하지 않는 이유는 테스트를 어떻게 해야할지 모르기 때문이 컸던거같습니다. "과연 이게 효과가 있겠어?", "이럴 시간에 코드 한줄 더 짜겠다.", "단위 테스트 어차피 정해진 시나리오로 동작하는지 확인하는거 아니냐?"  라는 생각을 하고있었는데 이는 테스트 코드를 작성하는 그 순간만을 생각했기 때문인거같습니다.

 

테스트는 코드를 작성하고, 수정하고, 리팩토링하고, 배포할 때마다 수행하여 기존에 만들어둔 테스트 케이스가 정상적으로 통과하는지를 판단할 수 있으며, 추가적인 오류가 발생하는 것을 사전에 방지할 수 있다고 생각합니다.