이번 포스팅에서는, 계층별 테스트 코드 작성을 해보도록 하겠습니다.
https://galid1.tistory.com/783
테스트코드를 작성해야하는 이유와, 간단한 팁들은 위 링크에 정리해두었습니다.
1. Domain 계층 테스트
Domain 계층에서 테스트할 대상은 각 도메인의 애그리거트루트입니다. 이 글에서는 ItemEntity, ItemService, ItemController만을 테스트 하도록 하겠습니다.
테스트 할 것
- removeStockQuantity 호출시 주문 수량만큼 재고량이 차감이 되는지.
- 재고량보다 많은 수의 주문을 할 시 에러가 발생하는지.
- 재고량 추가시 추가수량만큼 재고량이 증가하는지.
테스트 해야할 것들을 확인 했으니, 이제 테스트 코드를 작성해보겠습니다.
- 재고량 차감 테스트
class ItemEntityTest {
public void 주문시_재고량_차감() throws Exception {
//given
int STOCK_QUANTITY = 10;
ItemEntity itemEntity = ItemEntity.builder()
.categoryId(1l)
.imagePath("TEST")
.name("TEST")
.price(1000)
.stockQuantity(STOCK_QUANTITY)
.build();
//when
int ORDER_QUANTITY = 2;
itemEntity.removeStockQuantity(ORDER_QUANTITY);
//then
assertThat(itemEntity.getStockQuantity(), is(STOCK_QUANTITY - ORDER_QUANTITY));
}
}
도메인 계층의 경우, 중요 비즈니스로직들이 모여있는 곳으로, 어떠한 의존성도 가지지 않도록 하는것이 중요합니다. 이를 통해서 우리는 테스트하기 쉬운 코드를 얻어냈습니다. 위와 같이 아무런 의존성제어도 필요없이 테스트 대상인 ItemEntity만을 만들어 테스트를 진행합니다. (테스트코드는 given, when, then 절을 이용해 작성하면 가독성을 높일 수 있습니다. )
우선 given 절
에서 재고량이 10
인 ItemEntity를 생성합니다. 이어 when절
에서는 itemEntity의 removeStockQuantity를 호출합니다. 마지막으로 then 절
에서, 결과값으로 기대하는 값을 assert문을 이용해 검증합니다.
- 재고량 초과 주문시 에러발생 테스트
xclass ItemEntityTest {
public void 주문시_재고량_차감() throws Exception { ... }
public void 재고량을_초과하는_주문시_에러() throws Exception {
//given
int STOCK_QUANTITY = 10;
ItemEntity itemEntity = ItemEntity.builder()
.categoryId(1l)
.imagePath("TEST")
.name("TEST")
.price(1000)
.stockQuantity(STOCK_QUANTITY)
.build();
//when, then
int OVER_ORDER_QUANTITY = 20;
assertThrows(NotEnoughStockQuantityException.class,
() -> itemEntity.removeStockQuantity(OVER_ORDER_QUANTITY));
}
}
이번에 테스트할 것은 재고량 이상의 주문을 요청하는 경우 익셉션을 발생시키는지 테스트하는 것입니다. 마찬가지로 itemEntity를 생성합니다.
이번에는 when then절이 합쳐졌는데요, exception을 기대하는 assert문인 assertThrows
에서 테스트 대상 메소드를 함수형 인터페이스 형태로 매개변수로 전달받아 exception이 발생하는지 테스트를 해주기 때문입니다. (Exception테스트는 가독성 면에서는 Junit4가 더 나아보이는것은 왜일까요..)
여기서 잠깐 리팩토링
테스트코드 역시, 가독성과 유지보수성이 중요합니다. 마찬가지로 코드의 일부이기 때문인데요. 따라서 중복된 코드등은 테스트코드에서도 관리를 해주어야 합니다.
xxxxxxxxxx
ItemEntity itemEntity = ItemEntity.builder()
.categoryId(1l)
.imagePath("TEST")
.name("TEST")
.price(1000)
.stockQuantity(STOCK_QUANTITY)
.build();
// 메소드로 중복 코드 추출
private ItemEntity createItemEntity(int stockQuantity) {
return ItemEntity.builder()
.categoryId(1l)
.imagePath("TEST")
.name("TEST")
.price(1000)
.stockQuantity(stockQuantity)
.build();
}
중복되는 부분은 어디일까요. 바로 ItemEntity를 생성하는 부분입니다. 이를 처리하는 가장 간단한 방법은 메소드로 추출을 하는 것입니다.
xxxxxxxxxx
public void 주문시_재고량_차감() throws Exception {
//given
int STOCK_QUANTITY = 10;
ItemEntity itemEntity = createItemEntity(STOCK_QUANTITY);
...
}
public void 재고량을_초과하는_주문시_에러() throws Exception {
//given
int STOCK_QUANTITY = 10;
ItemEntity itemEntity = createItemEntity(STOCK_QUANTITY);
...
}
Entity 생성부분을 메소드로 추출하고 나니, 훨씬 코드가 짧아졌습니다.
- 재고량 추가 테스트
xxxxxxxxxx
public void 재고량_추가() throws Exception {
//given
int STOCK_QUANTITY = 10;
ItemEntity itemEntity = createItemEntity(STOCK_QUANTITY);
//when
int ADD_STOCK_QUANTITY = 10;
itemEntity.addStockQuantity(ADD_STOCK_QUANTITY);
//then
assertThat(itemEntity.getStockQuantity(), is(STOCK_QUANTITY + ADD_STOCK_QUANTITY));
}
마지막 테스트 대상은 재고량 추가입니다. 간단하니 코드만 보여드리겠습니다.
모두 테스트에 통과했습니다.
2. Service 계층 테스트
Mock
각 계층을 테스트할때는 이들을 단위 테스트로 테스팅하는 것이 중요합니다. 단위테스팅을 통해, 테스트할 대상에 집중하고, 테스트 시간을 단축할 수 있기 때문입니다. 문제는 Service layer 등은, 다른 객체에 의존하고 있다는 것인데요. 이 때문에 단위 테스팅이 어려워집니다.
이때 사용하는 것이 바로 Mock 입니다. 가짜 객체를 의미하는데요, 가짜 객체에는 우리가 원하는 방향으로 행동을 미리 지정하고 원하는 결과도 미리 지정할 수가 있어, 우리가 테스트할 대상에 집중할 수 있도록 도와줍니다.
테스트할 것
Service 계층은 Repository로 부터, 도메인 객체를 얻어, 도메인 객체의 메소드를 호출하는 등의 행위를 하는 곳입니다. 따라서, 우리가 테스트할 대상을 생각해본다면 아래의 것들이 있을 수 있습니다.
- Repository를 통해 도메인 객체를 얻어오는지 (X)
- 도메인객체의 메소드를 호츨한 결과가 올바른지 (X)
하지만! 잘 생각해보셔야 합니다. 도메인 객체를 얻어오는지는, Service 계층의 테스트 대상이 아닙니다. Repository의 역할이기 때문입니다. 또한 도메인 객체의 메소드를 호츨한 결과가 올바른지 역시, 도메인 객체의 역할입니다.
따라서 다시 테스트해야 할 것들을 정리해보면 아래와 같습니다.
- Repository의 특정 메소드를 호출하는지
- 도메인 객체의 메소드를 호출 하는지
즉, Service에서 도메인 객체를 얻어오기 위해 Repository의 메소드를 호출하는지, 기능 구현을 위해, 도메인 객체의 메소드를 호출하는지를 검사해야합니다.
- 아이템 추가 테스트
xxxxxxxxxx
MockitoExtension.class) (
class ItemServiceTest {
private ItemService itemService;
private ItemRepository itemRepository;
public void 아이템_추가() {
// given
AddItemRequest addItemRequest = createAddItemRequest();
given(itemRepository.save(any(ItemEntity.class)))
.willReturn(createItem(addItemRequest));
// when
itemService.saveItem(addItemRequest);
// then
verify(itemRepository, atLeastOnce()).save(any(ItemEntity.class));
}
private AddItemRequest createAddItemRequest() {
return AddItemRequest.builder()
.imagePath("TEST")
.name("TEST")
.price(1000)
.stockQuantity(2)
.build();
}
private ItemEntity createItem(AddItemRequest request) {
ItemEntity item = ItemEntity.builder()
.price(request.getPrice())
.name(request.getName())
.imagePath(request.getImagePath())
.stockQuantity(request.getStockQuantity())
.build();
ReflectionTestUtils.setField(item, "itemId", 1l);
return item;
}
}
아이템 추가 테스트에서는, itemRepository의 save() 메소드에 ItemEntity타입의 매개변수를 전달하여 호출했는지를 테스트하면 됩니다.
따라서, 당장 필요없는 의존성인 ItemRepository의 경우, @Mock
어노테이션을 이용해 가짜 객체를 주입했습니다. 또한 가짜 객체를 주입받기 위해 테스트 대상인 ItemService
에는 @InjectMocks
어노테이션을 부여했습니다.
우선, given절
에서 given()
메소드를 이용해, itemRepository.save()
메소드를 호출하면 ItemEntity를 반환하도록 했습니다. when절
에서는 테스트 대상 메소드인 saveItem() 메소드를 호출했습니다. 마지막으로, then절
에서는 verify()
를 이용해 itemService에서 itemRepository의 save() 메소드를 호출했는지를 검증했습니다.
꼭 중복되는 것이 아니어도 메소드 추출을 한다?
위 작성된 테스트 코드를 보면, 중복되는 것이 아님에도 메소드로 추출한것이 보이는데요, 이는 테스트 코드의 가독성을 위해서입니다. 생성하는 코드는 필요이상으로 길기 때문에, 우리가 집중해야할 테스트 코드에 집중하기가 어려워집니다. 따라서 AddItemRequest 객체 생성 메소드를 별도로 두어, 한줄로 "아~ 이부분은 AddItemRequest 객체를 생성하는 곳이군" 하고 넘어갈 수 있도록 했습니다.
- 아이템 찾기 테스트
xxxxxxxxxx
public void 아이템_찾기() throws Exception {
//given
Long ITEM_ID = 1l;
given(itemRepository.findById(any(Long.class)))
.willReturn(Optional.of(createItem(createAddItemRequest())));
//when
itemService.findItem(ITEM_ID);
//then
verify(itemRepository, atLeastOnce())
.findById(any(Long.class));
}
아이템 찾기 테스트역시 마찬가지로 특정 메소드를 호출하는지만을 테스트하면됩니다.
3. Presentation 계층 테스트
MockMvc
Presentation 역시 단위테스트가 가능하도록 해야합니다.
문제는 Service 계층처럼 특정 메소드를 호출하는지만 테스트를 하는 경우, MVC의 테스트 커버리지가 상당히 낮아질 것입니다. 즉, 아래와 같은 것들을 테스트하기가 어렵습니다.
- Handler에서 특정 View를 반환하는지
- Handler에서 Model에 특정 값을 맵핑해주었는지
- 사용자의 요청에 알맞은 값이 포함되어 있는지(Header, Body, Param 등등)
- 올바른 상태값을 반환하는지
...
Spring에서는 위와 같은 문제점으로 인해 Mvc테스트를 돕는 MockMvc
를 제공합니다.
@WebMvcTest, MockMvc 주입방법
MockMvc를 주입받는 방법은 여러가지가 존재하는데요, Presenetation Layer만을 슬라이싱해 테스트하기 위해서는, @WebMvcTest
를 이용해야 합니다. Domain, Service 계층 테스트와 다르게, ApplicationContext가 로드되지만, 서버가 실행되지는 않으며, PresentationLayer 테스트를 위한 Bean들만 ApplicationContext에 로드되어, 비교적 적은 Cost로 테스트를 수행할 수 있습니다.
- 아이템 생성 페이지 테스트
x
value = ItemController.class) (
class ItemControllerTest {
private MockMvc mvc;
private ItemService itemService;
private Long itemId = 1l;
private ItemEntity createItem() {
return ItemEntity.builder()
.imagePath("test")
.name("test")
.price(1000)
.stockQuantity(1)
.build();
}
public void 아이템_생성_페이지() throws Exception {
//given
given(itemService.saveItem(any()))
.willReturn(itemId);
// when
ResultActions resultActions = mvc.perform(get("/items/new"));
// then
resultActions
.andExpect(model().attributeExists("form"))
.andExpect(view().name("items/registerItemForm"))
.andExpect(status().isOk());
}
}
given절
에서 @MockBean
을 이용해 ItemService에 가짜 객체를 담고, when절
에서 MockMvc를 이용해 가짜로 아이템 생성페이지를 요청합니다.
then절
에서는 모의로 페이지를 요청한 결과에대한 응답값을 테스트할 수 있습니다.
나머지 컨트롤러 테스트 코드는 직접한번 작성해보시기 바랍니다!