[워밍업 클럽 4기 백엔드] 3주차 발자국
3주차에 배운 것들
📌 Layered Architecture(레이어드 아키텍처) 테스트

💡관심사를 분리하여 계층을 나누고, 책임을 나누어 유지보수에 용이하게 하는 방법
테스트 하기 복잡해 보이지만, 무엇을 어떻게 테스트 할 지 알아보자!
📌 Persistence Layer
Data Access의 역할
비즈니스 가공 로직이 포함되어서는 안 된다.
Data에 대한 CRUD에만 집중한 레이어이기 때문
@Test
@DisplayName("상품번호 리스트로 상품들을 조회한다.")
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)
);
}✅특징
작성한 쿼리가 우리의 의도대로 동작하는지 확인하는 단위 테스트의 성격을 가진다.
해당 쿼리가 미래에 어떠한 구현체로 변형될지 모르기 때문에 테스트 코드로 보장해주어야 한다.
📌 Business Layer
비즈니스 로직을 구현하는 역할
Persistence Layer와의 상호작용(Data를 읽고 쓰는 행위)을 통해 비즈니스 로직을 전개시킨다.
트랜잭션을 보장해야 한다.
트랜잭션 : "쪼갤 수 없는 업무 처리의 최소 단위"로 원자성, 일관성, 격리성, 지속성 일명 ACID를 보장해야함
@Test
@DisplayName("주문번호 리스트를 받아 주문을 생성한다.")
void createOrderTest() {
// given
Product product1 = createProduct(HANDMADE, "001", 1000);
Product product2 = createProduct(HANDMADE, "002", 3000);
Product product3 = createProduct(HANDMADE, "003", 5000);
productRepository.saveAll(List.of(product1, product2, product3));
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of("001", "002"))
.build();
// when
LocalDateTime registeredDateTime = LocalDateTime.now();
OrderResponse orderResponse = orderService.createOrder(request.toServiceRequest(), registeredDateTime);
// then
assertThat(orderResponse.getId()).isNotNull();
assertThat(orderResponse)
.extracting("registeredDateTime", "totalPrice")
.contains(registeredDateTime, 4000);
assertThat(orderResponse.getProducts()).hasSize(2)
.extracting("productNumber", "price")
.containsExactlyInAnyOrder(
tuple("001", 1000),
tuple("002", 3000)
);
}✅특징
작성한 비즈니스 로직이 의도대로 동작하는지 확인하는 통합 테스트의 성격을 가진다.
Business Layer는 Persistence Layer를 의존하므로 이 둘을 통합적으로 테스트한다.
📌Presentation Layer
외부 세계의 요청을 가장 먼저 받는 계층
파라미터에 대한 최소한의 검증을 수행
넘어온 값들의 검증(validation)이 중요 → 비즈니스 로직은 필요없음
Business Layer에서 비즈니스 로직을 전개하기 전에 값들이 유효한지 검증
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private ProductService productService;
@Test
@DisplayName("신규 상품을 등록한다.")
void createProduct() throws Exception {
// given
ProductCreateRequest request = ProductCreateRequest.builder()
.type(ProductType.HANDMADE)
.sellingStatus(ProductSellingStatus.SELLING)
.name("아메리카노")
.price(4000)
.build();
// when // then
// body 에 값을 넣어서 직렬화와 역직렬화가 발생
mockMvc.perform(
post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk());
}
}MockMvc : Mock(가짜) 객체를 사용해 스프링 MVC 동작을 재현할 수 있는 테스트 프레임워크
objectMapper : Java 객체를 JSON 문자열로 변환(직렬화), JSON 문자열을 Java 객체로 변환(역직렬화)을 수행
content(objectMapper.writeValueAsString(request))-> 직렬화controller의
@RequestBody에 의해 역직렬화 수행
✅ 특징
Business Layer와 Persistence Layer는 Mocking을 사용하여 정상 동작을 하는 것으로 간주하고 테스트한다.
@MockitoBean을 사용하여 ProductService를 Mocking
애플리케이션을 띄우지 않고도 HTTP 요청/응답 흐름을 테스트할 수 있다.
Validation이 정상적으로 작동하는지 테스트할 수 있다.
📌 @DataJpaTest vs @SpringBootTest
가장 큰 차이점은 @Transactional 유무의 차이이다. @DataJpaTest 는 트랜잭션을 지원해서 테스트 종료 후 롤백을 실행하는 반면, @SpringBootTest 는 트랜잭션을 지원하지 않아 테스트 간에 간섭을 받지 않기 위해서는 클랜징 작업(deleteAllInBatch)이 필요하다.
추가적으로 @DataJpaTest 는 @Entity, @Repository만 Bean으로 등록하기 때문에 Persistence Layer 테스트를 할 때만 사용하는 것이 바람직하다.
❓ 그렇다면 @SpringBootTest 과 @Transactional 를 같이 사용하면 되는거 아닐까?
둘이 같이 사용하게 되면 테스트에서 롤백을 해줘서 편하지만 주의해서 사용해야한다. 만약 service(Business Layer)에서 @Transactional 을 빼먹어도, 테스트에서 사용한 트랜잭션으로 인해 테스트는 정상 통과되고, 기능에 문제가 없다고 판단할 경우가 생길 수도 있기 때문이다.
그렇기 때문에 편리함을 위해 사용해도 되지만 service에 @Transactional 이 빠지지 않았는지, 실수할 수도 있다는 인지를 하면서 사용하는 것이 좋다.
📌 Validation
org.springframework.boot:spring-boot-starter-validation 을 사용하여 클라이언트의 요청 값에 대한 검증을 할 수 있다.
@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;
}
.
.
.
@PostMapping("/api/v1/products/new")
public ApiResponse<ProductResponse> createProduct(
@Valid @RequestBody ProductCreateRequest request
) {
return ApiResponse.ok(productService.createProduct(request));
}💡 클라이언트의 요청을 받는 시점에 @Valid 가 적용된 객체를 검증할 수 있다.
String 검증
@NotNull: 빈 문자열(””), 공백 문자열 통과(” “)@NotEmpty: 공백 문자열 통과(” “)@NotBlank: 둘 다 통과할 수 없음 → 문자가 필수로 있어야 함
❓ 상품 이름은 20자 제한이 주어진다면
controller에서 valid를 사용해서 해야하는지 고민
유효한 문자열이라면 가져야하는 합당한 검증과 도메인 정책에 맞는 특수한 형태의 검증을 구분할 줄 아는 시야를 길러야 한다.
✅ 20자 제한은 서비스 계층, 생성자로 생성을 할 때 검증을 할 수 있다.
미션을 통해 배운 것들
📌 Day 11
스터디카페 프로젝트의 테스트 코드를 작성하면서 많은 고민들을 통해 배울 수 있었다. 테스트를 통해서 기능을 검증하는 것을 넘어서, '이 테스트를 왜 해야 하는지'에 초점을 두고 이번 미션을 진행했다. '안다고 생각하는 것'과 '아는 것' 의 차이는 정말 크구나 다시 한번 느낄 수 있었다.
강의를 보면서 강사님의 코드를 따라 칠때는 "생각보다 쉬운데?"라는 생각을 했다. 하지만 내 스스로 어떤 테스트를 해야 적합한지 생각하고, 이해하기 쉬운 DisplayName을 생각하는 것도 어렵게 느껴졌다. 실제로 "사물함을 구매할 수 없다" vs "사물함을 사용할 수 없다" 이 두 문장을 가지고 20분은 넘게 고민도 하고, 열심히 짜둔 테스트 코드가 적합하지 않은 것 같아서 싹 지우고 다시 작성하기도 했다.
테스트를 직접 작성하면서 "나는 지금까지 무책임한 개발을 하고 있었구나"라는 생각이 들었다. 실무에서의 테스트는 이 예제보다 더 복잡하고, 생각할 것도 많을텐데 개발을 하면서 테스트 코드 작성 경험을 하지 못한 것은 참 아쉽다는 생각도 들었다. 그래도 이번 미션을 통해 그 감을 조금 익힐 수 있었고, 부족한 부분과 보완해야 할 점을 알 수 있는 좋은 기회가 되었다.
댓글을 작성해보세요.