테스트 환경을 복잡하게 만드는 요소와 해소 - 대역을 구현하기

대역의 필요성 - 자원을 많이 사용하고 통제 불가능

  • 외부 API의 경우
    • 항상 접근 가능한지 알 수 없다.
    • 접근 가능하더라도 모든 테스트마다 외부 API와 연결하는 것은 자원 낭비이다.
    • 테스트를 위한 API가 존재하지 않을 수 있다.
    • 외부 API는 롤백이 어렵다.
  • 리포지토리의 경우,
    • 테스트 DB에 데이터가 남아 있는 경우 정상적인 테스트를 수행하지 못할 수도 있다.
    • (특히 멀티 스레드를 테스트할 때) 트랜잭션으로 롤백할 수 없는 테스트가 존재한다. 이 경우 insert가 되어 테스트 DB가 오염될 수 있다.
    • DB와의 연결 자체가 자원을 소비한다. in memory db를 사용하더라도, create database -> create table -> insert into … 의 과정을 겪어야 한다.

통합테스트와 대역의 필요성

  • 아래는 OrderService를 테스트하기 위한 코드이다. 서비스는 OrderRepository를 사용한다.
  • 아래는 @SpringBootTest@Autowired을 사용하여 통합 테스트를 수행했다.
@SpringBootTest
public class OrderServiceTest{
    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void givenOrderOk_whenOrder_thenSuccess(){
        Order order = new Order("order-1234", "apple");
        
        orderService.order(order); 

        Optional<Order> found = orderRepository.findByOrderId("order-1234");
        assertThat(found).isPresent();
    }

    @Test
    void givenDuplicatedOrderIdOrder_whenOrder_thenThrownException(){
        Order order = new Order("order-duplicated-id", "apple");
        
        assertThatThrownBy(() -> orderService.order(order))
            .isInstanceOf(DuplicatedOrderIdException.class);
    }
}
  • 위 테스트의 문제는 여러 개가 있다.
    • DB를 사용함에 따라 많은 리소스를 사용한다.
    • DB를 미리 설정한 상태에서만 테스트가 가능하다. 레코드 중 orderId가 “order-1234”를 가진 레코드가 없어야 하며, “order-duplicated-id”를 가진 레코드가 존재해야 한다.
    • OrderService를 위한 테스트이다. 하지만 OrderRepository#findByOrderId의 정상 동작을 보장해야한다. 테스트의 초점이 OrderService로 온전하게 집중되지 않는다.
  • 이를 해소하기 위해서 테스트 대상을 제외한 전략이나 인자는 대역을 사용하는 것이 올바르다. OrderRepository를 MemoryOrderRepository로 구현하고 테스트를 진행하였다.
public class MemoryOrderRepository implements OrderRepository{
    Map<String, Order> orders;
    public void insert(Order order){
        if(order.getOrderId().contains("duplicated")) 
            throw new IllegalArgumentException(String.format("중복된 orderId가 입력되었습니다. 입력된 orderId : %s", order.getOrderId()));
        orders.put(order.getOrderId(), order);
    }

    public void findByOrderId(String orderId){
        return Optional.ofNullable(orders.get(orderId));
    }
}

public class OrderServiceTest{
    OrderService orderService;
    OrderRepository mockOrderRepository;
    @BeforeEach // 각각의 테스트마다 각 객체를 초기화한다.
    void setUp(){
        mockOrderRepository = new MemoryOrderRepository();
        orderService = new OrderService(mockOrderRepository);
    }
    
    // 상동
}
  • 이렇게 대역을 사용할 경우 대역이나 스파이 등 다양한 역할을 수행하도록 조작할 수 있다. 유연한 테스트가 가능하다.

대역 관리의 복잡함 - 전략

  • 다만 앞서의 방식으로 테스트 코드를 작성할 경우, 시스템이 커지면 커질 수록 관리 비용이 엄청나게 소모된다.
  • 거짓말은 거짓말을 부른다는 이야기가 있다. 대역은 대역을 부른다. 하나의 테스트를 위하여 수많은 대역을 필요로 할 수도 있다. 예를 들면 다음과 같다.
public SomeControllerTest{
    SomeController someController;

    @BeforeEach
    void setUp{
        A1Repository a1Repository = new A1RepositoryImpl();
        A2Repository a2Repository = new A2RepositoryImpl();
        B1Repository b1Repository = new B1RepositoryImpl();
        B2Repository b2Repository = new B2RepositoryImpl();
        AService aService = new AService(a1Repository, a2Repository);
        BService bService = new BService(b1Repository, b2Repository);
        someController = new SomeController(aService, bService);
    }
}

인자 관리의 어려움

  • 인자 역시 대역 관리가 어렵다. 특히 계층마다 분리를 강조하는 지금의 아키텍처 스타일을 따르면, 인자의 종류는 매우 복잡해진다. 예를 들면 다음과 같다.
    • Json - 컨트롤러가 최초에 받는 데이터
    • OrderRequestDTO - 컨트롤러가 json에서 pojo로 변환
    • OrderRequest - 컨트롤러가 OrderRequestDTO를 서비스에 전달
    • Order - 서비스가 OrderRequest를 분석하고 실질적으로 처리하는 객체. db에 insert를 하는 객체.
  • 위의 인자는 테스트를 어렵게 만든다. 그 이유는 1) 테스트 코드 블럭을 읽기 어렵게 만들고 2) 애당초 생성 자체가 불가능할 수도 있다.

