Presentation-Layer-Test
in TDD on Tdd

- Presentation-Layer란?
- MockMvc
- 요구사항
- 구현 순서
- 구현
- ProductController.java
- ProductCreateRequest.java
- ProductService.java
- ProductRepository.java
- ProductRepositoryTest.java
- ProductServiceTest.java
- ProductControllerTest.java
- JpaAuditingConfig.java
- gradle
- ApiResponse.java
- ApiControllerAdvice.java
- OrderControllerTest.java
- OrderCreateServiceRequest.java
- ProductCreateServiceRequest.java
- Git Link
Presentation-Layer란?
- 외부 세계의 요청을 가장 먼저 받는 계층
- 파라미터에 대한 최소한의 검증을 수행한다
MockMvc
- Mock(가짜) 객체를 사용해 스프링 MVC 동작을 재현할 수 있는 테스트 프레임워크
요구사항
- 관리자 페이지에서 신규 상품을 등록할 수 있다.
- 상품명, 상품 타입, 판매 상태, 가격 등을 입력받는다.
구현 순서
- ProductController createProduct
- ProductCreateRequest 생성
- ProductService createProduct
- ProductRepositoty findLatestProduct
- ProductRepositoryTest findLatestProductNumber
- ProductRepositoryTest findLatestProductNumberWhenProductIsEmpty
- ProductServiceTest createProduct
- RED
- ProductCreateRequest
- ProductService createProduct return null
- GREEN
- ProductServiceTest createProduct
- ProductService createProduct 빌더 return 하드코딩
- REFACTOR
- ProductService createNextProductNumber
- ProductCreateRequest toEntity()
- ProductServiceTest createProduct
- RED
- ProductServiceTest createProductWhenProductIsEmpty(상품이 하나도 없을때 등록시)
- ProductService createNextProductNumber
- ProductServiceTest tearDown(Clear)
- ProductRepositoty findLatestProduct
- ProductControllerTest createProduct(@WebMvcTest) : 컨트롤러 테스트
- given, when/then
- MockMvc
- @WebMvcTest
- @MockBean(service 의존성)
- spring/config/JpaAuditingConfig (@EnableJpaAuditing 분리)
- gladle
- dependencies impolementation ‘org.springframework.boot:spring-boot-starter-validation’
- ProductCreateRequest @NotNull, @NotBlank, @Positive // 검증 상세 처리
- ProductController createProduct @Valid // Controller 처리
- ApiResponse // 공통 리턴 형식 정의
- ApiControllerAdvicde(@ControllerAdvice) // validation 에러 처리
- @ExceptionHandler(BindException.class)
- ProductControllerTest createProductWithoutType // valid 에러 검증
- ProductControllerTest createProductWithoutSellingStatus // 판매 상태 검
- ProductControllerTest createProductWithoutName // 상품 이름 검증
- ProductControllerTest createProductWithoutZeroPrice // 상품 가격 양수 검증
- ProductControllerTest getSellingProducts
- given
- when
- then
- OrderControllerTest createOrder // 신규 주문 등록
- OrderControllerTest createOrderWithEmptyProductNumbers // 신규 주문 등록시 상품번호 1개 이상
- REFACTOR
- OrderCreateServiceRequest // 서비스 DTO를 따로 만든다 Contoller DTO만 Valid
- ProductCreateServiceRequest
구현
ProductController.java
@RequiredArgsConstructor
@RestController
public class ProductController {
private final ProductService productService;
@PostMapping("/api/v1/products/new")
public ApiResponse<ProductResponse> createProduct(@Valid @RequestBody ProductCreateRequest request) {
return ApiResponse.ok(productService.createProduct(request.toServiceRequest()));
}
@GetMapping("/api/v1/products/selling")
public ApiResponse<List<ProductResponse>> getSellingProducts() {
return ApiResponse.ok(productService.getSellingProducts());
}
}
ProductCreateRequest.java
@Getter
@NoArgsConstructor
public class ProductCreateRequest {
@NotNull(message = "상품 타입은 필수입니다.")
private ProductType type;
@NotNull(message = "상품 판매상태는 필수입니다.")
private ProductSellingStatus sellingStatus;
@NotBlank(message = "상품 이름은 필수입니다.")
private String name;
@Positive(message = "상품 가격은 양수여야 합니다.")
private int price;
@Builder
private ProductCreateRequest(ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
this.type = type;
this.sellingStatus = sellingStatus;
this.name = name;
this.price = price;
}
public ProductCreateServiceRequest toServiceRequest() {
return ProductCreateServiceRequest.builder()
.type(type)
.sellingStatus(sellingStatus)
.name(name)
.price(price)
.build();
}
}
ProductService.java
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class ProductService {
private final ProductRepository productRepository;
@Transactional
public ProductResponse createProduct(ProductCreateServiceRequest request) {
String nextProductNumber = createNextProductNumber();
Product product = request.toEntity(nextProductNumber);
Product savedProduct = productRepository.save(product);
return ProductResponse.of(savedProduct);
}
public List<ProductResponse> getSellingProducts() {
List<Product> products = productRepository.findAllBySellingStatusIn(ProductSellingStatus.forDisplay());
return products.stream()
.map(ProductResponse::of)
.collect(Collectors.toList());
}
private String createNextProductNumber() {
String latestProductNumber = productRepository.findLatestProductNumber();
if (latestProductNumber == null) {
return "001";
}
int latestProductNumberInt = Integer.parseInt(latestProductNumber);
int nextProductNumberInt = latestProductNumberInt + 1;
return String.format("%03d", nextProductNumberInt);
}
}
ProductRepository.java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
/**
* select *
* from product
* where selling_status in ('SELLING', 'HOLD');
*/
List<Product> findAllBySellingStatusIn(List<ProductSellingStatus> sellingStatuses);
List<Product> findAllByProductNumberIn(List<String> productNumbers);
@Query(value = "select p.product_number from product p order by id desc limit 1", nativeQuery = true)
String findLatestProductNumber();
}
ProductRepositoryTest.java
@ActiveProfiles("test")
//@SpringBootTest
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@DisplayName("원하는 판매상태를 가진 상품들을 조회한다.")
@Test
void findAllBySellingStatusIn() {
// given
Product product1 = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
Product product2 = createProduct("002", HANDMADE, HOLD, "카페라떼", 4500);
Product product3 = createProduct("003", HANDMADE, STOP_SELLING, "팥빙수", 7000);
productRepository.saveAll(List.of(product1, product2, product3));
// when
List<Product> products = productRepository.findAllBySellingStatusIn(List.of(SELLING, HOLD));
// then
assertThat(products).hasSize(2)
.extracting("productNumber", "name", "sellingStatus")
.containsExactlyInAnyOrder(
tuple("001", "아메리카노", SELLING),
tuple("002", "카페라떼", HOLD)
);
}
@DisplayName("상품번호 리스트로 상품들을 조회한다.")
@Test
void findAllByProductNumberIn() {
// given
Product product1 = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
Product product2 = createProduct("002", HANDMADE, HOLD, "카페라떼", 4500);
Product product3 = createProduct("003", HANDMADE, STOP_SELLING, "팥빙수", 7000);
productRepository.saveAll(List.of(product1, product2, product3));
// when
List<Product> products = productRepository.findAllByProductNumberIn(List.of("001", "002"));
// then
assertThat(products).hasSize(2)
.extracting("productNumber", "name", "sellingStatus")
.containsExactlyInAnyOrder(
tuple("001", "아메리카노", SELLING),
tuple("002", "카페라떼", HOLD)
);
}
@DisplayName("가장 마지막으로 저장한 상품의 상품번호를 읽어온다.")
@Test
void findLatestProductNumber() {
// given
String targetProductNumber = "003";
Product product1 = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
Product product2 = createProduct("002", HANDMADE, HOLD, "카페라떼", 4500);
Product product3 = createProduct(targetProductNumber, HANDMADE, STOP_SELLING, "팥빙수", 7000);
productRepository.saveAll(List.of(product1, product2, product3));
// when
String latestProductNumber = productRepository.findLatestProductNumber();
// then
assertThat(latestProductNumber).isEqualTo(targetProductNumber);
}
@DisplayName("가장 마지막으로 저장한 상품의 상품번호를 읽어올 때, 상품이 하나도 없는 경우에는 null을 반환한다.")
@Test
void findLatestProductNumberWhenProductIsEmpty() {
// when
String latestProductNumber = productRepository.findLatestProductNumber();
// then
assertThat(latestProductNumber).isNull();
}
private Product createProduct(String productNumber, ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
return Product.builder()
.productNumber(productNumber)
.type(type)
.sellingStatus(sellingStatus)
.name(name)
.price(price)
.build();
}
}
ProductServiceTest.java
@ActiveProfiles("test")
@SpringBootTest
class ProductServiceTest {
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@AfterEach
void tearDown() {
productRepository.deleteAllInBatch();
}
@DisplayName("신규 상품을 등록한다. 상품번호는 가장 최근 상품의 상품번호에서 1 증가한 값이다.")
@Test
void createProduct() {
// given
Product product = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
productRepository.save(product);
ProductCreateRequest request = ProductCreateRequest.builder()
.type(HANDMADE)
.sellingStatus(SELLING)
.name("카푸치노")
.price(5000)
.build();
// when
ProductResponse productResponse = productService.createProduct(request);
// then
assertThat(productResponse)
.extracting("productNumber", "type", "sellingStatus", "name", "price")
.contains("002", HANDMADE, SELLING, "카푸치노", 5000);
List<Product> products = productRepository.findAll();
assertThat(products).hasSize(2)
.extracting("productNumber", "type", "sellingStatus", "name", "price")
.containsExactlyInAnyOrder(
tuple("001", HANDMADE, SELLING, "아메리카노", 4000),
tuple("002", HANDMADE, SELLING, "카푸치노", 5000)
);
}
@DisplayName("상품이 하나도 없는 경우 신규 상품을 등록하면 상품번호는 001이다.")
@Test
void createProductWhenProductsIsEmpty() {
// given
ProductCreateRequest request = ProductCreateRequest.builder()
.type(HANDMADE)
.sellingStatus(SELLING)
.name("카푸치노")
.price(5000)
.build();
// when
ProductResponse productResponse = productService.createProduct(request);
// then
assertThat(productResponse)
.extracting("productNumber", "type", "sellingStatus", "name", "price")
.contains("001", HANDMADE, SELLING, "카푸치노", 5000);
List<Product> products = productRepository.findAll();
assertThat(products).hasSize(1)
.extracting("productNumber", "type", "sellingStatus", "name", "price")
.contains(
tuple("001", HANDMADE, SELLING, "카푸치노", 5000)
);
}
private Product createProduct(String productNumber, ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
return Product.builder()
.productNumber(productNumber)
.type(type)
.sellingStatus(sellingStatus)
.name(name)
.price(price)
.build();
}
}
ProductControllerTest.java
@WebMvcTest(controllers = ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean // 부트 3.4.0 부터 @MockitoBean 으로 대체
private ProductService productService;
@DisplayName("신규 상품을 등록한다.")
@Test
void createProduct() throws Exception {
// given
ProductCreateRequest request = ProductCreateRequest.builder()
.type(ProductType.HANDMADE)
.sellingStatus(ProductSellingStatus.SELLING)
.name("아메리카노")
.price(4000)
.build();
// when // then
mockMvc.perform(
post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk());
}
@DisplayName("신규 상품을 등록할 때 상품 타입은 필수값이다.")
@Test
void createProductWithoutType() throws Exception {
// given
ProductCreateRequest request = ProductCreateRequest.builder()
.sellingStatus(ProductSellingStatus.SELLING)
.name("아메리카노")
.price(4000)
.build();
// when // then
mockMvc.perform(
post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.status").value("BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("상품 타입은 필수입니다."))
.andExpect(jsonPath("$.data").isEmpty())
;
}
@DisplayName("신규 상품을 등록할 때 상품 판매상태는 필수값이다.")
@Test
void createProductWithoutSellingStatus() throws Exception {
// given
ProductCreateRequest request = ProductCreateRequest.builder()
.type(ProductType.HANDMADE)
.name("아메리카노")
.price(4000)
.build();
// when // then
mockMvc.perform(
post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.status").value("BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("상품 판매상태는 필수입니다."))
.andExpect(jsonPath("$.data").isEmpty())
;
}
@DisplayName("신규 상품을 등록할 때 상품 이름은 필수값이다.")
@Test
void createProductWithoutName() throws Exception {
// given
ProductCreateRequest request = ProductCreateRequest.builder()
.type(ProductType.HANDMADE)
.sellingStatus(ProductSellingStatus.SELLING)
.price(4000)
.build();
// when // then
mockMvc.perform(
post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.status").value("BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("상품 이름은 필수입니다."))
.andExpect(jsonPath("$.data").isEmpty())
;
}
@DisplayName("신규 상품을 등록할 때 상품 가격은 양수이다.")
@Test
void createProductWithZeroPrice() throws Exception {
// given
ProductCreateRequest request = ProductCreateRequest.builder()
.type(ProductType.HANDMADE)
.sellingStatus(ProductSellingStatus.SELLING)
.name("아메리카노")
.price(0)
.build();
// when // then
mockMvc.perform(
post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.status").value("BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("상품 가격은 양수여야 합니다."))
.andExpect(jsonPath("$.data").isEmpty())
;
}
@DisplayName("판매 상품을 조회한다.")
@Test
void getSellingProducts() throws Exception {
// given
List<ProductResponse> result = List.of();
when(productService.getSellingProducts()).thenReturn(result);
// when // then
mockMvc.perform(
get("/api/v1/products/selling")
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.status").value("OK"))
.andExpect(jsonPath("$.message").value("OK"))
.andExpect(jsonPath("$.data").isArray());
}
}
JpaAuditingConfig.java
// 메인 Application 에서 뺴고 새로 config 클래스를 작성해야 테스트시 에러가 없다.
@EnableJpaAuditing
@Configuration
public class JpaAuditingConfig {
}
gradle
dependencies {
...
implementation 'org.springframework.boot:spring-boot-starter-validation'
...
}
ApiResponse.java
@Getter
public class ApiResponse<T> {
private int code;
private HttpStatus status;
private String message;
private T data;
public ApiResponse(HttpStatus status, String message, T data) {
this.code = status.value();
this.status = status;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> of(HttpStatus httpStatus, String message, T data) {
return new ApiResponse<>(httpStatus, message, data);
}
public static <T> ApiResponse<T> of(HttpStatus httpStatus, T data) {
return of(httpStatus, httpStatus.name(), data);
}
public static <T> ApiResponse<T> ok(T data) {
return of(HttpStatus.OK, data);
}
}
ApiControllerAdvice.java
@RestControllerAdvice
public class ApiControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(BindException.class)
public ApiResponse<Object> bindException(BindException e) {
return ApiResponse.of(
HttpStatus.BAD_REQUEST,
e.getBindingResult().getAllErrors().get(0).getDefaultMessage(),
null
);
}
}
OrderControllerTest.java
@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean // 부트 3.4.0 부터 @MockitoBean 으로 대체
private OrderService orderService;
@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"));
;
}
@DisplayName("신규 주문을 등록할 때 상품번호는 1개 이상이어야 한다.")
@Test
void createOrderWithEmptyProductNumbers() throws Exception {
// given
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of())
.build();
// when // then
mockMvc.perform(
post("/api/v1/orders/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.status").value("BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("상품 번호 리스트는 필수입니다."))
.andExpect(jsonPath("$.data").isEmpty())
;
}
}
OrderCreateServiceRequest.java
@Getter
@NoArgsConstructor
public class OrderCreateServiceRequest {
private List<String> productNumbers;
@Builder
private OrderCreateServiceRequest(List<String> productNumbers) {
this.productNumbers = productNumbers;
}
}
ProductCreateServiceRequest.java
@Getter
@NoArgsConstructor
public class ProductCreateServiceRequest {
private ProductType type;
private ProductSellingStatus sellingStatus;
private String name;
private int price;
@Builder
private ProductCreateServiceRequest(ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
this.type = type;
this.sellingStatus = sellingStatus;
this.name = name;
this.price = price;
}
public Product toEntity(String nextProductNumber) {
return Product.builder()
.productNumber(nextProductNumber)
.type(type)
.sellingStatus(sellingStatus)
.name(name)
.price(price)
.build();
}
}
tip
String.format(“%03d”, 변수);
ex : 9 > 009, 10 > 010
동시성 이슈
빈도수가 낮을 경우 : 시스템 상에서 재시도 약3회
빈도수가 높은 경우 : UUID
@Transactional(readOnly=true)
- readOnly = true : 읽기전용
- CRUD 에서 CUD 동작 X only Read
- JPA : CUD 스냅샷 저장, 변경감지 X (성능 향상)
- CQRS - Command(CUD) / Read 분리
- 보통의 서비스는 Read가 훨씬 많음
- 추천 : 서비스 상단에 @Transactional(readOnly=true), 메서드에서 CUD가 필요하다면 @Transactional
@WebMvcTest
컨트롤러 레이어만 띄우기 위해 관련 빈들만 띄움
사용법 : @WebMvcTest(controllers = 컨트롤러. class)
@MockBean(@Mock도 있음)
@WebMvcTest에서는 MockBean으로 안해주면 Service 못찾음 (부트 3.4.0 부터 @MockitoBean 으로 대체)
MockMvc 사용법 :
mockMvc.perform(MockMvcRequestBuilders.post("주소")
.content(objectMapper.writeValueAsString(request)) // 직렬화
.contentType(MediaType.APPLICATION_JSON)
)
.andExpect(MockMvcResultMatchers.status().isOk()); // 검증
org.springframework.boot:spring-boot-starter-validation
- @NotNull(message=…) : yes : “ “, “”
- @NotBlank : no : “ “, “”
- @NotEmpty : ok : “ “, no : “”
- @Positive : 양수만 가능
- @PositiveOrZero : 양수와 0만 가능
- @Negative : 음수만 가능
- @NegativeOrZero : 음수와 0만 가능
- Controller 메소드 @Valid
- 에러 관리 @ControllerAdvice @ExceptionHandler(BindException.class) 공백 및 null둥 기본적인 검증만 pojo에서 처리 그외 디테일한 검증은 service단에서 추천