[워밍업 클럽 4기 백엔드] 4주차 발자국
4주차에 배운 것들📌Test Double"Test Double"은 테스트 중에 실제 객체를 대체하기 위해 사용하는 객체를 통칭하는 개념으로 다섯 가지 유형이 존재Dummy : 아무 것도 하지 않는 깡통 객체Fake : 단순한 형태로 동일한 기능은 수행하나, 프로덕션에서 쓰기에는 부족한 객체ex. FakeRepository Stub : 테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체 그 외에는 응답하지 않는다.상태 검증(State Verification) : 메서드 호출 후 Stub이 상태가 어떻게 바뀌었는지 검증Mock : 행위에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체행위 검증(Behavior Verification) : 메서드에 파라미터를 넘겨줬을 때, 어떤 값을 반환할지 행위를 검증Spy : Stub이면서 호출된 내용을 기록하여 보여줄 수 있는 객체 일부는 실제 객체처럼 동작시키고 일부만 Stubbing할 수 있다. 📌 나는 Classicist 일까 Mockist 일까"Classicist VS. Mockist" 강의를 듣기 전까지 Mockist였다. "Mocking을 통해서 단위 테스트로 기능을 테스트했으면 통합 테스트에서는 안해도 될 것 같은데?"처럼 단순하게 생각했다. 그런데 코치님께서 Classicist를 지향하시는 이유를 듣고난 후 다시 생각해보았다. 어쩔 수 없이 Mocking을 해야하는 상황을 제외하고, 각 기능들이 의존 관계를 가질 때는 어떤 예외가 발생할지 모르기 때문에 최대한 프로덕션 환경과 비슷하게 테스트를 하는 것이 좋다고 생각되었다. 그래서 앞으로는 Classicist 방식으로 테스트 하는 것을 지향해야겠다. 📌 그렇다면 Mocking은 언제?💡 Mocking이 유용한 상황은 대표적으로 2가지가 있다.외부 시스템에 의존하는 기능외부 시스템 같이 오류가 발생해도 우리가 통제할 수 없다.테스트할 때마다 비용이 발생하는 기능 ex) 메일 전송 ❓ 만약 한 객체에 특정 기능은 Mocking을 하고싶고, 실제 동작도 테스트하고 싶다면?이럴 땐 Spy 객체를 활용할 수 있다! Mocking을 원하는 특정 기능만 Stubbing을 하고, 나머지는 원본 객체처럼 실제 동작을 테스트할 수 있다.🔗 Mock 관련 어노테이션 정리 📌 Mockito도 BDD 스타일로Mockito.when(mailSendClient.sendEmail(anyString(), anyString())) .thenReturn(true); BDDMockito.given(mailSendClient.sendEmail(anyString(), anyString())) .willReturn(true);기존 Mockito에서는 stubbing을 위한 기능의 메서드명이 when 인데, stubbing 과정은 given 절에서 해야하는 작업이라 메서드명이 부자연스러웠다. 그럴땐 Mockito를 한 번 감싼 BDDMockito를 사용하면 BDD 스타일에 더 자연스럽게 코드를 작성할 수 있다. BDDMockito에서 given 은 Mockito의 when 과 동일한 기능을 한다. 📌한 문단에 한 주제@Test @DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.") void containsStockTypeEx() { // given ProductType[] productTypes = ProductType.values(); for (ProductType productType : productTypes) { if(productType == ProductType.HANDMADE) { // when boolean result = ProductType.containsStockType(productType); // then assertThat(result).isFalse(); } if(productType == ProductType.BAKERY || productType == ProductType.BOTTLE) { // when boolean result = ProductType.containsStockType(productType); // then assertThat(result).isTrue(); } } }각 타입들을 모두 검증할 수 있도록 하나의 테스트로 구성하는 것은 단점이 있다.한 문단에 두 가지 이상의 내용이 포함된다.반복문 자체가 여러 논리 구조가 들어가게 하고, 보는 사람의 사고를 복잡하게 한다.💡이럴 경우에는 케이스를 나눠서 테스트를 작성하는 것이 좋고, 이 방식은 지양하자! 📌테스트 환경의 독립성을 보장하자다른 API를 끌고와서 테스트 간 결합도가 생길 수 있다.// given Stock stock1 = Stock.create("001", 2); Stock stock2 = Stock.create("002", 2); stock1.deductQuantity(3); // 예외 : 차감할 재고 상품이 없습니다.테스트 주제와 맞지 않은 given 절에서 예외가 발생하고 있음왜 실패했는지 유추하기 어려운 상황이 발생💡 독립성을 보장하는 방법given 절에서 순수 builder 또는 생성자 기반으로 데이터를 생성팩토리 메서드나 메서드 기반은 지양어떠한 의도를 가지고 필요한 인자만 받거나 내부에 검증 로직이 포함되어 있을 수 있기 때문 📌Test Fixture 구성하기✅Test Fixture : given 절에서 생성한 객체들Fixture : 고정물, 고정되어 있는 물체테스트를 위해 원하는 상태로 고정시킨 일련의 객체💡 공통된 Fixture들을 만들어야 할 때문서로서의 역할을 위해서 given 절에서 생성하는 것이 좋다!@BeforeEach void setUp() { // before method }@BeforeEach 를 사용공유 변수 같이 테스트 간 독립성을 훼손(테스트 간 결합도가 생김)상위 setUp 메서드에서 어떤 데이터를 생성하는지 확인해야 하며 파편화를 유발문서로서의 역할을 할 수 없음 만약 각 테스트 입장에서 봤을 때 아예 몰라도 테스트 내용을 이해하는 데에 문제가 없는 경우나 해당 공유 변수를 수정해도 모든 테스트에 영향을 주지 않는 경우는 사용해도 괜찮다.data.sql을 사용하여 테스트 데이터 생성파편화가 발생 (given 절에 데이터가 없으므로 어떤 데이터로 테스트를 하는지 확인하기 불편함)테스트가 커질 수록 또 다른 관리 포인트가 됨builder를 private 메서드로 구성파라미터에 해당 테스트에서 필요한 데이터만 받도록 구성테스트 데이터용 builder를 한 곳에 모아서 사용자바 특성 상 필요한 파라미터가 달라 새로운 builder가 계속 생겨 오히려 복잡도가 상승🚨Test Fixture 클렌징 시 유의 사항deleteAllInBatch()테이블 전체를 벌크성으로 빠르게 삭제할 수 있음외래키 제약 조건로 인해 순서를 잘 고려해야함여러 트랜잭션 경계가 참여하는 @Transactional 의 롤백 기능을 사용하기 어려워 해당 방법을 사용해야 함 deleteAll()해당 테이블을 조회하여 엔티티를 통해 delete(엔티티) 방식으로 데이터를 삭제데이터 개수만큼 쿼리가 발생 → 병목지점 유발자신과 외래키 제약 조건으로 연관된 데이터도 조회하여 삭제 📌@ParameterizedTest여러가지 값에 대한 테스트를 하나의 테스트 코드에 녹이기 위한 방법// Case 1 @ParameterizedTest @CsvSource({"HANDMADE, false", "BOTTLE, true", "BAKERY, true"}) @DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.") void containsStockType4(ProductType productType, boolean expected) { // when boolean result = ProductType.containsStockType(productType); // then assertThat(result).isEqualTo(expected); } // Case 2 private static Stream<Arguments> provideProductTypesForCheckingStockType() { return Stream.of( Arguments.of(ProductType.HANDMADE, false), Arguments.of(ProductType.BOTTLE, true), Arguments.of(ProductType.BAKERY, true) ); } @ParameterizedTest @MethodSource("provideProductTypesForCheckingStockType") @DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.") void containsStockType5(ProductType productType, boolean expected) { // when boolean result = ProductType.containsStockType(productType); // then assertThat(result).isEqualTo(expected); } 📌@DynamicTest환경을 설정하고, 환경에 변화를 중간 중간 검증을 하는 시나리오를 테스트하고싶을 때 사용@TestFactory @DisplayName("재고 차감 시나리오") Collection<DynamicTest> stockDeductionDynamicTest() { // given Stock stock = Stock.create("001", 1); return List.of( DynamicTest.dynamicTest("재고를 주어진 개수만큼 차감할 수 있다.", () -> { // given int quantity = 1; // when stock.deductQuantity(quantity); // then assertThat(stock.getQuantity()).isZero(); }), DynamicTest.dynamicTest("재고보다 많은 수의 수량으로 차감 시도하는 경우 예외가 발생한다.", () -> { // given int quantity = 1; // when // then assertThatThrownBy(() -> stock.deductQuantity(quantity)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("차감할 재고 상품이 없습니다."); }) ); }💡 List.of() 에 정의된 순서대로 테스트가 동작하며 이전 테스트의 형태를 그대로 유지하기 때문에 테스트 간 결합이 생겨 시나리오를 구성하여 테스트가 가능하다. 📌 테스트 환경 통합하기테스트 환경 통합전에는 전체 테스트(gradle → Tasks → verification → test) 수행 시 총 6번의 스프링 컨텍스트가 로딩되었다. 테스트가 많아질 수록 이는 더 많은 시간을 소모하게 되고, 테스트를 하지 않게 할 수 있다.@ActiveProfiles("test") @SpringBootTest public abstract class IntegrationTestSupport { @MockitoBean protected MailSendClient mailSendClient; } // 통합이 필요한 클래스에서 상속하여 환경 통합 class OrderServiceTest extends IntegrationTestSupportMocking을 하고 있는 클래스를 통합하려면 상위 클래스에 해당 객체를 옮겨주어야 한다. Mock객체가 없는 클래스와 있는 클래스는 다른 환경이라고 인식하여 제대로 통합이 이루어지지 않기 때문이다.이럴 경우 Mock이 필요없는 클래스에도 객체가 들어가는데 이게 싫다면, 테스트를 두 가지 환경으로 분리하여 Mock이 필요한 클래스, 필요하지 않은 클래스로 나누고 필요한 클래스의 상위 클래스에 Mock 객체를 몰아 넣는 형식으로 구성할 수 있다.@WebMvcTest(controllers = { OrderController.class, ProductController.class }) public abstract class ControllerTestSupport { @Autowired protected MockMvc mockMvc; @Autowired protected ObjectMapper objectMapper; @MockitoBean protected OrderService orderService; @MockitoBean protected ProductService productService; } class ProductControllerTest extends ControllerTestSupportController 테스트 환경 통합도 동일하게 진행하면 된다.✅환경을 통합한 후에는 총 2번의 스프링 컨텍스트가 로딩되어 테스트 수행 시간을 단축할 수 있었다. 📌Q. private 메서드의 테스트는 어떻게 하나요?private 메서드는 테스트할 필요가 없다!만약 private 메서드를 테스트 하고 싶은 때에는 “객체를 분리할 시점인가?”에 대해서 생각해봐야 한다.public 메서드(공개 API)를 가지고 있다는 것은 이를 의존하는 테스트코드, 컨트롤러 즉 클라이언트 입장에서는 공개 메서드만 알면 된다. 외부로 노출되지 않는 private 메서드에 대해서는 알 필요가 없다는 것이다. public 메서드의 테스트를 수행하면서 private 메서드의 테스트도 저절로 수행되기 때문이다.해당 private 메서드의 테스트는 꼭 필요하다고 생각되면 객체에 역할에 맞게 분리가 되지 않았을 수 있다. 그러므로 객체를 분리하여 다른 클래스로 만든 뒤 테스트를 구성하는 것이 바람직하다. 📌Q. 테스트에서만 필요한 메서드가 생겼는데 프로덕션 코드에서는 필요 없다면?만들어도 된다! 하지만 보수적으로 접근해야 한다.@Test @DisplayName("신규 상품을 등록한다.") void createProduct() throws Exception { // given ProductCreateRequest request = ProductCreateRequest.builder() .type(ProductType.HANDMADE) .sellingStatus(ProductSellingStatus.SELLING) .name("아메리카노") .price(4000) .build(); }해당 request 객체는 프로덕션 코드에서는 필요가 없다.(@RequestBody 로 외부로부터 값을 받기 때문) 하지만 테스트를 하기 위해서는 꼭 필요하다.객체로서 가져도 되는 기능(빌더, 생성자, 사이즈)이면서 미래에도 충분히 사용이 될 수 있는 기능이라면 만들어도 된다. 📌 학습 테스트?잘 모르는 기능, 라이브러리, 프레임워크를 학습하기 위해 작성하는 테스트여러 테스트 케이스를 스스로 정의하고 검증하는 과정을 통해 보다 구체적인 동작과 기능을 학습할 수 있다.관련 문서만 읽는 것보다 훨씬 재미있게 학습할 수 있다.💡사용하려는 라이브러리를 테스트 코드를 작성하면서 학습하면 팀 내에서도 공유하는 자료로도 활용할 수 있다. 📌 Spring REST Docs테스트 코드를 통한 API 문서 자동화 도구API 명세를 문서로 만들고 외부에 제공함으로써 협업을 원활하게 한다.기본적으로 AsciiDoc을 사용하여 문서를 작성한다.👍장점테스트를 통과해야 문서가 만들어진다. (신뢰도가 높다.)프로덕션 코드에 비침투적이다.👎단점코드 양이 많다.설정이 어렵다.지금까지 API 문서를 위해 Swagger를 사용했는데, Swagger는 어노테이션으로 선언만 해주면 해당 API가 검증되지 않아도, 해당 API의 명세가 생성된다. 그에 반해 Spring REST Docs는 테스트 코드가 통과해야 명세가 생성되므로 안정성을 보장하는 면에서 더 이점이 있다고 생각한다. 그렇지만 명세를 위한 코드의 양이 너무 많고, 작성하기 번거로웠다. 이 문제를 해결하기 위해 자동화할 수 있는 방법을 찾아봐야겠다.미션을 통해 배운 것들📌 Day 16각 레이어가 어떤 특징들을 가지는지 명확하게 알 수 있었고, 적합한 테스트 방법과 Classicist인지 Mockist인지 생각할 수 있는 기회를 준 좋은 미션이였습니다!해당 미션을 수행할 때까지는 Business Layer에서 통합 테스트를 수행 시 Persistence Layer의 기능은 단위 테스트로 검증했으니 Mocking하여 비즈니스 로직만 테스트하는 것이 좋다고 생각했는데, 이후 "Classicist VS. Mockist" 강의를 듣고 해당 부분에 대한 제 생각을 다시 정리할 수 있었습니다. 📌 Day 18다른 강의에서 Mock 관련 기능들을 간단히 배웠는데, 그 때까지는 Mocking을 한다는 것이 정확히 뭔지 잘 이해하지 못했습니다. 그리고 비슷한 어노테이션과 서로 다른 주입법들로 인해서 복잡하고 이해하기 힘들었습니다.이번 강의를 통해 "Mocking을 왜 해야하는지"와 각 어노테이션들을 사용하는 방법과 상황에 대해서 완벽하게 이해할 수 있었습니다. 미션을 통해 같은 Mock 객체를 생성할 때도 @Mock 을 사용해야할 때, @MockitoBean 을 사용해야할 때를 명확하게 알 수 있었고, setUp절에 배치하면 좋은 Fixture 들을 구분하는 방법도 이해할 수 있었습니다.