레이어드 아키텍처 특징 및 테스트 작성법 알아보기
이 글은 박우빈님의 Practical-Testing 강의 를 참조하여 작성한 글입니다.
레이어드 아키텍처(Layered Architecture)란?
소프트웨어 시스템을 계층(layer)으로 나누어 구성하는 설계 방식으로, 각 계층이 특정한 역할과 책임을 갖도록 설계하는 것을 의미한다.
이렇게 각 계층들을 관심사를 기준으로 분리함으로써 계층의 응집도를 높이고 결합도를 낮출 수 있다.
-> 유지보수성이 올라감!
레이어드 아키텍처의 주요 특징
계층 분리
Layered Architecture에서는 보통 3개의 Layer로 구성되어 있다.
Presentation Layer (UI 계층): 사용자의 요청 및 응답을 처리하며 상호작용 한다.
Business Layer (서비스 계층): 비즈니스 로직을 수행하는 책임을 지닌다.
Persistence Layer (비즈니스 계층): DB에 접근하여 상호작용(데이터 CRUD) 한다.
독립성
상위 계층은 하위 계층에 의존하지만,
하위 계층은 상위 계층에 대한 지식이나 정보를 가지지 않아야 한다.
e.g, Presentation Layer
에서는 하위 계층인 Business Layer
를 의존한다.
이때 Business Layer
는 상위 계층인 Presentation Layer
에서 넘어온 데이터로 비즈니스 로직을 처리할 뿐이며,
Presentation Layer
에 대해 알고 있지 않다!
유연성
특정 계층의 구현을 변경하더라도 다른 계층에는 영향을 미치지 않도록 설계할 수 있다.
테스트 용이성
계층별로 테스트가 가능하므로, 각 계층의 단위 테스트를 독립적으로 수행할 수 있다.
각 계층 테스트 코드 작성법
Presentation Layer
API의 요청-응답 흐름과 응답 형식(HTTP 상태 코드와 JSON 응답 데이터)을 검증하는 테스트를 작성한다.
@WebMvcTest
를 통해 테스트 하고자 하는 컨트롤러를 등록해준다.해당 컨트롤러를 테스트 하기위해 필요한 Business 계층(Service 클래스)를
@MockBean
을 통해 주입하며,MockMvc
또한 의존성 주입해준다.이 때 json과의 소통이 필요하므로 객체를 json, json을 객채로 변환할 수 있게끔
ObjectMapper
또한 주입해주자.
@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@MockBean
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()
.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());
}
}
Business Layer
작성한 비지니스 로직이 의도한 대로 작동하는지 검증하는 테스트를 작성한다.
@SpringBootTest
을 통해 모든 Bean 의존성 주입함으로써 통합 테스트가 가능하다.해피케이스 뿐만 아니라 예외 테스트, 경계값 테스트 등 여러 케이스에 대해 테스트를 작성하자.
@Transactional
vssql
을 이용한 데이터 삭제
실제 코드에서는 @Transactional을 사용하지 않는데, 단순히 롤백만을 위해 테스트코드에서 @Transactional을 사용하면, 실제 작동 방식과 다르게 작동할 수 있다.
따라서 테스트 코드 작성시 Transactional의 부작용에 대해 인지하고 사용할 것!!
@SpringBootTest
class ProductServiceTest {
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@AfterEach
void tearDown() {
productRepository.deleteAllInBatch();
}
@DisplayName("신규 상품을 등록한다. 상품번호는 가장 최근 상품의 상품번호에서 1 증가한 값이다.")
@Test
void createProduct() {
//given
Product product1 = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
productRepository.save(product1);
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", "price", "name")
.contains("002", HANDMADE, SELLING, 5000, "카푸치노");
List<Product> products = productRepository.findAll();
assertThat(products).hasSize(2)
.extracting("productNumber", "type", "sellingStatus", "price", "name")
.containsExactlyInAnyOrder(
tuple("001", HANDMADE, SELLING, 4000, "아메리카노"),
tuple("002", HANDMADE, SELLING, 5000, "카푸치노")
);
}
}
Persistence Layer
데이터에 접근하는 역할로 데이터 CRUD와 연관된 메서드들을 테스트 한다.
@DataJpaTest
JPA와 관련된 의존성들만 주입해준다 ->
@SpringBootTest
보다 가볍다.어노테이션 내부에
@Transactional
이 포함되어 있어 테스트 후 데이터가 롤백된다.
@DataJpaTest // @DataJpaTest안에 @Transactional이 걸려있어서 자동으로 rollback이 됨
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)
);
}
}
Reference
댓글을 작성해보세요.