워밍업 클럽 2기 BE 클린코드&테스트 - 회고 4회
Practical Testing : 실용적인 테스트 가이드 - 회고 4회
이 글은 박우빈님의 강의를 참조하여 작성한 글입니다.
벌써 4주차가 되었습니다. 인프런 워밍업 클럽의 마지막 회고입니다. 다른 것도 한다는 핑계로 시간을 많이 투자하지 못한 거 같아 아쉽기도 하지만 최대한 잘 녹여서 작성하겠습니다.
테스트 작성에 대한 많은 팁을 알게 되어 좋았지만 테스트를 바라보는 여러 관점을 알게 되고 이에 대한 주관이 나름 생긴 거 같아 좋은 시간이었습니다.
학습 목차
Spring & JPA 기반 테스트: Presentation Layer(제외)
Mock을 마주하는 자세
더 나은 테스트를 작성하기 위한 구체적 조언
학습 테스트(제외)
Mock을 마주하는 자세
Mock에 대한 찬반 의견이 있다. 물론 현재 상황(구현 기간, 서비스 특성 등)에 따라 얼마나 Mock 처리의 빈도가 달라질 것이다.
기본적인 Mock에 대한 의견은 Classicist와 Mockist 로 나뉜다.
Classicist VS Mockist
말그대로 Mock을 하면 안 된다와 해야 한다로 나뉜다. Classicist는 여러 객체(모듈)이 상호작용하기에 A와 B에 대한 통합 테스트를 구현하는 경우에 A, B 둘 다 실제 객체를 사용해야 한다고 Mockist는 이미 A, B 각 객체에 대한 단위 테스트가 이뤄졌기에 A, B에 대한 통합 테스트를 구현할 때 A 또는 B 하나를 Mock 처리해서 하는 것이 비용과 시간에 대해 더 합리적이라고 주장한다.
더 나은 테스트를 작성하기 위한 구체적 조언
완벽하게 제어하기
// 시간에 따라 주문 여부가 정해지는 로직 void createOrderWithCurrentTime(){ CafeKiosk cafeKiosk = new CafeKiosk(); Americano americano = new Americano(); cafeKiosk.add(americano); Order order = cafeKiosk.createOrder(LocalDateTime.of(2023,1,17,10,0)); assertThat(order.getBeverage()).hasSize(1); assertThat(order.getBeverage().get(0).getName()).isEqualTo("아메리카노"); } // 시간, 외부 API 등 변할 수 있는 요소는 외부로 빼서 가져오기 // 예로 시간을 파라미터로 가져오는 게 아니라면 // LocalDateTime.now()가 내부 로직에 있다면 테스트를 실행하는 // 시간마다 성공 여부가 다를 것이다. 따라서 제어가 어려운 것은 // 외부로 내보내 완벽하게 제어하자. public Order createOrder(LocalDateTime currentTime){ // 위 시간 인자를 통해 지정한 시간 내인지 확인하는 로직 // 가능하면 주문 생성, 아니면 예외 발생 }테스트 환경의 독립성을 보장하자(테스트 하나에는 하나만 검증)
void createOrderWithNoStock(){ //given LocalDateTime registeredDateTime = LocalDateTime.now(); Product product1 = createProduct(BOTTLE, "001", 1000); Product product2 = createProduct(BAKERY, "002", 2000); Product product3 = createProduct(HANDMADE, "003", 3000); Stock stock1 = Stock.create("001", 2); Stock stock2 = Stock.create("002", 2); stock1.deductQuantity(3); // 행위1 stockRepository.saveAll(List.of(stock1, stock2)); OrderCreateServiceRequest request = OrderCreateServiceRequest.builder() .productNumber(List.of("001","001","002", "003")); .build(); // when/then (행위2) assertThatThrownBy( () -> orderService.createOrder(request, registerDateTime)) .isInstanceOf(IlligalArgumentException.class) .hasMessages("재고가 부족한 상품이 있습니다")); 밑줄 친 코드를 보자. 테스트는 하나의 행위만 검증해야 한다. 위 상황에서는 현재 재고보다 더 많은 상품을 주문했을 때, 예외가 발생하는 지 검증하는 테스트이다. given 절에 밑줄 코드를 보면 어떠한 행위가 또 들어가 있다. 즉, 상황을 준비만 해야 하는 given 절에서 어떠한 행위가 일어났다. 위 테스트 코드를 이해하려면 밑줄 친 코드(메서드)의 행위를 알기 위한 논리적 사고가 발생하여 독립적이지 못한 테스트이다. }한 눈에 들어오는 Test Fixture 구성하기
테스트를 작성하다 보면 공통적인 given절로 중복되는 코드가 발생한다. 보통, 코드의 중복을 줄이기 위해 @BeforeEach, @BeforeAll, @Afrer... 등이나 data.sql 파일로 손 쉽게 데이터의 삽입이 이뤄진다. (삭제는 테스트 간 결합도를 낮추기 위해 사용하므로 @BeforeEach 혹은 @Transactional을 사용하자) 비즈니스 로직 개발 관점에서는 당연히 코드의 중복을 줄이는 것이 합리적이며 유지보수성이 좋다고 생각하지만 테스트에서는 위와 같은 방법을 지향해야 한다. 테스트는 말 그대로 테스트고 위와 같은 방법을 사용하면 모든 테스트가 @BeforeEach, data.sql 등에 강결합하여 의존성이 커진다. 위 설정 과정이 달라졌을 때, 어떤 결과가 나올지 모른다. 따라서 테스트 코드가 길어지더라도 given 절에서 설정하는 것이 올바르며 한 눈에 파악하기 쉽다.(data.sql로 given을 구성하면 테스트를 볼때마다 data.sql로 이동하기에 파악하기 어렵다.[파편화]) ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ 1. 각 테스트 입장에서 아예 몰라도 테스트 내용을 이해하는 데에 문제가 없는가? 2. 수정해도 모든 테스트에 영향을 주지 않는가? 위 질문에 해당하지 않는다면 @BeforeEach와 같은 기능을 사용해도 괜찮다. 위 내용을 떠나서라도 @BeforeEach을 사용하더라도 전달하고자 하는 도메인 개념이나 로직 등 관련 내용이 충분히 전달되면 괜찮다. 핵심은 도메인이다.Test Fixtuer 클렌징
@BeforeEach void beforEach(){ // 1 orderProductRepository.deleteAllInBatch(); orderRepository.deleteAllInBatch(); productRepository.deleteAllInBatch(); // 2 orderProductRepository.deleteAll(); orderProductRepository.deleteAll(); orderProductRepository.deleteAll(); } 1번은 해당 테이블 데이터 전체를 삭제하겠다는 의미이다. 2번은 해당 테이블 데이터 전체를 조회하고 1씩 삭제하겠다는 의미이다. 데이터가 많을수록 성능 차이가 커지므로 1번을 사용하자. 대신 참조키, 무결성 관계를 고려해서 순서에 맞게만 사용하면 된다. ex) orderRepository.deleteAllInBatch(); orderProductRepository.deleteAllInBatch(); productRepository.deleteAllInBatch(); 위 예시처럼 실행하면 orderProduct가 order를 참조하기 있기 때문에 테스트가 실패한다. [ order <- 일대다 -> orderProduct ] ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ **추가로 @Transactional, @BeforeEach 중 어떤 것을 사용할 지는 상황에 맞게 선택하면 된다.테스트 간편화 기능
@ParameterizedTest : 다양한 인자를 통해서 테스트의 완성도를 높이거나 코드를 줄일 수 있음 @CsvSource, @MethodSource 등 제공하는 값을 통해서 메서드 인자로 넘기면 해당 수만큼 실행한다. @DynamicTest @Test 대신 @TestFactory를 사용해야 하고 Collection을 기준으로 연쇄적인 기능을 가진 객체를 사용하면 된다.(List, stream 등) 연쇄적인 테스트가 필요하다면 해라. 따로 따로 하는 것보다 연쇄적으로 하는 것이 좀 더 직관적일 수 있다. 예로, 해피 케이스랑 예외 케이스를 따로 테스트를 작성해서 검증할 수 있다. 하지만 해당 어노테이션을 사용하면 하나의 테스트에서 연쇄적으로 검증할 수 있다. '성공 -> 예외' 한 묶음으로. 하지만 given-when-then 절이 중첩 if 문 같이 사용되어 가독성이 떨어질 수 있으니 적절하게 사용하자. 추가로 연쇄적인 시나리오를 위해서도 사용 가능하다. 재고 차감을 예시로 보면 재고가 1인 경우에 감소하고 0인 경우 감소하는 경우를 연속으로 확인할 수 있어 좀 더 가독성이 높다.private 메서드는 테스트 해야 할까?
외부 관점(컨트롤러, 외부 호출자 등)에서는 공개된 API만 사용한다. private 메서드를 직접적으로 호출하는 경우는 없으며 공개 API(public 메서드)를 호출한다. 또한 공개 API를 호출하면서 내부에 있는 private 메서드도 호출한다. 즉, 공개 API가 검증이 되면 내부에 있는 기능 또한 검증되므로 굳이 private 메서드를 검증할 필요가 없다.
결국 private 메서드는 테스트 할 필요가 없다. 그럼에도 이런 생각이 든다면 ‘**객체를 분리할 시점인가?‘**라는 질문을 해라.
위와 같은 생각이 든다면 객체의 책임이 복합적일 수도 있다는 신호라고 한다.
(사실, 이러한 경험이 없어 위와 같은 느낌이 아직은 모호하게 다가온다.)
개인 의견
기존에는 고려하지 못한 부분에 대해 많이 알게 되어 테스트 코드에 대한 주관이 조금은 생겼다. 물론 아직도 명확하게 답을 할 수 있는 것들이 적지만 충분히 의미 있는 시간이었다.
위에서도 말했듯이 절대적인 것은 없기에 자신이 처한 상황에 따라 되게 유동적으로 테스트 코드의 중요성과 작성법 등 변한다. 상황에 맞게 적용할 수 있게 여러 방법을 시도해 보는 것도 좋은 거 같다.
번외로 가장 인상 깊었던 부분은 클린 코드 강의의 목수 이야기다. 대게 새로운 것을 통해 기존의 방식을 변경하는데 어려움을 겪어 거부하기도 하고. 기존에 사용하는 방식만 안 다면 더 쉽고 좋은 방법이 있더라도 이를 적용할 생각조차 하지 않는다는 내용이다.
새로운 기술을 학습하고 적용하는 것은 현실을 고려했을 때 어렵긴 하지만 새로운 기술에 대한 학습과 적용을 두려워하지 않는 습관을 기르는 것이 좋다는 것을 상기시켜줘서 좋았다. 학습적인 측면을 넘어 개발자로서 필요한 자세나 습관에 대한 내용도 담겨 있어 좋은 선생님이라 생각합니다.
댓글을 작성해보세요.