1) 복잡해지는 코드 블럭

  • 아래처럼 필요로한 인자를 코드 블럭에서 작성하면 아래와 같이 코드를 작성할 것이다. 이러한 코드 작성이 모든 테스트마다 있을 것이다. 중복된 코드가 반복되고 중요하지 않은 코드가 강조되는 효과를 가진다.
@Test 
void test1(){
    // given
    OrderRequestDTO dto = OrderRequestDTO.builder()
        .orderId("orderId")
        .itemName("itemName")
        .price(123)
        // ..중략..
        .build();
}

@Test 
void test2(){
    // given
    OrderRequestDTO dto = OrderRequestDTO.builder()
        .orderId("orderId")
        .itemName("itemName")
        .price(123)
        // ..중략..
        .build();
}

2) 캡슐화로 인한 생성 불가

  • 특히 2)가 가장 큰 문제다. 캡슐화를 잘 수행하기 위해서는 가능한 열려 있는 api를 최소화 해야 한다. 캡슐화의 기술이 발달하여 자바는 생성자와 세터 등 모든 메서드를 막더라도 객체 생성 및 데이터 주입이 가능하다. 예를 들면 ObjectMapper는 json에서 pojo로 파싱할 때 리플렉션을 사용한다. 이로 인해 아래와 같이 생성자와 세터를 막아도 동작한다.
@Getter // getter만 열어둔다. setter가 존재하지 않는다.
public class OrderRequestDTO{
    private final String orderId;
    private final String item;

    private OrderRequestDTO(){} // 생성자가 막혀있다.
}
  • 만약 위와 같은 상황이라면 테스트 코드에 사용하는 인자 자체를 작성할 수 없다.
  • 어쩔 수 없이 캡슐화의 수준을 낮춰 생성자나 세터를 package-private으로 변경해야 할 수도 있다.
  • 혹은 테스트마다 String json을 ObjectMapper로 변환해야 할 수도 있다. 그럼 json 자체를 읽어야 하는 문제가 발생한다.

팩터리를 활용한 관리

  • 모듈의 대역 관리만큼 인자의 관리 역시 까다롭고 복잡하다.
  • 나의 경우 팩터리를 만들어서 해결한다. 리포지토리나 서비스 등 전략 역시 같은 방식으로 해결 가능하다.
  • 코드의 양을 확실하게 줄여준다. 필요에 따라 스태틱 메서드를 추가하거나 수정하여 적합한 객체를 제공할 수 있다.
  • 재사용 가능하다.
public class OrderRequestArgumentFactory{
    private static ObjectMapper objectMapper;

    public static String json(String orderer, String orderItem, String receiverAddress, ...args){
        return String.format("{\"orderer\":\"%s\".........}", orderer, orderItem, receiverAddress, ...args);
    }
    
    public static OrderRequestDTO orderRequestDTO(...args){
        return objectMapper.readValue(json(...args), OrderRequestDTO.class);
    }

    public static OrderRequest orderRequest(...){}

    public static Order order(...){}
}

public class SomeTest{
    @Test
    void test_service(){
        OrderRequest orderRequest = OrderRequestArgumentFactory.orderRequest("김순애", "과자", "서울시 용산구");
    }

    @Test
    void test_controller(){
        OrderRequestDTO orderRequest = OrderRequestArgumentFactory.orderRequestDTO("김순애", "과자", "서울시 용산구");
    }
}

나아가며

팩터리와 Mockito의 혼용

  • 전략이나 인자를 실제로 구현한다면, 테스트 코드 블럭에 하나하나 구현하는 것보다 팩터리로 만드는 것이 가장 효과적이었다. 하지만 팩터리가 완벽한 방식은 아니라 생각한다.
    • 읽어야 하는 코드가 늘어난다. 팩터리를 이해해야 한다.
    • 관리 대상이 늘어난다. 코드가 변경되면 팩터리도 유지보수해야 한다.
    • 유닛 테스트는 최소한의 의존성을 가지고 수행하는 테스트이다. 하지만 팩터리에 대한 의존성이 발생한다.
  • 나의 경우 팩터리는 제한적으로 사용한다.
    • 전략의 경우 Mockito만 사용한다. 전략이 복잡하게 얽힌 순간 관리가 매우 어려워진다. 장기적으로 Mockito가 유지보수에 유리하다.
    • 인자의 경우 Mockito와 팩터리를 혼용한다. 외부 API의 경우 json 스펙은 변경 가능성이 크지 않다. 이 경우 Mockito보다 팩터리를 사용하는 것이 더 편하다.

통합 테스트의 필요성과 대역을 빈에 등록하기

  • 유닛 테스트만큼 통합 테스트 역시 중요하다.
    • 우리가 런타임에 사용하는 어플리케이션은 순수한 자바 코드가 아닌 스프링부트 프레임워크에서 동작한다. 통합 테스트의 동작을 보장해야 한다.
    • pc의 성능과 네트워크 성능이 좋아지고 있다. 유닛 테스트보다 느릴 뿐, 통합테스트 역시 충분히 빠르다.
  • 결과적으로 어느 정도의 테스트코드는 반드시 @SpringBootTest 아래에서 작성해야 한다.
  • 다만, 모든 통합 테스트가 DB와의 통신을 필요로 하지 않는다. 이 경우 어떻게 하는가?
    • 대역을 빈으로 등록한다. @TestConfiguration을 구현하고 @Bean으로 Memory{}Repository를 주입한다. Mockito를 주입할 수도 있다.
    • in memory db를 사용한다.