인프런 워밍업 클럽 백엔드 4주차 후기
후기 드디어 인프런 워밍업 클럽의 마지막 발자국을 쓰게 되었다.길다면 길고 짧다면 짧은 4주동안 두 개의 강의를 들으면서 많은 것을 느낄 수 있던 시간이었다.Readable Code에서는 추상을 통해서 메시지를 잘 만들고, 책임과 역할을 잘 분배하여 읽기 좋은 코드를 만들 수 있도록 노력해야 겠음을 느꼈고, Practical Test에서는 좋은 테스트 코드를 만들어서 사람이 노가다 하는 비중을 최대한 줄이면서, 안정적인 코드를만들어야겠음을 느꼈다.개발이라는 것은 나혼자만이 아닌 팀 단위로 움직이는 것이기 때문에 이를 팀 레벨로 이끌고 싶은 욕구가 생겼다. 그러기 위해선 내가 그를 증명할 수 있는 탄탄하고 좋은 실력을 갖추도록 노력해야겠다. 이번 인프러너의 좋은 강의들을 재반복하면서 나의 지식으로 습득하고, 이를 다른 사람들에게 전수할 수 있는 그런 사람이 되도록 해야겠다.즐거운 기회를 마련해준 우빈님과 인프런에게 감사를 표하며 글을 마치겠다! ㅎㅎ 내용 정리Presentation Layer 테스트(1)외부 세계의 요청을 가장 먼저 받는 계층파라미터에 대한 최소한의 검증을 수행한다Mock (가짜, 대역) 객체테스트 시 의존관계 주입이 방해될 때, 가짜를 집어넣어 처리함MockMvcMock(가짜) 객체를 사용해 스프링 MVC 동작을 재현할 수 있는 테스트 프레임워크 @Transactional(readOnly = true)CRUD에서 CUD가 동작 안 함오직 R만 작동JPA는 기본적으로 1차 캐시에 스냅샷을 저장하고, 트랜잭션 커밋 플러시 하는 시점에 변경 감지가 작동하여 업데이트 쿼리를 발생시킴근데 reaOnly = true를 열면 CUD 스냅샷 저장, 변경감지를 안 하여 성능 향상 효과가 있음CQRSCommand / QueryRead 작업이 거의 80%정도 주를 이룰 때가 많음그래서 command와 query 작업을 분리하여 서로 연관되지 않게끔 분리하는 CQRS 패턴을 활용조회 하는 서비스만 만들면 @Transactional(readOnly=true)를 써서 관리할 수 있음DB 엔드포인트도 구분하여 쓰기는 마스터, 읽기는 슬레이브로 보내버릴 수 있음어노테이션 보고 마스터나 슬레이브로 구분해줄 수 잇다함클래스 단위에은 readOnly를, 변경 메서드엔 @Transactional을 달자아님 객체 단위로 나눠서 관리Presentation Layer 테스트(2)Validation을 활욜String@NotBlank: 전부 다 허용 안됨@NotNull: “”, “ “는 통과됨@NotEmpty: “ “ 공백은 통과 “” 빈문자열 실패정책을 validation에서 거르는게 맞을까? 라는 고민을 해야함EX) 상품 이름은 20자 → 이런건 서비스 레이어나 프러턱트 코드 같은 안쪽에서 해도 됨상위 레이어는 하위 레이어를 알아도 되지만하위 레이어는 상위 레이어를 모르게 하는게 좋음Layered Architecture 단점DB랑 연결하기 위해 JPA 도메인 객체를 만들었는데, 너무 강결합 되는 구조가 됨깊어질 수록 바꾸기가 어려워짐 Hexagonal Architecture포트와 어댑터 형태포트를 통해 외부와 통신을 함도메인 정책이 가장 안 쪽에 있음모노레포라면 레이어드 아키텍쳐도 괜찮지만점점 커질 거 같으면 헥사고날을 고려해보자QueryDSLJPA랑 함께 많이 쓰이는 동적 쿼리 빌더타입체크를 지원해주서 컴파일단에서 체크해 안전해짐 섹션7: Mock을 마주하는 자세Mockito로 Stubbing 하기이메일 등 외부 네트워크에 전송하는 서비스 로직에는 Transactional 안 걸어두는게 좋음트랜잭션으로 DB 조회할때 커넥션을 가지고 있는데, 외부 소통 하면서 갖고 있으면 다른데서 못 가져감Test DoubleStunt Double: 스턴트 배우를 쓰는 것을 차용 → 대역을 사용Dummy: 아무 것도 하지 않는 깡통 객체Fake: 단순한 형태로 동일한 기능을 수행하나, 프로덕션에서 쓰기에는 부족한 객체 (ex. FakeRepository - 메모리 Map)Stub: 테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체. 그 외에는 응답하지 안흠Spy: Stub 이면서 호출된 내용을 기록하여 보여줄 수 있는 객체. 일부는 실제 객체처럼 동작시키고 일부만 Stubbing할 수 있다.Mock: 행위에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체Stub과 Mock이 좀 헷갈림Stub은 상태 검증 (State Verfication)Mock은 행위 검증(Behavior Verication): 메서드가 무엇을 했을 때(행위) 어떤 값을 돌려주는 느낌~Mocks Aren't Stubspublic interface MailService { void send(Message msg); } public class MailServiceStub implements MailService { private List<Message> messages = new ArrayList<>(); public void send(Message msg) { messages.add(msg); } public int numberSent() { return messages.size(); } } // Stub 검증 class OrderStateTest { @Test void testOrderSendsMailIfUnfilled() { Order order = new Order(TALISKER, 51); MailServiceStub mailer = new MailServiceStub(); order.setMailer(mailer); order.fill(wraehouse); assertEquals(1, mailer.numberSent()); // 상태에 대한 검증 } } // Mock 검증 class OrderInteractionTester { @Test void testOrderSendsMailIfUnfilled() { Order order = new Order(TALISKER, 51); Mock warehouse = mock(Warehouse.class); Mock mailer = mock(MailService.class); order.setMailer((MailService) mailer.proxy()); mailer.expects(once()).method("send"); // 메서드가 한 번 불림. 행위 확인 warehouse.expects(once()).method("hasInventory") .withAnyArguments() .will(returnValue(false)); order.fill((Warehouse) warehouse.proxy()); } } @Mock, @Spy, @InjectMocks순수 mockito로 검증하기BDDMockitoClassicist VS MockistMockist: 모든 걸 Mocking 처리해서 하자Classicist: 모킹만 해서 실 프로덕션 환경을 다 커버하긴 어렵다필요한 경우엔 실제를 쓰다가 필요하면 Mockito를 쓰자강사님 (Classcisit)Presentaion은 외부에서 오는 값만 검증하고, 하위 레벨은 Mocking외부 시스템은 우리에게 제어권이 없기에 이런 경우 (외부 계를 나누자)비용이 조금 더 들더라도 실제 객체를 갖고와 테스트를 하는게 안전하지 않을까~키워드 정리Bean이 들어가면 스프링 환경에서이니, 단위 테스트로만 Mocking하고 싶으면 Bean이 안 들어간걸 쓰자섹션8: 더 나은 테스트를 작성하기 위한 구체적 조언한 문단에 한 주제!글쓰기에서 요지는 한 문단엔 한 주제만을 넣음테스트도 문서로써의 기능을 함테스트 코드를 글 쓰기의 관점에서 하나의 테스트도 하나의 문단으로 보고, 그에 맞게 처리하자void containsStockTypeEx() { // given ProductType[] productTypes = ProductType.values(); for (ProductType productType: productTypes) { if (productType == ProductType.HANNDMADE) { // 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(); } } 위와 같이 if 같은 분기문이 있으면 두 가지 이상의 케이스를 구분하겠단 의미반복문의 경우에도 테스트 코드를 읽는 사람이 생각을 해야함DisplayName을 한 문장으로 만들게끔 해서 하나의 케이스만 처리하게끔 하자완벽하게 제어하기아래와 같이 시간, 랜덤값 같이 내가 제어할 수 없는 값은 외부에서 주입할 수 있도록 해야함public Order createOrder() { LocalDateTime currentDateTime = LocalDateTime.now(); final LocalTime currentTime = currentDateTime.toLocalTime(); if (currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) { throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요."); } return new Order(LocalDateTime.now(), beverages); } // 외부에서 주입 public Order createOrder(LocalDateTime currentDateTime) { final LocalTime currentTime = currentDateTime.toLocalTime(); if (currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) { throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요."); } return new Order(LocalDateTime.now(), beverages); } 그렇다고 LocalDateTime registeredDateTime = LocalDateTime.now() 테스트 코드에서 현재 시각을 주는 메서드를 막 쓰지는 말자 (환경마다 다를 수 있으니)시각을 LocalDateTime.of() 같은 시각을 지정해줄 수 있도록 원칙을 만드는 것도 좋음테스트 환경의 독립성을 보장하자@DisplayName("재고가 부족한 상품으로 주문을 생성하려는 경우 예외가 발생한다.") @Test void createOrderWithNoStock() { // given final LocalDateTime registeredDateTime = LocalDateTime.now(); Product product1 = createProduct(BOTTLE, "001", 1000); Product product2 = createProduct(BAKERY, "002", 3000); Product product3 = createProduct(HANDMADE, "003", 5000); productRepository.saveAll(List.of(product1, product2, product3)); Stock stock1 = Stock.create("001", 2); Stock stock2 = Stock.create("002", 2); stock1.deductQuantity(1); // todo stockRepository.saveAll(List.of(stock1, stock2)); OrderCreateRequest request = OrderCreateRequest.builder() .productNumbers(List.of("001", "001", "002", "003")) .build(); // when // then assertThatThrownBy(() -> orderService.createOrder(request.toServiceRequest(), registeredDateTime)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("재고가 부족한 상품이 있습니다."); } deductQuantity 부분으로 인해 논리적으로 한 번 더 생각해서 테스트를 짜게 된다테스트는 when, then에 집중해야는데, given도 보면서 혼란스러워짐given은 순수 생성자 기반의 값을 주는게 좋음 or Builder팩토리 메서드도 지양하는게 좋음 (팩토리 메서드 내에 의도를 가지고 뭔가 로직이 있을 수 있기에)테스트 간 독립성을 보장하자class StockTest { private static final Stock stock = Stock.create("001", 1); @DisplayName("재고의 수량이 제공된 수량보다 작은지 확인한다.") @Test void isQuantityLessThanEx() { // given int quantity = 2; // when boolean result = stock.isQuantityLessThan(quantity); // then assertThat(result).isTrue(); } @DisplayName("재고를 주어진 개수만큼 차감할 수 있다.") @Test void deductQuantityEx() { // given int quantity = 1; // when stock.deductQuanaity(quantity); // then assertThat(stock.getQuanaity()).isZero(); } } 두 가지 테스트가 static 변수 같은 공유 자원을 활용하고 있음공유 자원의 값이 변경되면 다른 테스트 결과에 영향이 생김테스트 수행 순서는 랜덤하기에 독립적으로 항상 올바를 테스트가 되도록 해줘야함DynamicTest를 쓰면 지정해줄 수 있나봄한 눈에 들어오는 Test Fixture 구성하기Fixture: 고정물, 고정되어 있는 물체테스트를 위해 원하는 상태로 고정시킨 일련의 객체활용 어노테이션@BeforeAll@BeforeEach@AfterAll@AfterEach픽스처를 통해 공통의 테스트 객체를 만들면 하나의 테스트를 바꿀 때 모두 영향이 끼치기에 지양하는 것이 좋음테스트클래스가 엄청 길어진 경우 given 절을 보는데 문맥을 기억하기 어려워짐 (→ 문서 파악이 어려워짐)사용해도 괜찮을 때각 테스트 입장에서 봤을 때 : 아예 몰라도 테스트 내용을 이해하는 데에 문제가 없는 경우에수정해도 모든 테스트에 영향을 주지 않는가data.sql에서 given 데이터를 만들 수 있겠지만, 데이터 파편화가 될 가능성이 큼 (비추)테이블도 많아지고, 사이즈가 커질 수록 관리가 어려워짐생성 메서드를 만들 때 테스트에 필요한 파라미터만 받아서 쓰자name 필드가 필요없으면 파라미터에서 빼버림테스트 패키지 전체에서 사용하는 추상 클래스를 만들어 픽스처 빌더들을 모아 쓸 순 있겠지만 NO 추천파라미터가 엄청 많아질 때 마다 내가 필요한 빌더들이 생기고, 관리가 더 안 될 수도 있음코틀린을 사용하면 롬복도 필요없고, 빌더도 필요없음. 기본값을 지정해줄 수 있음Text Fixture 클렌징deleteAll: select 해서 건건이 where id 조회해서 지움연관 관계 걸려있는 것도 찾아서 같이 지어줌전체 찾아서 순회해서 지워주고 있음삭제도 일종의 비용이기 때문에 테스트에서 오래걸리면 좀 그럴 수 있음@Transactional public void deleteAll() { Iterator var2 = this.findAll().iterator(); while(var2.hasNext()) { T element = (Object)var2.next(); this.delete(element); } } deleteAllInBatch전체 삭제 쿼리를 만들어서 지어줌 → 벌크성으로 지워줌@Transactional public void deleteAllInBatch() { Query query = this.entityManager.createQuery(this.getDeleteAllQueryString()); this.applyQueryHints(query); query.executeUpdate(); } private String getDeleteAllQueryString() { return QueryUtils.getQueryString("delete from %s x", this.entityInformation.getEntityName()); } @Transactional을 쓰면 되는데, 사이드 이펙트를 잘 고려해서 진행해야함@ParameterizedTest반복되는 given에 대해서 위 어노테이션을 활용해서 반복해서 할당할 수 있다.@DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.") @CsvSource({ "HANDMADE, false", "BOTTLE, true", "BAKERY, true" }) @ParameterizedTest void containsStockType4(ProductType productType, boolean expected) { // when boolean result = ProductType.containsStockType(productType); // then assertThat(result).isEqualTo(expected); } private static Stream<Arguments> provideProductTypesForCheckingStockType() { return Stream.of( Arguments.of(ProductType.HANDMADE, false), Arguments.of(ProductType.BOTTLE, true), Arguments.of(ProductType.BAKERY, true) ); } @DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.") @MethodSource("provideProductTypesForCheckingStockType") @ParameterizedTest void containsStockType5(ProductType productType, boolean expected) { // when boolean result = ProductType.containsStockType(productType); // then assertThat(result).isEqualTo(expected); } @DynamicTest공통의 값으로 단계 별로 테스트를 돌리고 싶으면 사용한다@DisplayName("") @TestFactory Collection<DynamicTest> dynamicTest() { return List.of( DynamicTest.dynamicTest("", () -> {}), DynamicTest.dynamicTest("", () -> {}) ); } @DisplayName("재고 차감 시나리오") @TestFactory 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("차감할 재고 수량이 없습니다."); }) ); } 테스트 수행도 비용이다. 환경 통합하기테스트를 작성하는 이유는 사람이 수동으로 돌리는 비용보다 기계에 맡겨서 피드백을 빨리 받게 하기 위함테스트의 속도가 빨라야 유의미하기에 비용관리를 해야함서버 띄우는 경우가 많아지면 서버가 오래 걸리게 됨이런 비용을 관리해야함@MockBean 의 경우에도 서버를 새로 띄어야됨@WebMvcTest(controllers = { OrderController.class, ProductController.class }) public abstract class ControllerTestSupport { @Autowired protected MockMvc mockMvc; @Autowired protected ObjectMapper objectMapper; @MockBean protected OrderService orderService; @MockBean // Mock 객체 만들어줌 protected ProductService productService; } @ActiveProfiles("test") @SpringBootTest public abstract class IntegrationTestSupport { @MockBean protected MailSendClient mailSendClient; } Q. private 메서드의 테스트는 어떻게 하나요?하려고 해서도 안 되고 할 필요가 없다.클라이언트(외부)는 공개된 API만 알면 되기에, private을 알 필요 없다만약 private 메서드를 단독으로 빼서 테스트하고 싶다면, 객체를 분리할 시점인가를 고민해봐야 한다필요하다면 새로운 객체를 만들어 책임을 위임하면 된다Q. 테스트에서만 필요한 메서드가 생겼는데 프로덕션 코드에서는 필요 없다면?만들어도 됨. 하지만 보수적으로 접근하기테스트에서 조회성을 위해 만드는 거 정돈 괜찮지만, 테스트에서만 사용되는 메서드를 막 만드는 것은 지양해야함getter, 생성자, 생성자 빌더, 사이즈 등 객체가 마땅히 가져도 되는 행위라 생각되고 미래에 충분히 사용되는 것들은 만들어도 괜찮다~ 학습 테스트잘 모르는 기능, 라이브러리, 프레임워크를 학습하기 위해 작성하는 테스트여러 테스트 케이스를 스스로 정의하고 검증하는 과정을 통해 보다 구체적인 동작과 기능을 학습할 수 있다.관련 문서만 읽는 것보다 훨씬 재미있게 학습할 수 있다.Spring REST Docs테스트 코드를 통한 API 문서 자동화 도구API 명세를 문서로 만들고 외부에 제공함으로써 협업을 원할하게 한다기본적으로 AsciiDoc을 사용하여 문서를 작성한다REST Docs VS SWAGGERREST DOCS장점테스트를 통과해야 문서가 만들어진다 (신뢰도가 높음)프로덕션 코드에 비침투적임단점코드 양이 많다.설정이 어렵다SWAGGER장점적용이 쉬움문서에서 바로 API 호출을 수행해볼 수 있다.단점프로덕션 코드에 침투적이다테스트와 무관하기 때문에 신뢰도가 떨어질 수 있음