- Published on
3-Tier-Architecture 테스트 코드 작성하기 (1)
- Authors
- Name
- Chan Sol OH
목차
- 개요
- 좋은 테스트의 5가지 속성 (F.I.R.S.T)
- Given-When-Then 패턴
- 어노테이션 기반의 테스트 방식. JUnit
- JUnit의 생명주기
- Controller 테스트
- 슬라이스 테스트
- Service 테스트
- 주의할 점
개요
우리 팀이나 내가 코드를 잘 짰겠지만, 만에 하나 잘못된 코드를 작성한 경우, 혹은 정상적으로 돌아가지만 로직이 잘 못된 경우 그 오류를 찾기는 매우 힘들다. 그렇기 때문에 모듈 단위(메서드 단위)로 테스트하는 단위 테스트와 외부 요인(DB, 네트워크)를 포함한 통합 테스트를 꼭 수행할 필요가 있다. 이번 포스팅에서는 sol-coupang-restapi 과제의 테스트 코드를 짜겠다.
이번 포스팅은 Controller와 Service의 테스트 코드를 작성한다.
좋은 테스트의 5가지 속성 (F.I.R.S.T)
- Fast(빠르게) : 테스트는 빠르게 진행돼야 코드의 피드백을 빠르게 받을 수 있다. 특히 단위 테스트는 메서드 단위의 빠른 피드백이 필요하기 때문에 외부 환경을 사용하지 않거나 단순한 설정을 작성한다.
- Isolated(독립적) : 하나의 테스트 코드는 하나의 대상에 대해서만 수행돼야 한다. 만약 한 테스트가 다른 테스트 코드와 상호작용하거나 관리할 수 없는 외부 소스를 사용하게 되면 외부 요인으로 인해 정확한 테스트가 수행되지 않을 수 있다.
- Repeatable(반복 가능한) : 테스트는 외부 환경(개발 환경의 변화, 네트워크 연결 여부)과 상환 없이 반복 가능해야한다. 이는 Isolated와 밀접하게 연관돼있다.
- Self-Validating(자가 검증) : 테스트 코드는 그 자체만으로도 테스트의 검증이 완료돼야 한다. 따라서 테스트 결과의 정답도 함께 가지고 있어야한다. 그리고 결과값과 기댓값을 비교하는 작업은 자동화해야한다.
- Timely(적시에) : TDD를 위한 속성. 테스트 코드는 애플리케이션 코드를 구현하기 전에 완성돼야한다. 그렇지 않으면 애플리케이션읭 문제를 너무 늦게 발견하게돼고 문제 해결을 위해 소모되는 개발 비용도 커진다.
Given-When-Then 패턴
Given-When-Then 패턴은 테스크 코드를 표현한느 방식 중 하나이다.
- Given : 테스트를 수행하기 전에 테스트에 필요한 환경을 설정하는 단계로 테스트에 필요한 변수나 Mock 객체를 통해 특정 상황에 대한 행동을 정의한다.
- When : 테스트 목적을 보여주는 단계로 테스트를 통한 결과값을 가져오게 된다.
- Then : 테스트 결과를 검증하는 단계로 When에서 나온 결괏값을 검증하는 작업을 수행한다. 이 테스틀르 통해 나온 결과에서 검증해야 하는 부분이 있다면 이 단계에 포함한다.
Given-When-Then 패턴은 코드의 흐름을 볼 수 있고 기대값이 명확하기 때문에 명세 문서
의 역할을 할 수 있어 단위 테스트나 통합 테스트에 자주 사용된다.
어노테이션 기반의 테스트 방식. JUnit
JUnit은 단위 테스트와 통합 테스트르 할 수 있는 테스트 프레임워크로 어노테이션을 이용해서 테스트 코드를 작성할 수 있다. 또한 assert
문을 통해 테스트 케이스의 기댓값과 결괏값을 비교할 수 있다. JUnit의 구조는 JUnit Platform가 뼈대 역할하는 인터페이스를 제공하고 JUnit Vintage와 JUnit Jupiter가 구현체 역할을 수행한다.
JUnit의 생명주기
- @Test : 테스트 코드를 포함한 메서드를 정의
- @BeforeAll : 테스트 시작 전에 호출되는 메서드를 정의
- @BeforeEach : 각 테스트 메서드가 실행되기 전에 동작하는 메서드를 정의
- @AfterAll : 테스트를 종료하면서 호출되는 메서드를 정의
- @AfterEach : 각 테스트 메서드가 종료되면서 호출되는 메서드를 정의
따라서 테스트가 진행되면서 BeforeAll과 AfterAll는 한번만 실행되고 BeforeEach와 AfterEach는 메서드가 실행될 때마다 실행된다. 아래는 그 예시
13:58:04.924 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --BeforAll 호출
13:58:04.934 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --BeforEach 호출
13:58:04.935 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --test 시작
13:58:04.935 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --afterEach 호출
13:58:04.937 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --BeforEach 호출
13:58:04.937 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --displayNameTest 시작
13:58:04.937 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --afterEach 호출
13:58:04.938 [Test worker] INFO com.example.solcoupang.TestLifeCycle -- --AfterAll 호출
Controller 테스트
Controller는 개발자가 직접 작성한 코드 중 가장 클라이언트와 가까운 부분이다. 특징으로는 외부로 입,출력 그리고 Service 계층으로 입,출력을 담당한다. Controller를 테스트하기 위해선 Service 계층의 Mock 객체가 필요하다. 그렇지 않으면 비즈니스 로직을 거치지 않은 단순 외부 입출력만 가능하기 때문이다. 테스트할 ProductControllerV1.java
는 다음과 같다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/products")
@Slf4j
public class ProductControllerV1 {
private final ProductService productService;
@GetMapping(value = "/{productId}")
public BaseResponse<ProductDto> getProductDetail(@PathVariable Long productId){
ProductDto productDto = productService.findByProductId(productId);
return new BaseResponse(productDto);
}
}
위와 같은 메서드는 아래와 같은 조건을 충족시키는 Test가 필요하다.
- Service 객체를 대신할 Mock 객체 생성 :
BDDMockito.given
- Mock 객체 출력 선언 :
willReturn
- 가상의 HTTP 입력에 따른 Controller 결괏값 :
MockMVC.perform
- 결괏값과 기댓값 비교 :
Mockito.verify
@WebMvcTest(ProductControllerV1.class) //슬라이스 테스트
//@RequiredArgsConstructor
public class ProductControllerTest {
private final MockMvc mockMvc;
@Autowired
public ProductControllerTest(MockMvc mockMvc) { // Mock 객체 불러오기
this.mockMvc = mockMvc;
}
// 서비스 Mock 객체 만들기
@MockBean
ProductService productService;
@Test
@DisplayName("Mock MVC를 통한 Product 데이터 가져오기 테스트")
void getProductDetail() throws Exception{ // 서비스 Mock 객체의 입출력 선언
// 컨트롤러가 사용하는 Mock ProductService 객체를 선언
given(productService.findByProductId(1L))
.willReturn(
ProductDto.builder().productId(1L)
.productName("test1")
.sellerId(1L)
.registrationDate(LocalDate.parse("2023-11-16"))
.countryOfManufacture("한국")
.weight(1000L)
.kcCertificationInformation("한국 : 응! 인정!")
.manufacturer("당근")
.importer("한국물류")
.productContentDtoList(List.of("https:s3//djsfdl","https:s3//djsfdl","https:s3//djsfdl"))
.build()
);
String productId = "1";
mockMvc.perform(
get("/api/v1/products/detail/"+productId) // Controller에 입력 선언
).andExpect(status().isOk()) // Controller 출력 검증
.andExpect(jsonPath("$.isSuccess").exists())
.andExpect(jsonPath("$.productId").exists())
.andExpect(jsonPath("$.sellerId").exists())
.andDo(print());
verify(productService).findByProductId(1L); // findByProductId 메서드가 실행됐는지 확인
}
}
위와 같이 테스트를 작성하면 아래와 같은 에러가 발생한다. 그 이유는 출력에 productId
가 없기 때문이다.
MockHttpServletResponse:
Status = 200
Error message = null
Headers = [Content-Type:"application/json"]
Content type = application/json
Body = {"isSuccess":true,"code":"OK","message":"요청에 성공하였습니다.","result":{"productId":1,"sellerId":1,"registrationDate":"2023-11-16","productName":"test1","countryOfManufacture":"한국","weight":1000,"kcCertificationInformation":"한국 : 응! 인정!","manufacturer":"당근","importer":"한국물류","productContentDtoList":["https:s3//djsfdl","https:s3//djsfdl","https:s3//djsfdl"]}}
Forwarded URL = null
Redirected URL = null
Cookies = []
java.lang.AssertionError: No value at JSON path "$.productId"
아래처럼 검증 부분을 수정하면 정상적인 테스트가 가능하다.
mockMvc.perform(
get("/api/v1/products/detail/"+productId)
).andExpect(status().isOk())
.andExpect(jsonPath("$.isSuccess").exists())
.andExpect(jsonPath("$.result.productId").exists())
.andExpect(jsonPath("$.result.sellerId").exists())
.andDo(print());
슬라이스 테스트
@WebMvcTest
어노테이션을 이용한 테스트는 슬라이스(Clice) 테스트라고 한다. 슬라이스 테스트는 단위 테스트와 통합 테스트의 중간 개념으로, 3-Tier-Architecture를 기준으로 각 레이어별로 나누어 테스트를 진행한다는 의미를 가진다. 단위 테스트는 의미상 외부와 모든 연결을 차단하고 테스트해야하지만, Controller는 역할 자체가 연결이기 때문에 슬라이스 테스트가 적합하다.
mock.perform
을 이용하면 서버로 RESTAPI 요청을 보내는 것처럼 컨트롤러를 테스트할 수 있다. 이번 테스트는 Get
요청에대해서만 테스트했지만, MockMVCRequestBuilders
는 POST, PUT, DELETE에 매핑되는 메서드를 제공한다.
Service 테스트
Service 객체를 테스트하기 위해 Repository 객체의 Mocking만 수행하고 모든 외부와 연결을 차단한체 단위 테스트를 진행하는 것이 좋을 것이다. 테스트할 ProductService
는 다음과 같다.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductDao productDao;
private final ProductRepository productRepository;
public ProductDto findByProductId(Long id){
Product product = productRepository.findByProductIdWithFetch(id);
return ProductDto.fromEntity(product);
}
}
서비스 코드의 테스트는 아래처럼 작성할 수 있다.
public class ProductServiceTest { // 단위 테스트, 외부 연결 X
private ProductRepository productRepository = Mockito.mock(ProductRepository.class);
private ProductServiceV2 productService;
@BeforeEach
public void setUpTest(){ // ProductServiceV2 객체 생성을 위한 생성자를 통한 DI
productService = new ProductServiceV2(productRepository);
}
@Test
void getProductByIdTest(){
Seller seller = Seller.builder()
.sellerId(1L)
.build();
Product givenProduct = Product.builder()
.productId(1L)
.seller(seller)
.productName("삼애성플")
.productContents(new ArrayList<>())
.build();
// Repository 객체의 동작을 가정한다.
Mockito.when(productRepository.findByProductIdWithFetch(1L))
.thenReturn(givenProduct);
// Serivce 객체 결과값 얻기
ProductDto productDto = productService.findByProductId(1L);
// 결과값과 기대값 비교
assertEquals(productDto.getProductId(), givenProduct.getProductId());
assertEquals(productDto.getProductName(), givenProduct.getProductName());
assertEquals(productDto.getSellerId(), givenProduct.getSeller().getSellerId());
// 메서드 사용했는지 확인
verify(productRepository).findByProductIdWithFetch(1L);
}
}
Product
객체 선언- Mockito로 ProductRepoistory의 입,출력 가정
- ProductService의 결과값 생성
Assertions.assertEquals
를 이용해서 결과값과 기대값 비교verify
를 통해 메서드 사용 확인
주의할 점
Jupiter는 스프링 빈을 직접 가져오지 못한다. : 테스트 실행주체는 Spring이 아닌 Jupiter이기 때문에
@Autowired
를 명시적으로 선언해줘야 Jupiter가 Spring Container에게 빈 주입을 요청한다. 아래는 MockMvc 빈을 주입받는 예시Autowired.javaprivate final MockMvc mockMvc; @Autowired public ProductControllerTest(MockMvc mockMvc) { this.mockMvc = mockMvc; }
verify
를 메서드가 테스트 중 실행되는지 확인할 수 있다. 만약 Mocking된 메서드가 실행되지 않는다면 아래와 같은 에러가 발생하고 테스트는 실패된다. 에러는 호출되지 않은 메서드와 정상적으로 호출된 메서드를 보여준다.Wanted but not invoked: productRepository.findByProductId(1L); -> at com.example.solcoupang.service.ProductServiceTest.getProductByIdTest(ProductServiceTest.java:59) However, there was exactly 1 interaction with this mock: productRepository.findByProductIdWithFetch( 1L ); -> at com.example.solcoupang.product.service.ProductServiceV2.findByProductId(ProductServiceV2.java:15)
Service의
save
를 테스트할 때는 아래와 같이 작성했다. 그러나 문제는ProductService
가ProductRepoistory
로 부터seller
객체를 얻지 못하는 상황이 발생했다.ProductServiceTest.java... @Test void saveProductTest(){ Mockito.when(productRepository.save(any(Product.class))) .then(returnsFirstArg()); ProductDto productDto = productService.saveProduct(ProductRequestDto.builder() .sellerId(1L) .productName("삼애성플") .productContents(new ArrayList<>()) .build()); assertEquals(productDto.getProductName(), "삼애성플"); assertEquals(productDto.getSellerId(), 1L); assertEquals(productDto.getProductContentDtoList(), new ArrayList<>()); verify(productRepository).save(any()); }
java.lang.NullPointerException: Cannot invoke "com.example.solcoupang.product.domain.Seller.getSellerId()" because the return value of "com.example.solcoupang.product.domain.Product.getSeller()" is null
위와 같은 문제를 해결하기 위해선
saveProduct
메서드 내부의SellerRepository
의 동작을 아래처럼 Mokito로 가정해줘야한다.Mockito.when(sellerRepository.findBySellerId(1L)) .thenReturn(seller);
위처럼
sellerRepository
와productRepository
의 동작을 가정하고 테스트하면saveProduct
메서드의 입출력 그리고 내부 비즈니스 로직을 테스트할 수 있다.