묻고 답해요
141만명의 커뮤니티!! 함께 토론해봐요.
인프런 TOP Writers
-
미해결Practical Testing: 실용적인 테스트 가이드
OrderRepository에 @Repository
안녕하세요JPARepository에는 @Repository 를 안 붙여도정상 작동하는 걸로 알고 있는데따로 붙이신 이유가 있으실까요?
-
미해결Practical Testing: 실용적인 테스트 가이드
코틀린 관련 질문!
강의에서 코틀린을 간간히 언급을 해주시는데, 실무에서는 코틀린을 사용중이신건지 궁금해서 글을 남기게 되었습니다! 코틀린을 사용하신다면, 어떤 상황에서는 자바를 사용하고, 어떤 상황에서는 코틀린을 사용하면 좋을지 추천해주시면 좋을 것 같아서 질문을 남겨봅니다! 강의 너무 잘보고 있습니다 감사합니다!
-
미해결Practical Testing: 실용적인 테스트 가이드
테스트가 무시되는 상황이 일어납니다.
안녕하세요!수업과 최대한 유사하게 코드를 작성해왔는데,테스트가 무시되는 상황이 발생하여 질문 올립니다. 에러메세지대로 @SpringBootConfiguration도 추가해보고,구글링도 해봤는데 해결이 되지 않아 질문 올립니다 죄송합니다. 테스트가 무시되었습니다.java.lang.IllegalStateException: Unable to find a @SpringBootConfiguration, you need to use @ContextConfiguration or @SpringBootTest(classes=...) with your test at org.springframework.util.Assert.state(Assert.java:76) at org.springframework.boot.test.context.SpringBootTestContextBootstrapper.getOrFindConfigurationClasses(SpringBootTestContextBootstrapper.java:237) at org.springframework.boot.test.context.SpringBootTestContextBootstrapper.processMergedContextConfiguration(SpringBootTestContextBootstrapper.java:152) at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:393) at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildDefaultMergedContextConfiguration(AbstractTestContextBootstrapper.java:309) at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:262) at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildTestContext(AbstractTestContextBootstrapper.java:107) at org.springframework.boot.test.context.SpringBootTestContextBootstrapper.buildTestContext(SpringBootTestContextBootstrapper.java:102) at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:137) at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:122) at org.junit.jupiter.engine.execution.ExtensionValuesStore.lambda$getOrComputeIfAbsent$4(ExtensionValuesStore.java:86) at org.junit.jupiter.engine.execution.ExtensionValuesStore$MemoizingSupplier.computeValue(ExtensionValuesStore.java:223) at org.junit.jupiter.engine.execution.ExtensionValuesStore$MemoizingSupplier.get(ExtensionValuesStore.java:211) at org.junit.jupiter.engine.execution.ExtensionValuesStore$StoredValue.evaluate(ExtensionValuesStore.java:191) at org.junit.jupiter.engine.execution.ExtensionValuesStore$StoredValue.access$100(ExtensionValuesStore.java:171) at org.junit.jupiter.engine.execution.ExtensionValuesStore.getOrComputeIfAbsent(ExtensionValuesStore.java:89) at org.junit.jupiter.engine.execution.ExtensionValuesStore.getOrComputeIfAbsent(ExtensionValuesStore.java:93) at org.junit.jupiter.engine.execution.NamespaceAwareStore.getOrComputeIfAbsent(NamespaceAwareStore.java:61) at org.springframework.test.context.junit.jupiter.SpringExtension.getTestContextManager(SpringExtension.java:294) at org.springframework.test.context.junit.jupiter.SpringExtension.beforeAll(SpringExtension.java:113) at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.lambda$invokeBeforeAllCallbacks$10(ClassBasedTestDescriptor.java:381) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.invokeBeforeAllCallbacks(ClassBasedTestDescriptor.java:381) at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.before(ClassBasedTestDescriptor.java:205) at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.before(ClassBasedTestDescriptor.java:80) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:148) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) at java.base/java.util.ArrayList.forEach(ArrayList.java:1511) at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:35) at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57) at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:54) at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:107) at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:88) at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:54) at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:67) at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:52) at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114) at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:86) at org.junit.platform.launcher.core.DefaultLauncherSession$DelegatingLauncher.execute(DefaultLauncherSession.java:86) at org.junit.platform.launcher.core.SessionPerRequestLauncher.execute(SessionPerRequestLauncher.java:53) at com.intellij.junit5.JUnit5IdeaTestRunner.startRunnerWithArgs(JUnit5IdeaTestRunner.java:57) at com.intellij.rt.junit.IdeaTestRunner$Repeater$1.execute(IdeaTestRunner.java:38) at com.intellij.rt.execution.junit.TestsRepeater.repeat(TestsRepeater.java:11) at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:35) at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:232) at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:55)종료 코드 255(으)로 완료된 프로세스
-
미해결Practical Testing: 실용적인 테스트 가이드
캐시를 포함한 Service Layer 테스트 가이드
안녕하세요. 우빈님!캐시를 처음 적용해 보아서 캐시를 포함한 비지니스 로직을 어떻게 작성하는 것이 좋을까? 에 대한 고민이 있어 글 남깁니다...! AS IS@Transactional public UosRestaurantMenuResponse getUosRestaurantMenu(UosRestaurantInput input) { // 학식 조회 UosRestaurant findUosRestaurant = uosRestaurantRepository.findByCrawlingDateAndRestaurantNameAndMealType(input.getDate(), input.getRestaurantName(), input.getMealType()) .orElseThrow(() -> new UosRestaurantMenuException(UosRestaurantMenuException.NOT_FOUND_MENU)); // 조회수 증가 findUosRestaurant.increaseView(); return UosRestaurantMenuResponse.of(findUosRestaurant); } TO BE@Transactional public UosRestaurantMenuResponse getUosRestaurantMenu(UosRestaurantInput input) { // 캐시에서 학식 조회 Optional<CacheUosRestaurant> cacheUosRestaurant = cacheUosRestaurantRepository .findById(CacheUosRestaurant.createId(input)); // 캐시에 학식이 존재하면 if(cacheUosRestaurant.isPresent()) { // 조회수 증가 cacheUosRestaurant.get().increaseView(); CacheUosRestaurant saveCacheRestaurant = cacheUosRestaurantRepository.save(cacheUosRestaurant.get()); return UosRestaurantMenuResponse.of(saveCacheRestaurant); } // 학식 조회 UosRestaurant findUosRestaurant = uosRestaurantRepository.findByCrawlingDateAndRestaurantNameAndMealType(input.getDate(), input.getRestaurantName(), input.getMealType()) .orElseThrow(() -> new UosRestaurantMenuException(UosRestaurantMenuException.NOT_FOUND_MENU)); // 조회수 증가 findUosRestaurant.increaseView(); // 캐시에 저장 cacheUosRestaurantRepository.save(CacheUosRestaurant.of(findUosRestaurant)); return UosRestaurantMenuResponse.of(findUosRestaurant); } 캐시에 대한 로직이 추가될 때 위와 같이 하나의 서비스 레이어 메소드에 작성하는 것이 좋은걸까요?캐시에 대한 테스트 코드를 작성할 때 캐시를 사용할 때와 캐시를 사용하지 않을 때를 상황을 구분하여 작성하는 것이 맞을까요..?(그것이 좋겠죠....? -> 기존 비지니스 로직 테스트 코드를 수정하는것이 최선일까? 에대한 의문이 들어서 질문 드렸습니다.)스프링에서 CacheManager를 이용하여 @Cacheable 을 활용하는 방법이 있는데, CrudRepository를 사용하는 방법과 CacheManager를 사용하는 방법 중 어느 것이 더 좋은(?) 방법인지 말씀주시면 감사하겠습니다.4. 마지막으로 캐시를 사용할 때 깔끔하게 비지니스 로직을 작성할 수 있는 노하우 말씀주시면 감사하겠습니다.!!!다소 질문이 난해한데 너그럽게 이해해주시면 감사하겠습니다.ㅠㅠ좋은 강의 잘 듣고 있습니다. 다음 강의도 기대할께요^^!!감사합니다.
-
미해결Practical Testing: 실용적인 테스트 가이드
@Transactional을 붙였을 때"만" Stock 감소 검증에 성공합니다
각 테스트 케이스들을 검증하고, 이에 대해 값을 비워주기 위해 두 가지 방식을 소개해주셨습니다.1) tearDown()을 사용해서 after each로 모두 날려주는 방식과2) 클래스 레벨에서 테스트 클래스에 @Transactional을 붙이는 방식 강의와 동일하게 "001" : 1000 : 2개, "002" : 3000 : 2개, "003" : 5000 가정 하에, "001", "001", "002", "003" 순으로 주문했다 하였습니다. 하지만, 문제는1)을 사용하였을 때는 아래와 같은 에러가 발생하구요, 2)을 사용했을 때는 테스트 케이스가 정상 검증 됩니다.[Extracted: productNumbeㅏr, quantity] Expecting actual: [("001", 2), ("002", 2)] to contain exactly in any order: [("001", 0), ("002", 1)] elements not found: [("001", 0), ("002", 1)] and elements not expected: [("001", 2), ("002", 2)] 올려주신 깃허브 코드 5-7 또한 참고해보았으며, 코드 복붙까지 시도했는데 AfterEach 방식에서만 검증 실패가 등장하네요// 보여주신 디버깅 방식 참조하여 디버깅도 해보았는데, 제 눈에는,,, 이상이 없었습니다... 미천한 디버깅 실력에 부끄러움만 앞서지만, 왜 트랜잭션 어노테이션에서만 검증 성공인지 의아합니다.혹시 답변 가능하시다면 부탁드립니다,,! 코드 첨부합니다.OrderServicepackage sample.cafekiosk.api.service.order; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import sample.cafekiosk.api.controller.order.OrderCreateRequest; import sample.cafekiosk.domain.order.Order; import sample.cafekiosk.domain.product.Product; import sample.cafekiosk.domain.product.ProductRepository; import sample.cafekiosk.domain.product.ProductType; import sample.cafekiosk.domain.stock.Stock; import sample.cafekiosk.domain.stock.StockRepository; import java.time.LocalDateTime; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class OrderService { // Product를 가져와야 하므로 의존 private final ProductRepository productRepository; private final OrderRepository orderRepository; private final StockRepository stockRepository; public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime) { List<String> productNumbers = request.getProductNumbers(); List<Product> products = findProductsBy(productNumbers); deductStockQuantities(products); Order order = Order.create(products, registeredDateTime); Order savedOrder = orderRepository.save(order); return OrderResponse.of(savedOrder); } private void deductStockQuantities(List<Product> products) { List<String> stockProductNumbers = extractStockProductNumbers(products); Map<String, Stock> stockMap = createStockMapBy(stockProductNumbers); Map<String, Long> productCountingMap = createCountingMapBy(stockProductNumbers); for (String stockProductNumber : new HashSet<>(stockProductNumbers)) { Stock stock = stockMap.get(stockProductNumber); int quantity = productCountingMap.get(stockProductNumber).intValue(); if (stock.isQuantityLessThan(quantity)) { throw new IllegalArgumentException("재고가 부족한 상품이 있습니다."); } stock.deductQuantity(quantity); } } private List<Product> findProductsBy(List<String> productNumbers) { List<Product> products = productRepository.findAllByProductNumberIn(productNumbers); Map<String, Product> productMap = products.stream() .collect(Collectors.toMap(Product::getProductNumber, p -> p)); return productNumbers.stream() .map(productMap::get) .collect(Collectors.toList()); } private static List<String> extractStockProductNumbers(List<Product> products) { return products.stream() .filter(product -> ProductType.containsStockType(product.getType())) .map(Product::getProductNumber) .collect(Collectors.toList()); } private Map<String, Stock> createStockMapBy(List<String> stockProductNumbers) { List<Stock> stocks = stockRepository.findAllByProductNumberIn(stockProductNumbers); return stocks.stream() .collect(Collectors.toMap(Stock::getProductNumber, s -> s)); } private static Map<String, Long> createCountingMapBy(List<String> stockProductNumbers) { return stockProductNumbers.stream() .collect(Collectors.groupingBy(p -> p, Collectors.counting())); } } OrderServiceTestpackage sample.cafekiosk.api.service.order; import jakarta.transaction.Transactional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.web.bind.annotation.RestController; import sample.cafekiosk.api.controller.order.OrderCreateRequest; import sample.cafekiosk.domain.product.Product; import sample.cafekiosk.domain.product.ProductRepository; import sample.cafekiosk.domain.product.ProductSellingStatus; import sample.cafekiosk.domain.product.ProductType; import sample.cafekiosk.domain.stock.Stock; import sample.cafekiosk.domain.stock.StockRepository; import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.groups.Tuple.tuple; import static org.junit.jupiter.api.Assertions.*; import static sample.cafekiosk.domain.product.ProductSellingStatus.SELLING; import static sample.cafekiosk.domain.product.ProductType.*; @ActiveProfiles("test") @SpringBootTest //@Transactional class OrderServiceTest { @Autowired private OrderService orderService; @Autowired private ProductRepository productRepository; @Autowired private OrderProductRepository orderProductRepository; @Autowired private OrderRepository orderRepository; @Autowired private StockRepository stockRepository; @AfterEach void tearDown() { orderProductRepository.deleteAllInBatch(); productRepository.deleteAllInBatch(); orderRepository.deleteAllInBatch(); stockRepository.deleteAllInBatch(); } @DisplayName("재고와 관련된 상품이 포함되어 있는 주문번호 리스트를 받아 주문을 생성한다.") @Test void createOrderWithStock() { // given 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); stockRepository.saveAll(List.of(stock1, stock2)); OrderCreateRequest request = OrderCreateRequest.builder() .productNumbers(List.of("001", "001", "002", "003")) .build(); // when OrderResponse orderResponse = orderService.createOrder(request, registeredDateTime); // then assertThat(orderResponse.getId()).isNotNull(); assertThat(orderResponse) .extracting("registeredDateTime", "totalPrice") .contains(registeredDateTime, 10000); assertThat(orderResponse.getProducts()).hasSize(4) .extracting("productNumber", "price") .containsExactlyInAnyOrder( tuple("001", 1000), tuple("001", 1000), tuple("002", 3000), tuple("003", 5000) ); List<Stock> stocks = stockRepository.findAll(); assertThat(stocks).hasSize(2) .extracting("productNumber", "quantity") .containsExactlyInAnyOrder( tuple("001", 0), tuple("002", 1) ); } @DisplayName("주문 번호리스트를 받아 주문을 생성한다.") @Test void createOrder() { // GIVEN LocalDateTime n = LocalDateTime.now(); Product product1 = createProduct(HANDMADE, "001", 1000); Product product2 = createProduct(HANDMADE, "002", 2000); Product product3 = createProduct(HANDMADE, "003", 3000); productRepository.saveAll(List.of(product1, product2, product3)); OrderCreateRequest request = OrderCreateRequest.builder() .productNumbers(List.of("001","002")) .build(); // WHEN OrderResponse orderResponse = orderService.createOrder(request,n ); // THEN assertThat(orderResponse.getId()).isNotNull(); assertThat(orderResponse) .extracting("registeredDateTime","totalPrice") .containsExactlyInAnyOrder(n,3000); assertThat(orderResponse.getProducts()).hasSize(2) .extracting("productNumber", "price") .containsExactlyInAnyOrder( tuple("001", 1000), tuple("002", 2000) ); } @DisplayName("중복되는 상품번호 리스트로 주문을 생성할 수 있다.") @Test void createOrderWiuthDuplicatedProductNumbers() { // GIVEN LocalDateTime n = LocalDateTime.now(); Product product1 = createProduct(HANDMADE, "001", 1000); Product product2 = createProduct(HANDMADE, "002", 2000); Product product3 = createProduct(HANDMADE, "003", 3000); productRepository.saveAll(List.of(product1, product2, product3)); OrderCreateRequest request = OrderCreateRequest.builder() .productNumbers(List.of("001","001")) .build(); // WHEN OrderResponse orderResponse = orderService.createOrder(request,n ); // THEN assertThat(orderResponse.getId()).isNotNull(); assertThat(orderResponse) .extracting("registeredDateTime","totalPrice") .containsExactlyInAnyOrder(n,2000); assertThat(orderResponse.getProducts()).hasSize(2) .extracting("productNumber", "price") .containsExactlyInAnyOrder( tuple("001", 1000), tuple("001", 1000) ); } private Product createProduct(ProductType type, String productNumber,int price) { return Product.builder() .productNumber(productNumber) .type(type) .sellingStatus(SELLING) .name("아메리카노") .price(price) .build(); } }Stockpackage sample.cafekiosk.domain.stock; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import sample.cafekiosk.domain.BaseEntity; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity public class Stock extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id; private String productNumber; private int quantity; @Builder public Stock(String productNumber, int quantity) { this.productNumber = productNumber; this.quantity = quantity; } public static Stock create(String s, int i) { return Stock.builder().productNumber(s).quantity(i).build(); } public boolean isQuantityLessThan(int quantity) { return this.quantity < quantity; } public void deductQuantity(int quantity) { if(this.quantity < quantity){ throw new IllegalArgumentException("주문 수량이 재고보다 많습니다."); } this.quantity-=quantity; } }
-
미해결Practical Testing: 실용적인 테스트 가이드
ControllerTestSupport 관련 질문입니다.
안녕하세요 강사님현재 회사에서 진행중인 프로젝트에서 ControllerTestSupport를 생성하여 controllerTest시 스프링부트를 띄우는 횟수를 줄이기 위해 사용하였는데 테스트 속도가 줄어들거나 하지 않고 거의 똑같더라구요,, 그래도 스프링부트를 띄우는걸 최소화 하는게 나을까요? ControllerTestSupport 사용해야하는 더 나은 이점이 있는지 궁금합니다!
-
해결됨Practical Testing: 실용적인 테스트 가이드
컨트롤러 테스트의 Mock을 통한 Stubbing 관련 질문
안녕하세요. 우빈님!먼저 우빈님의 테스트 강의가 너무나 재미있어, 테스트에 대한 막연한 지식을 구체화하고 테스트 작성 열망을 크게 키울 수 있어서 감사하다는 말씀을 드리고 싶습니다!다름이 아니라, Controller 테스트를 위해 @WebMvcTest를 통해 Service와 Repository를 Mocking하여 단위 테스트의 형식으로 작성한다는 것을 배웠습니다. 이렇게 배운 것을 사이드 프로젝트에 적용해보며, 의문점이 생겼는데 능력 부족으로 인해 의문이 해결되지 않아 질문을 드리려 합니다. 상황강의에서 작성한 컨트롤러 테스트 중 일부인 OrderControllerTest의 테스트 메서드는 다음과 같습니다.@DisplayName("신규 주문을 등록한다.") @Test void createOrder() throws Exception { // given OrderCreateRequest request = OrderCreateRequest.builder() .productNumbers(List.of("001")) .build(); // when & then mockMvc.perform( post("/api/v1/orders/new") .content(objectMapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON) ) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("200")) .andExpect(jsonPath("$.status").value("OK")) .andExpect(jsonPath("$.message").value("OK")); }또한, 해당 메서드의 실행 로그를 보면 MockHttpServletResponse이 다음과 같다는 것을 볼 수 있었습니다.MockHttpServletResponse: Status = 200 Error message = null Headers = [Content-Type:"application/json"] Content type = application/json Body = {"code":200,"status":"OK","message":"OK","data":null} Forwarded URL = null Redirected URL = null Cookies = [] 이후, 저의 사이드 프로젝트의 컨트롤러 테스트를 위와 동일한 방식으로 작성하였지만, 아래와 같이 MockHttpServletResponse의 Body가 빈 채로 응답이 되어 테스트가 실패하게 되는 문제가 발생했습니다.MockHttpServletResponse: Status = 200 Error message = null Headers = [X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"] Content type = null Body = Forwarded URL = null Redirected URL = null Cookies = []이때, given()을 통해 Service에 대한 행위를 Stubbing해주니 정상적으로 테스트가 성공하는 것을 확인했습니다. 문제 파악을 위해 조사한 결과 추측되는 차이점은 다음과 같습니다.강사님의 프로젝트에서 Controller의 응답 타입은 커스텀 응답 객체인 ApiResponse를 사용합니다.저의 사이드 프로젝트에서 Controller의 응답 타입은 HttpEntity를 상속하는 ResponseEntity를 사용합니다. 질문Q1. 강사님의 코드를 보면, 아래와 같이 createOrder 메서드에 대한 Stubbing 없이도 정상적으로MockHttpServletResponse의 Body가 응답되어 테스트가 성공합니다.// given ... given(orderService.createOrder(any())) .willReturn(OrderResponse.builder() ... // 생략 .build() ); // 없어도 테스트는 성공한다.저는 given() 절에 @MockBean을 통해 Mock 객체로 설정한 OrderService가 어떤 행위를 해야할지 Stubbing 해주어야 하는 것으로 이해하고 있었는데, 어떻게 Stubbing 없이 Body가 정상적으로 채워져 테스트가 성공한 것인지 궁금합니다. Q2. Q1과 연관하여 강사님의 코드에서는 ApiResponse라는 커스텀한 응답 객체를 컨트롤러 메서드의 응답으로 사용하는데, 제 사이드 프로젝트에서의 응답 타입은 ResponseEntity를 사용하고 있습니다. 이 차이 때문에 발생하는 문제인지 궁금합니다. Q3. 이번 의문점을 통해 컨트롤러 테스트에서 메서드의 행위에 대한 기댓값을 Stubbing하여 검증하는 것이 일종의 답정너(?)와 같은 테스트를 작성하는 것은 아닐까? 라는 생각과 함께, 컨트롤러 테스트 방식에 많은 고민을 해야 하겠다는 다짐을 하게 되었습니다. 이에 대한 우빈님의 생각은 어떠하신지 궁금합니다. 질문이 수준이 다소 떨어지지만, 이 의문점을 해결하고 싶은 마음에 장황하게 나열할 수 밖에 없었음을 양해 부탁드립니다.답변 기다리겠습니다. 감사합니다!
-
미해결Practical Testing: 실용적인 테스트 가이드
@NotNull 관련 질문 드립니다.
안녕하세요.먼저, 강의 잘 듣고 있습니다. 강의 따라하던 중 아래 이슈가 발생해서 문의 드립니다. Q1. @NotNull 지정 시,Not-null fields must be initialized라고 경고창이 뜨며.. Q2. 테스트를 진행하면 아래와 같이 메세지 부분에기대했던 값 외에.. 추가 적인 스트링이 들어가서 테스트 성공이 되지 않는 것 같습니다.Body = {"code":400,"status":"BAD_REQUEST","message":"Field error in object 'productCreateRequest' on field 'type': rejected value [null]; codes [NotNull.productCreateRequest.type,NotNull.type,NotNull.sample.cafekiosk.spring.domain.product.ProductType,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [productCreateRequest.type,type]; arguments []; default message [type]]; default message [상품 타입은 필수입니다.]","data":null} 그 외에 @NotBlank, @Postivie 도 유사한 에러가 발생하고 있습니다. Expected :상품 가격은 양수여야 합니다.Actual :Field error in object 'productCreateRequest' on field 'price': rejected value [0]; codes [Positive.productCreateRequest.price,Positive.price,Positive.int,Positive]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes ... Q2 의 경우 하여.. 우선은 아래와 같이 문자열을 포함하는지로 수정해서 테스트는 넘어갔습니다..andExpect(jsonPath("$.message", "상품 가격은 양수여야 합니다.").exists()) Q1 내용은 어떻게 수정하면 된느지 궁금합니다. 확인 부탁드립니다~
-
미해결Practical Testing: 실용적인 테스트 가이드
궁금한게 있습니다.
언제 @MockBean 쓰고@Mock + @InjectMocks 을 이용하나요?? @MockBean은 자동으로 DI해주니 @MockBean 쓰는게 낫지 않나요?? 제가 설명해주신걸 놓친거 같은데..다시한번 설명 부탁드리겠습니다.미리 감사드릴게요.
-
미해결Practical Testing: 실용적인 테스트 가이드
강의 내용 블로그 포스팅 문의 드립니다.
안녕하세요 강사님, 다름이 아니라 강의 내용을 블로그(벨로그) 글로 정리해서 기록해도 될까 여쭤봅니다. 코드, 내용을 사용하고 싶은데 허락해주실까요? ㅠ 출처글을 항상 남기겠습니다.
-
미해결Practical Testing: 실용적인 테스트 가이드
공통관심사 테스트 가이드
안녕하세요. 우빈님질문이 있습니다! AOP, Interceptor... 와 같은 공통 관심사를 테스트할 때는 보통 어떤 전략을 갖고 테스트 코드를 작성하나요?예를 들어 아래와 같은 요구사항이 있다고 가정합니다.22시 이후에는 모든 요청에 대해서 예외를 발생시킨다. 저라면 모든 Controller에 check22hours()라는 로직을 AOP를 사용하여 적용할 것 같은데요.이때 순수하게 check22hours()라는 단위 테스트만 작성하면 되는 것 일까요? 아니면 AOP 자체가 의도한대로 동작하는지 테스트 코드를 작성하는 것이 바람직한 것 인가요? 감사합니다.
-
미해결Practical Testing: 실용적인 테스트 가이드
테스트 환경 통합 질문
안녕하세요. 테스트 환경 통합 강의를 보다가 궁금한 사항이 있어서 질문드립니다. 강의 내용처럼 통합 테스트를 수행할 때 여러번 Spring 서버를 띄우는 것을 효과적으로 개선하기 위해 TestSupport 추상클래스를 상속받아 Repository/Service 계층 테스트시에 통합된 환경을 구축하는게 더 좋은 것은 이해했는데요.Controller 테스트의 경우, 강의 내용처럼 따로 스프링을 띄우지 않고 @WebMvcTest로만 테스트코드를 작성하는 경우라면 공통 추상 클래스를 구현하는게 비효율적일수도 있을 것 같아서 궁금증이 생겼습니다.@WebMvcTest(Controllers="Controller.class") 형식으로 컨트롤러 클래스들을 명시해 줘야 하는데, 클래스가 수십개로 많아질수록 매번 추가해야 하고, Controllers에 많은 클래스를 넣어야 하고(패키지 단위로 지정한다든가 등의 방식은 없는것 같더라구요), 각 클래스에서 사용하는 MockBean이 많아질수록 필드가 많아져서 본문이 길어 보기 힘들수도 있을 것 같아서요. @SpringBootTest처럼 서버를 띄우는 비용이 발생하지 않으므로, 각각의 컨트롤러 테스트마다 명시적으로 @WebMvcTest를 사용하고, 해당 클래스에서 사용할 Mockbean 또한 명시적으로 지정하는 방식도 괜찮을까요? 실무적인 관점으로 볼 때, 제 생각대로 Controller 클래스의 테스트는 통합하지 않고 각각 구현하는 건 어떨지 궁금합니다.
-
미해결Practical Testing: 실용적인 테스트 가이드
TDD 질문입니다.!!
우빈님 커뮤니티나 유튜브에서 잘보고 있습니다.ㅎㅎTDD관련해서 질문이있는데, 프로덕션 코드 이전에 테스트 케이스를 먼저 작성할때 성공/실패 케이스에 대해서 미리 작성하고 "리팩토링"단계에서 앞서 작성한 케이스를 성공하도록 프로덕션 코드를 구현하는 순으로 하나요?아니면, 성공 케이스만 작성하고 리팩토링 단계에서 프로덕션 코드를 구현하고, 이후 실패 케이스를 작성하고 프로덕션 코드를 수정하는 순으로 하나요?!
-
미해결Practical Testing: 실용적인 테스트 가이드
강사님께서 사용하고 계신 개발 환경이 궁금합니다.
IDE를 보니 유용한 플러그인들이 보이는 것 같아요.궁금합니다...!
-
미해결Practical Testing: 실용적인 테스트 가이드
Builder 패턴은 언제 사용하고 언제 사용하지 않는게 좋은 건가요
안녕하세요!! 강의 중 ApiResponse라는 제네릭 클래스를 만들 때에는 Builder를 사용하지 않으셨는데, 제네릭 클래스여서 사용하지 않은 건가요??그리고 혹시 Builder패턴을 사용을 하지 않았으면 하는 부분들이 있을까요?항상 친절하게 답해주셔서 감사합니다!!
-
해결됨Practical Testing: 실용적인 테스트 가이드
현업에서 TDD를 사용하시나요?
학습 관련 질문을 남겨주세요. 어떤 부분이 고민인지, 무엇이 문제인지 상세히 작성하면 더 좋아요!먼저 유사한 질문이 있었는지 검색해 보세요.서로 예의를 지키며 존중하는 문화를 만들어가요.강사님은 현업에서 TDD를 사용해서 개발하시는지 궁금합니다!
-
미해결Practical Testing: 실용적인 테스트 가이드
TEST 코드 내에서 발생하는 쿼리의 바인딩 되는 부분을 볼 수 있나요?
안녕하세요. 강의를 듣다가 테스트를 하며 JPA에서 제공하는 쿼리가 제가 원하는 쿼리가 맞는지 확인함과 더불어 실제 들어가는 값이 제대로 들어가는지 알고 싶을 때가 존재하는데 이때 로그에서 확인할 수 있는 방법이 있을까요?
-
해결됨Practical Testing: 실용적인 테스트 가이드
시간대에 따라서 변화하는 로직 테스트 하는 가이드
안녕하세요. 우빈님 시간대에 따라 다른 결과를 주는 로직을 테스트 하는 과정에서 고민이 있어 질문을 드립니다. 우빈님이 강의에서도 언급하셨지만, 시간과 같이 관측할 때마다 달라지는 영역은 외부로 분리하면 테스트하기 쉬워진다고 말씀하셨습니다.!하지만, 현재 서비스 레이어 까지만 분리가 가능하고 컨트롤러에서 LocalDateTime을 파라미터로 받지 못하는 상황입니다. 그래서 컨트롤러를 테스트할 때 어떻게 해야할까 고민을 좀 해봤는데요. 저의 결론은 TimeProvider라는 클래스를 하나 만들어서컨트롤러를 테스트할 때는 이를 mocking하는 방식으로 테스트 코드를 작성했습니다. TimeProvider/** * 시간을 고정하여 테스트하기 위해 사용 */ @Component public class TimeProvider { public LocalDateTime getCurrentLocalDateTime() { return LocalDateTime.now(); } } ControllerGET을 사용하고 싶은데, 외부와 연동해야해서 POST를 사용할 수 밖에 없습니다. ㅠㅠ/** * 인기 메뉴 조회 * 현재 시간대의 식사종류와 일치하는 가장 조회수가 많은 금일 식사 메뉴 조회 */ @PostMapping("/menu/top1-view") public ResponseEntity<SkillResponse> getTop1RestaurantMenuByView(@RequestBody SkillPayload payload, @PageableDefault(size = 1) Pageable pageable) { log.info("request={}", payload); Page<RestaurantMenuResponse> top1RestaurantMenuByView = restaurantService.findTop1RestaurantMenuByView(pageable, timeProvider.getCurrentLocalDateTime()); RestaurantsMenuResponse response = new RestaurantsMenuResponse(top1RestaurantMenuByView.getContent()); return new ResponseEntity<>(response.toSkillResponseUseTextCard(apiVersion), HttpStatus.OK); } Test Code@Test @DisplayName("추천수 가장 많은 메뉴를 1개 조회한다.") void getTop1UosRestaurantMenuByView() throws Exception { // given // 현재 시간을 고정할 시간 생성 LocalDateTime fixedDateTime = LocalDateTime.of(2023, 8, 16, 10, 59, 59); when(timeProvider.getCurrentLocalDateTime()).thenReturn(fixedDateTime); String date = CrawlingUtils.toDateString(fixedDateTime); restaurant restaurant1 = createUosRestaurant(date, STUDENT_HALL, MealType.BREAKFAST, "라면", 0, 0); restaurant restaurant2 = createUosRestaurant(date, MAIN_BUILDING, MealType.BREAKFAST, "김밥", 1, 0); restaurant restaurant3 = createUosRestaurant(date, WESTERN_RESTAURANT, MealType.BREAKFAST, "돈까스", 2, 0); restaurant restaurant4 = createUosRestaurant(date, MUSEUM_OF_NATURAL_SCIENCE, MealType.BREAKFAST, "제육", 2, 1); restaurantRepository.saveAll(List.of(restaurant1, restaurant2, restaurant3, restaurant4)); SkillPayload skillPayload = createSkillPayload(RestaurantName.STUDENT_HALL.name(), MealType.BREAKFAST.name()); // when // then mockMvc.perform(post("/api/v1/text-card/restaurant/menu/top1-view") .contentType(MediaType.APPLICATION_JSON) .content(om.writeValueAsBytes(skillPayload)) .content(om.writeValueAsString(PageRequest.of(0, 1)))) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.version").value(SkillResponse.apiVersion)) .andExpect(jsonPath("$.template").isNotEmpty()) .andExpect(jsonPath("$.template.outputs").isArray()) .andExpect(jsonPath("$.template.outputs[0].textCard").isNotEmpty()) .andExpect(jsonPath("$.template.outputs[0].textCard.text").isString()); } 이런식으로 작성하는 것이 최선일까요?LocalDateTime fixedDateTime = LocalDateTime.of(2023, 8, 16, 10, 59, 59); when(timeProvider.getCurrentLocalDateTime()).thenReturn(fixedDateTime);TimeProvider 클래스를 만들어서 mocking 하는 방법이 최선일까요?혹시 더 좋은 방법을 말씀주시면 감사하겠습니다.! 좋은 강의 만들어 주셔서 감사합니다.^^
-
해결됨Practical Testing: 실용적인 테스트 가이드
예외 처리에 대한 rest doc 작성하기
안녕하세요!강사님 덕분에 테스트에 많은 관심이 생겨 개인 프로젝트에도 적용을 해보고 있습니다! Rest doc 작성 중 궁금한 점이 생겨 질문드립니다.API의 정상 응답이 아닌 예외 발생 시의 응답도 Rest doc으로 작성하고자 합니다.예를 들어 인증, 인가 관련하여 예외가 발생하는 경우가 있을 때 다음과 같이 생각했습니다.예외 케이스 별로 에러 코드를 상세하게 나누어 세밀한 응답을 전달하기 (ex. 아이디가 틀렸을 때 - 401A, 비밀번호가 틀렸을 때 - 401B, 아이디가 존재하지 않을 때 - 401C 등등..)공통 예외코드로 처리하기 (ex. 인증 실패 시 어떤 경우라도 401 코드 반환)위의 2가지 경우에 어떤 식으로 rest doc을 작성하는 것이 좋을까요? 1번 케이스의 경우는 특정 API 문서마다 함께 적는 것이 좋을 것 같긴 한데 2번 케이스의 경우는 프로젝트 전반적인 공통 예외처리라 별도의 문서 항목으로 1개만 작성하는 게 좋을 지 고민이 됩니다. 혹시 현업에서는 예외 발생 시 응답에 대한 문서도 작성하시는 지 궁금하고 1번, 2번 케이스에 대하여 어떻게 rest doc을 작성하는 것이 다른 인원과 소통하기 편할지 의견 부탁드리겠습니다! 감사합니다.
-
미해결Practical Testing: 실용적인 테스트 가이드
외부 세계에 영향을 주는 코드
관측할 때마다 다른 값의 의존하는 코드는즉, 현재시각, 랜덤 값 등등은 이해 하겠는데외부 세계에 영향을 주는 코드는 어떤 건지 이해가 잘 안되서요.혹시 간단한 예제를 들어주실수 있으실까요?