저번시간에는 spring에서 테스트 코드 작성 시 필요한 라이브러리와 Junit5 어노테이션에 대해 알아봤다.
이번에는 강의에서 제공한 코드를 보면서 AssertJ 라이브러리를 사용법에 초점을 둬 작성한다.
포스팅을 위해 따로 spring project를 만들어도 봤지만 강의에서 사용한 코드보다 훨씬 퀄리티가 떨어져 그냥 강의 코드를 사용하려고 한다.
강의에서 진행한 프로젝트에 대해 간단하게 설명하자면 카페 키오스크 프로젝트로 상품, 재고, 주문관련 로직이 있다. 전체 코드를 모르더라도 테스트 할 코드를 보여주거나 로직을 설명하면서 최대한 이해할 수 있도록 작성했다.
AssertJ는 보통 테스트 결과를 확인할 때 많이 사용되는데 이전 포스팅에서 설명했듯이 장점이 메소드 체이닝 지원으로 코드 가독성 향상, 많은 API지원으로 익숙해지기만 한다면 사용하기 쉽다.
예를들어 입력받은 문자열 뒤에 World를 붙여 반환하는 메서드를 테스트하고 싶다면
public String returnString(String str) {
return str + " World";
}
@DisplayName("문자열 뒤에 World가 붙어있는지 확인한다.")
@Test
void test() {
// given
String str = "hello";
// when
String result = returnString(str);
// then
assertThat(result)
.isNotEmpty()
.startsWith("hello")
.contains("llo")
.endsWith("World")
.isEqualTo("hello World");
}
assertThat() 안에 결과값을 넣고 메서드체인방식을 이용해서 원하는 만큼 결과값을 확인할 수 있다.
양수, 음수, class 검사, 원하는 필드를 골라 포함되어있는지, 오류처리까지 AssertJ를 사용해서 확인할 수 있을 정도로 많은 API를 지원하고 있다.
BDD는 TDD(Test Driven Development)에서 파생된 개발 방법이다.
함수 단위의 테스트에 집중하기보다, 시나리오에 기반한 테스트케이스 자체에 집중하여 테스트하는 것으로 개발자가 아닌 사람이 봐도 이해할 수 있을 정도의 추상화 수준을 권장하는 방식이다.
위 테스트처럼 영한님 강의에서도 테스트를 작성할 때 주석으로 given, when, then 으로 나눠서 작성하는데 이게 바로 BDD 스타일이다.
Given: 시나리오 진행에 필요한 모든 준비 과정
When: 시나리오 행동 진행
Then: 시나리오 진행에 대한 결과 명시, 검증
사용자가 어떤 환경에서(given) 어던 행동을 진행했을 때(when) 어떤 상태 변화가 일어난다.(then) 이렇게 시나리오를 생각하고 작성하는게 BDD 스타일이다.
테스트 코드는 BDD방식을 지키면서 작성해보자.
spring 테스트하기 위해서는 먼저 필요한 스프링 빈을 초기화하고 로드해야 한다. 필요한 빈을 로드해주는 어노테이션이 두가지가 있어 정리해본다.
repository를 테스트하기 위해서는 먼저 테스트 코드에 필요한 빈을 초기화하고 로드해야하는데 관련 어노테이션이 @SpringBootTest, @DataJpaTest 두가지가 있다.
@ActiveProfiles("test")
@SpringBootTest
// @DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
...
}
@SpringBootTest 어노테이션은 모든 빈을 업로드해서 실행시간이 길고, 트랜잭션 롤백을 지원하지 않는다.
@DataJpaTest 어노테이션은 JPA에 초점을 맞춰 JPA관련 빈만 로드하고, 트랜잭션 롤백을 지원한다.
@DataJpaTest 어노테이션이 효율적이고 트랜잭션 관리까지 돼서 더 좋아보인다. 하지만 만약 서비스 계층에서 트랜잭션 관리를 해야하는데 하지 못한 경우 @DataJpaTest 어노테이션으로 테스트를 진행하면 서비스 계층의 트랜잭션이 잘 작동하는 지 확인하지 못하기 때문에 @SpringBootTest 어노테이션을 권장한다.
만약 모든 팀원들이 이런 문제를 인지하고 있다면 @DataJpaTest를 사용해도 문제 없다.
@SpringBootTest 어노테이션에서 롤백을 하고 싶으면 @AfterEach 어노테이션을 사용하여 매번 테스트가 끝날때마다 관련 repository를 초기화하거나 @Transactional 어노테이션을 사용할 수 있다.
@AfterEach
void tearDown() {
productRepository.deleteAllInBatch();
}
AssertJ로 해피 케이스를 메서드를 테스트해보자.
product를 원하는 만큼 생성하고 repository에 저장한다. 그리고 원하는 판매상태 (SELLING, HOLD)로 되어있는 상품을 리스트로 반환하여 확인하고 싶다.
AssertJ로 then절을 어떻게 채우면 확인할 수 있을까?
@DisplayName("원하는 판매상태를 가진 상품들을 조회한다.")
@Test
void findAllBySellingStatusIn() {
// given
Product product1 = getProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
Product product2 = getProduct("002", HANDMADE, HOLD, "카페라떼", 4500);
Product product3 = getProduct("003", HANDMADE, STOP_SELLING, "팥빙수", 7000);
productRepository.saveAll(List.of(product1, product2, product3));
// when
List<Product> products = productRepository.findAllBySellingStatusIn(List.of(SELLING, HOLD));
// then
....
}
코드에서 내가 원하는 products 결과값은 밑의 코드와 같다.
products = [
Product(id=1, productNumber=001, type=HANDMADE, sellingStatus=SELLING, name=아메리카노, price=4000),
Product(id=2, productNumber=002, type=HANDMADE, sellingStatus=HOLD, name=카페라떼, price=4500)
]
...
// then
assertThat(products)
.hasSize(2)
.extracting("productNumber", "name", "sellingStatus")
.containsExactlyInAnyOrder(
tuple("001", "아메리카노", SELLING),
tuple("002", "카페라떼", HOLD)
);
assertThat()을 사용한 코드에서는 먼저 hasSize로 리스트의 크기값을 설정할 수 있다.
extracting()를 사용하여 검사하고 싶은 필드값만 고를 수 있다. 단 DB 테이블의 필드값을 입력해야한다!
리스트 내용을 확인하기 위해 containsExactlyInAnyOrder() 메서드를 확인했다.
여기서부터는 선택지가 다양해 지는데
내가 사용한 containsExactlyInAnyOrder()은 순서에 상관없이 리스트 값을 확인할 때 사용하고, 만약 원하는 순서가 있다면 containsExactly() 메서드를 사용해야 한다.
위 2 메서드는 리스트의 크기와 확인하려는 tuple의 갯수가 같아야 한다.
예를 들어 리스트의 크기는 2이지만 검사하는 tuple이 하나이면 에러발생한다.
// then 에러발생
assertThat(products)
.hasSize(2)
.extracting("productNumber", "name", "sellingStatus")
.containsExactlyInAnyOrder(
tuple("001", "아메리카노", SELLING),
// tuple("002", "카페라떼", HOLD)
);
이렇게 여러 값중에서 필요한 데이터만 확인하고 싶다면 contains() 메서드를 사용하면 된다.
// then 정상동작
assertThat(products)
.hasSize(2)
.extracting("productNumber", "name", "sellingStatus")
.contains(
tuple("001", "아메리카노", SELLING)
);
이 외에도 메서드를 알고 싶으면 공식문서를 찾아보거나 구현되어 있는 코드를 찾아보면 된다.
직접 구현 코드를 찾아가보면 주석으로 자세하게 메서드 사용법이 적혀있다. 영어로 되어있지만 예시까지 되어있어 쉽게 이해할 수 있을 것이다.
/*
Verifies that the actual group contains exactly the given values and nothing else, in any order.
Example:
// an Iterable is used in the example but it would also work with an array
Iterable<Ring> elvesRings = newArrayList(vilya, nenya, narya, vilya);
// assertion will pass
assertThat(elvesRings).containsExactlyInAnyOrder(vilya, vilya, nenya, narya);
// assertion will fail as vilya is contained twice in elvesRings.
assertThat(elvesRings).containsExactlyInAnyOrder(nenya, vilya, narya);
If you want to specify the elements to check with an Iterable, use containsExactlyInAnyOrderElementsOf(Iterable) instead.
*/
@Override
@SafeVarargs
public final SELF containsExactlyInAnyOrder(ELEMENT... values) {
return containsExactlyInAnyOrderForProxy(values);
}
AssertJ로 원하는 예외가 발생했는지 확인해본다.
테스트의 given절에서는 상품번호 “001”의 개수는 1로 설정했지만 주문 개수(quantity)를 2개로 설정해 일부러 에러를 발생시킨다.
@DisplayName("재고보다 많은 수의 수량으로 차감 시도하는 경우 예외가 발생한다.")
@Test
void deductQuantity2() {
// given
Stock stock = Stock.create("001", 1);
int quantity = 2;
// when then
...
}
이런 경우 프로덕션 코드에서는 IllegalArgumentException에러를 발생시킨다.
...
if (isQuantityLessThan(quantity)) {
throw new IllegalArgumentException("차감할 재고 수량이 없습니다.");
}
이런 상황에서 에러를 확인하고 싶으면 assertThat이 아닌 assertThatThrownBy()를 사용하면 된다.
// when then
assertThatThrownBy(() -> stock.deductQuantity(quantity))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("차감할 재고 수량이 없습니다.");
assertThatThrownBy()안에 에러가 발생하는 코드를 입력해야 하기때문에 when절과 then절이 같이 있어야 된다.
isInstanceOf() 를 사용해서 에러 타입을 확인할 수 있고 hasMessage()로 에러 메시지도 확인할 수 있다.
다른 계층과 달리 controller 계층을 테스트하기 위해서는 HTTP 요청이 필요하다. 테스트에서는 MockMVC 을 사용해서 HTTP 요청을 보내고 반환되는 HTTP 응답을 검증할 수 있다.
MockMvc - Mock(가짜) 객체를 사용해 스프링 MVC 동작을 재현할 수 있는 테스트 프레임워크
MockMvc를 사용하기 위해서는 MockMvc 인스턴스를 주입을 해야한다.
2가지 방법이 있는데 첫번째는 기존의 @SpringBootTest 어노테이션과 @AutoConfigureMockMvc 어노테이션을 같이 사용하는 방법
@SpringBootTest
@AutoConfigureMockMvc
class productControllerTest {
@Autowired
private MockMvc mockMvc;
...
}
두 번째는 @WebMvcTest 어노테이션을 사용하는 방법이다.
@WebMvcTest 어노테이션에 테스트할 컨트롤러 클래스를 입력하면 된다.
@WebMvcTest(controllers = productController.class)
class productControllerTest {
@Autowired
private MockMvc mockMvc;
...
}
모든 빈을 로드해서 통합 테스트를 하고싶다면 @SpringBootTest 어노테이션을 사용하고 테스트할 컨트롤러 관련 빈만 로드하려면 @WebMvcTest 어노테이션 사용을 추천한다.
먼저 테스트할 프로덕션 코드를 확인하자.
등록하고 싶은 product 를 POST요청에 같이 보내면 product를 저장하고 HTTP OK 200 응답과 product의 정보를 반환한다.
@PostMapping("/api/v1/products/new")
public ApiResponse<ProductResponse> createProduct(@Validated @RequestBody ProductCreateRequest request) {
return ApiResponse.ok(productService.createProduct(request.toServiceRequest()));
}
위의 컨트롤러 코드를 테스트하기위한 코드이다.
@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(
MockMvcRequestBuilders.post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.status").value("OK"))
.andExpect(MockMvcResultMatchers.jsonPath("$.data.id").value(1))
.andExpect(MockMvcResultMatchers.jsonPath("$.data.type").value("HANDMADE"))
.andExpect(MockMvcResultMatchers.jsonPath("$.data.sellingStatus").value("SELLING"))
.andExpect(MockMvcResultMatchers.jsonPath("$.data.name").value("아메리카노"))
.andExpect(MockMvcResultMatchers.jsonPath("$.data.price").value(4000));
}
입력코드
mockMvc의 perform 메서드를 사용하여 요청을 보낼 수 있다.
MockMvcRequestBuilders 로 GET, POST, PUT, PACTH, DELETE 요청을 보낼 수 있고, contenct 메서드로 바디안에 들어가는 객체를 입력한다.
만약 GET요청으로 url에 쿼리가 들어가야 한다면
MockMvcRequestBuilders.get("/api/v1/products/new")
.queryParam("name", "이름")
// 최종 URI /api/v1/products/new?name=이름
)
코드처럼 queryParam("value", "key") 값을 넣어주면 된다.
테스트하고자하는 코드가 post요청이었기 때문에 post(”url”)로 입력하고 content로 given에서 설정한 객체를 입력, contentType을 JSON으로 입력했다.
content에서 객체가 JSON으로 입력되야하기때문에 objectMapper.writeValueAsString() 를 사용하여 객체를 스트링으로 변환했다.
응답코드
응답을 확인하기 위해 andDo(MockMvcResultHandlers.print()) 를 사용하여 입력과 응답 모두 프린트할 수 있다.
MockHttpServletRequest:
HTTP Method = POST
Request URI = /api/v1/products/new
Parameters = {}
Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"83"]
Body = {"type":"HANDMADE","sellingStatus":"SELLING","name":"아메리카노","price":4000}
Session Attrs = {}
Handler:
Type = sample.cafekiosk.spring.api.controller.product.productController
Method = sample.cafekiosk.spring.api.controller.product.productController#createProduct(ProductCreateRequest)
...
MockHttpServletResponse:
Status = 200
Error message = null
Headers = [Content-Type:"application/json"]
Content type = application/json
Body = {"code":200,"status":"OK","message":"OK","data":{"id":1,"productNumber":"001","type":"HANDMADE","sellingStatus":"SELLING","name":"아메리카노","price":4000}}
Forwarded URL = null
Redirected URL = null
Cookies = []
눈으로 응답과 요청이 잘 나왔는지 확인하고 넘어갈 수 있지만 MockMvcResultMatchers.jsonPath().value() 를 사용하여 응답의 특정값을 검증할 수 있다. jsonPath() 안에는 “$.data” 와 같은 표현식으로 JSON 객체에 접근할 수 있다.
위의 프로덕션 코드의 예외 케이스이다. 예외를 발생시키기 위해 given 절에서 상품 타입을 주석처리하여 검증단계에서 예외를 유도했다.
@DisplayName("신규 상품을 등록할 때 상품 타입은 필수값이다.")
@Test
void createProductWithoutType() throws Exception {
// given
ProductCreateRequest request = ProductCreateRequest.builder()
// .type(ProductType.HANDMADE)
.sellingStatus(ProductSellingStatus.SELLING)
.name("아메리카노")
.price(4000)
.build();
// when // then
mockMvc.perform(
MockMvcRequestBuilders.post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isBadRequest())
.andExpect(MockMvcResultMatchers.jsonPath("$.code").value("400"))
.andExpect(MockMvcResultMatchers.jsonPath("$.status").value("BAD_REQUEST"))
.andExpect(MockMvcResultMatchers.jsonPath("$.message").value("상품 타입은 필수입니다."))
.andExpect(MockMvcResultMatchers.jsonPath("$.data").isEmpty());
}
위 해피 케이스와 같은 메서드를 사용하여 어떻게 나왔는지 잘 보일 것이다.
응답 코드, 메시지, 데이터의 유무를 확인해서 유도한 에러가 발생했는지 확인하는 코드이다.
이 포스트는 Practical Testing: 실용적인 테스트 가이드 강의를 참고하여 작성했습니다.