예전에 토비 스프링을 공부하며 테스트 코드에 공부한 적이 있었습니다.
스프링이 개발자에게 제공하는 가장 중요한 가치가 무엇이냐고 질문한다면 나는 주저하지 않고 객체지향과 테스트라고 대답할 것이다 - 토비 스프링 글귀
토비 스프링에서 테스트는 애플리케이션의 빠른 변화에 대응하여 개발자가 작성한 코드를 확신할 수 있게 해주고, 변화에 유연하게 대처할 수 있는 자신감을 주는 기술이라 강조하였습니다.
글쓴이는 이제껏 개발을 진행하며 테스트 코드를 생략하고 로그를 활용하여 해당 로직이 제대로 동작하는지를 검사하였습니다.
테스트는 결국 내가 예상하고 의도했던 대로 코드가 정확히 동작하는지를 확인해서, 만든 코드를 확신할 수 있게끔 하는 검사인데 로그를 활용하여도 충분히 테스트 결과를 확인할 수 있다고 생각하였습니다.
하지만 이는 테스트의 단위를 명확히 정하지 못할 뿐더러 다른 팀원이 나의 코드를 봤을 때 무엇에 대한 테스트인지 모르기 때문에 좋지 않은 습관이라 생각하였습니다.
그래서 이번 기회에 테스트 코드를 작성하는 방법을 공부하며 실제 개발에도 적용하려는 습관을 들이려고 합니다.
@SpringBootTest
는 스프링 부트 애플리케이션 컨텍스트를 전체적으로 로드하여 실제 환경과 유사한 조건에서 테스트를 실행할 수 있도록 합니다.
@SpringBootTest
class ProductServiceIntegrationTest {
@Autowired
private ProductService productService;
@Test
void testProductServiceIntegration() {
// given
Long productId = 1L;
// when
Product product = productService.getProductDetails(productId);
// then
assertNotNull(product);
assertEquals(productId, product.getId());
}
}
@DataJpaTest
는 JPA 컴포넌트에 초점을 맞춰 데이터 액세스 계층만 테스트하는데 사용됩니다. 이 어노테이션을 사용하면 JPA 관련 설정, 엔티티, 레포지토리 등만 로드되며, 데이터베이스와의 상호작용을 테스트할 수 있습니다.
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Test
void testFindByProductId() {
// given
Product product = new Product(1L, "Laptop", 999.99);
productRepository.save(product);
// when
Optional<Product> foundProduct = productRepository.findById(1L);
// then
assertTrue(foundProduct.isPresent());
assertEquals(product.getId(), foundProduct.get().getId());
}
}
@WebMvcTest
는 웹 계층, 주로 컨트롤러를 테스트하는 데 사용됩니다. 이 어노테이션은 웹 계층에 필요한 컴포넌트만 로드합니다.
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService;
@Test
void testGetProduct() throws Exception {
// given
when(productService.getProductDetails(1L)).thenReturn(new Product(1L, "Laptop", 999.99));
// when & then
mockMvc.perform(get("/products/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id", is(1)))
.andExpect(jsonPath("$.name", is("Laptop")));
}
}
@RestClientTest
는 REST 클라이언트를 테스트하기 위해 사용합니다. Rest 통신을 수행하는 데 필요한 컴포넌트를 설정합니다.
@RestClientTest(ProductServiceClient.class)
class ProductServiceClientTest {
@Autowired
private MockRestServiceServer server;
@Autowired
private ProductServiceClient client;
@Test
void testGetProductDetails() {
// given
server.expect(requestTo("/products/1"))
.andRespond(withSuccess("{\"id\": 1, \"name\": \"Laptop\", \"price\": 999.99}", MediaType.APPLICATION_JSON));
// when
Product product = client.getProductDetails(1L);
// then
assertEquals(1L, product.getId());
}
}
@Service
public class ProductServiceClient {
private final RestTemplate restTemplate;
private final String productServiceUrl;
public ProductServiceClient(RestTemplate restTemplate, @Value("${product.service.url}") String productServiceUrl) {
this.restTemplate = restTemplate;
this.productServiceUrl = productServiceUrl;
}
public Product getProductDetails(Long productId) {
String url = productServiceUrl + "/products/" + productId;
return restTemplate.getForObject(url, Product.class);
}
}
MockRestServiceServer
목적: REST 클라이언트의 동작을 테스트하기 위해 사용됩니다. 클라이언트가 외부 API에 요청을 보낼 때, 그 요청을 가로채고 모의 응답을 제공함으로써 클라이언트의 처리 로직을 검증할 수 있습니다.
적용 범위: 주로 외부 API와의 통신을 담당하는 REST 템플릿 또는 웹 클라이언트 사용 코드를 테스트하는 데 사용됩니다.
동작 방식: RestTemplate이나 WebClient와 같은 HTTP 클라이언트의 요청을 가로채어 모의 응답을 반환합니다. 이를 통해 실제 외부 서비스를 호출하지 않고도, 클라이언트가 외부 API와의 통신을 올바르게 처리하는지 확인할 수 있습니다.
MockRestServiceServer를 사용하는 목적은 실제 외부 서비스에 대한 요청을 하지 않으면서 ProductServiceClient의 행동을 검증하기 위함입니다.
요청 검증: ProductServiceClient가 올바른 URL, 올바른 HTTP 메소드, 필요한 경우 올바른 페이로드(body)로 요청을 보내고 있는지 확인합니다. 즉, 클라이언트가 예상대로 외부 API와의 통신을 구성하고 있는지를 테스트합니다.
응답 처리 검증: MockRestServiceServer로부터 받은 모의 응답을 ProductServiceClient가 어떻게 처리하는지 확인합니다. 이는 클라이언트가 API로부터 데이터를 받았을 때 그 데이터를 올바르게 파싱하고, 필요한 비즈니스 로직을 수행하며, 적절한 모델 또는 객체로 변환하는지 등을 검증할 수 있습니다.
@MockBean
는 애플리케이션의 빈을 목 객체로 대체하여 테스트에서 사용할 수 있게 해줍니다. 서비스 또는 레포지토리 같은 의존성을 가짜로 대체하여 테스트를 격리된 상태로 유지할 수 있게 해줍니다.
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService; // ProductService를 목 객체로 생성
@Test
void testGetProductById() throws Exception {
// given
Long productId = 1L;
Product mockProduct = new Product(productId, "Laptop", 999.99);
Mockito.when(productService.getProductDetails(productId)).thenReturn(mockProduct);
// when & then
mockMvc.perform(MockMvcRequestBuilders.get("/products/" + productId))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.id").value(productId))
.andExpect(MockMvcResultMatchers.jsonPath("$.name").value("Laptop"));
}
}
위 테스트에서는 ProductService의 getProductDetails 메서드가 특정 ID의 제품을 반환하도록 설정되었습니다. MockMvc를 사용하여 HTTP GET 요청을 시뮬레이션하고, 응답으로 예상된 JSON 데이터가 있는지 검증합니다.
"테스트의 범위를 어떻게 나눌까?"는 테스트 코드 작성 전 고려해야할 중요한 사항입니다.
테스트하고자 하는 대상이 명확하다면 그 대상에만 집중해서 테스트하는 것이 바람직하며 이런식으로 작은 단위의 코드에 대해 테스트를 수행한 것을 단위 테스트(unit test)라고 합니다.
일반적으로 테스트의 단위는 작을수록 좋지만 단위는 그 크기와 범위가 정해진 것이 아니기 때문에 개발자가 정의함에 따라 달라질 수 있습니다.
각 레이어 별로 테스트 하는 방법에 대해서 알아보겠습니다.
레포지토리 테스트는 데이터베이스와의 상호작용을 검증하는 데 중점을 두어 @DataJpaTest
를 사용하여 JPA 관련 설정만 로드하며, 데이터베이스와의 연결을 위해 H2 같은 인메모리 데이터베이스를 사용할 수 있습니다.
@DataJpaTest
public class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private UserRepository userRepository;
@Test
public void testFindByUsername() {
User user = new User("user1", "user1@example.com");
entityManager.persist(user);
entityManager.flush();
User found = userRepository.findByUsername(user.getUsername());
assertEquals(user.getUsername(), found.getUsername());
}
}
서비스 레이어 테스트는 비즈니스 로직을 검증하며 주로 @MockBean
을 사용하여 필요한 의존성을 목 객체로 대체하고 @SpringBootTest
나 @ExtendWith(SpringExtension.class)
를 사용하여 필요한 스프링 컴포넌트를 로드합니다.
@SpringBootTest
public class UserServiceTest {
@MockBean
private UserRepository userRepository;
@Autowired
private UserService userService;
@Test
public void testFindUserById() {
User user = new User(1L, "user1", "user1@example.com");
Mockito.when(userRepository.findById(1L)).thenReturn(Optional.of(user));
User result = userService.findUserById(1L);
assertEquals("user1", result.getUsername());
}
}
컨트롤러 테스트는 API의 엔드포인트를 검증하는 데 중점을 두며 @WebMvcTest
를 사용하여 해당 컨트롤러 관련 컴포넌트만 로드하며, MockMvc
를 사용하여 HTTP 요청과 응답을 시뮬레이션합니다.
@WebMvcTest(UserController.class)
public class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
public void testGetUserById() throws Exception {
User user = new User(1L, "user1", "user1@example.com");
Mockito.when(userService.findUserById(1L)).thenReturn(user);
mockMvc.perform(get("/api/users/1")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("user1"));
}
}