스프링이 포함된 테스트는 순수 자바 코드의 테스트와 다르게 신경써야할 점들이 있습니다.
개인적으로 스프링에 테스트를 짤 때 신경써야할 점에 대해서, 정리해보도록 하겠습니다.
스프링에서 테스트를 하면, 순수 자바의 테스트보다 속도가 느립니다.
스프링에선 빈을 사용하기 위해 ApplicationContext를 사용하기 때문입니다. 실제 테스트 시간의 대부분은 ApplicationContext를 만드는 데 쓰입니다.
ApplicationContext를 사용하지 않는 단순한 도메인 테스트(OrderTest)는 417ms가 걸린 반면,
ApplicationContext를 사용하는 통합 테스트(OrderIntegrationTest)는 1sec 282ms가 걸린 것을 볼 수 있습니다.
테스트 속도는 테스트 코드 품질 중에서 무시할 수 없는 요소입니다. 테스트 자체가 빠른 피드백을 위한 도구인데, 속도가 느리다면 장점이 퇴색되기 때문이죠.
그렇다면, 속도를 높이기 위해서 저희는 어떤 시도를 할 수 있을까요?
Once the TestContext framework loads an
ApplicationContext
(orWebApplicationContext
) for a test, that context is cached and reused for all subsequent tests that declare the same unique context configuration within the same test suite.테스트에 대한 ApplicationContext(또는 WebApplicationContext)를 로드하면 해당 컨텍스트는 캐시되어 동일한 테스트 스위트 내에서 동일한 고유 컨텍스트 구성을 선언하는 모든 후속 테스트에 재사용됩니다.
스프링 공식문서에 나온 내용입니다.
간단하게 테스트에서 ApplicationContext를 로드하면 이를 캐싱하고, 이후의 동일한 구성의 ApplicationContext가 필요한 테스트에서 재사용한다는 내용입니다.
이러한 캐싱을 잘 이용한다면, 테스트에 걸리는 시간을 크게 단축시킬 수 있습니다.
이전에 저는 @Import 어노테이션을 사용하여, 해당 테스트에서만 사용하는 Bean들만 ApplicationContext에 등록시키는 방식으로 테스트를 작성하였습니다.
이렇게 사용하면, 테스트에서만 사용된 빈들만 생성되므로, 조금 더 효율적이고 빠른 테스트가 작성될 줄 알았습니다.
하지만, 그렇지 않았죠.
이런식으로 특정 테스트에 최적화하는 식으로 Bean들을 등록하면, 캐싱된 ApplicationContext를 사용할 수 없습니다.
등록하려는 빈을 4,5개 줄이려다가 캐싱기능을 활용하지 못했기에, 오히려 더 많은 시간이 걸린 것입니다.
그래서 저는 사용하지 않는 빈이 등록되더라도, 기존 캐싱된 Context를 활용하는 방식으로 테스트를 작성하는 방식으로 수정했습니다.
하지만, 테스트를 작성할 때마다 매번 동일한 구성을 기억해서 사용하는 것은 매우 귀찮습니다.
그렇다고, 모든 테스트에서 @SpringBootTest를 붙일 수도 없습니다. 특정 테스트에서는 다양한 설정을
다음과 같은 방법으로 동일한 구성의 컨텍스트를 유지하면서 사용하게 할 수 있습니다.
이 방법은 우테코 크루에게 공유받은 방법입니다. (정말 뛰어난 사람들이 많은 것 같습니다.)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@JdbcTest(includeFilters = {
@Filter(type = FilterType.ANNOTATION, value = Repository.class)
})
public @interface RepositoryTest {
}
위와 같은 식으로, @Repository 어노테이션이 붙은 클래스를 모두 빈으로 등록하는 새로운 Annotation을 정의할 수 있습니다.
여기서, 조금 더 다양한 설정을 추가할 수도 있습니다. @Profie을 이용해 해당 테스트에서만 사용하는 application.properties를 정의할 수도 있죠. 사용하기에 따라 다양한 방향으로 확장할 수 있는 방법입니다.
사용할 때 단순하게 해당 Annotation만 붙이면 되니, 더욱 편하게 컨텍스트의 설정을 관리할 수 있습니다.
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public abstract class IntegrationTest {
@Autowired
protected MockMvc mockMvc;
}
다음은 테스트에서 abstract class를 상속하는 방법입니다.
클래스를 상속한다는 것은, 사실 부모클래스가 먼저 초기화 되고, 자식클래스가 초기화되는 방식이기에, 부모클래스에 적용된 어노테이션을 자식클래스에게 모두 적용되는 효과를 얻을 수 있습니다.
이렇게 하면, @Autowired를 통해 주입받은 객체 또한 편하게 자식 테스트 클래스에서 사용할 수 있다는 장점도 있습니다.
웹 환경이 도입되면서, 입력부터 출력까지 검증하는 E2E테스트를 추가로 작성하게 됬습니다.
스프링에서는 E2E 테스트를 위해 크게 두 가지 도구를 이용할 수 있는데, 두 개 다 사용해보고 난 장단점을 정리해봤습니다.
RestAssured는 실제 웹환경을 띄워서 테스트하는 방식으로 E2E테스트를 지원합니다.
아래와 같이 작성할 수 있죠.
@Sql("/init-integration-data.sql")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class IntegrationTest {
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
}
}
SpringBootTest에서 RANDOM_PORT로 설정을 하고, 이를 RestAssured에 port로 등록하는 과정이 필요합니다.
RestAssured는 실제 웹 요청을 하기에, 어떠한 포트로 요청을 보낼지 세팅을 해줘야 하는 것이죠.
그렇게 해서 다음과 같이 테스트를 작성할 수 있습니다.
@DisplayName("경로 조회 기능 테스트")
class PathIntegrationTest extends IntegrationTest {
@DisplayName("출발점과 도착점으로 경로와 총 거리와 금액을 구한다.")
@Test
void findPath() {
final PathRequest pathRequest = new PathRequest(SAMSUNG.getName(), SEONGLENUG.getName());
final StationResponse samsung = new StationResponse(SAMSUNG.getId(), SAMSUNG.getName());
final StationResponse seongLenug = new StationResponse(SEONGLENUG.getId(), SEONGLENUG.getName());
final PathResponse expectedResponse =
new PathResponse(List.of(samsung, seongLenug), 1250, 5);
final ExtractableResponse<Response> response = RestAssured
.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(pathRequest)
.when().get("/path")
.then().log().all().extract();
// then
final PathResponse pathResponse = response.as(PathResponse.class);
assertThat(pathResponse)
.usingRecursiveComparison()
.isEqualTo(expectedResponse);
}
}
RestAssured는 as와 같은 메서드를 지원해서, 실제 요청의 반환값을 자바 객체로 쉽게 매핑할 수 있습니다. 테스트하기가 더욱 용이해지지요.
그리고 given when then의 bdd 스타일이기에 가독성이 뛰어나다는 점도 있습니다.
하지만, RestAssured는 큰 단점이 있는데 바로 @Transactional을 사용하지 못한다는 것 입니다.
그렇기에, 테스트간 격리를 위해서 매번 @Sql("/init-integration-data.sql")
와 같은 어노테이션으로 데이터베이스를 초기화 해줘야 하죠.
많은 사람들이 MockMvc를 이용해서 Controller나 Interceptor의 sliceTest를 하고 있습니다.
하지만 MockMvc또한, E2E테스트에 사용할 수 있는 도구입니다.
MockMvc는 웹 환경을 Mocking하는 것으로 실제로 서블릿 컨테이너를 모킹한 객체입니다. 그렇기에 모든 Bean을 등록하는 @SpringBootTest와 함께 사용할 수 있죠.
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public abstract class IntegrationTest {
@Autowired
protected MockMvc mockMvc;
}
@AutoConfigureMockMvc는 자동으로 MockMvc 객체를 만들어주는 어노테이션입니다. 해당 어노테이션으로 유저가 만든 Filter들도 다 MockMvc에 적용되게 하죠.
실제로 위와 같은 클래스를 정의하고 아래와 같이 사용할 수 있습니다.
class OrderIntegrationTest extends IntegrationTest {
@DisplayName("쿠폰을 사용하지 않은 주문내역을 조회한다.")
@Test
void findOrderWithoutCoupon() throws Exception {
//given
final Product savedProduct = productRepository.insertProduct(PRODUCT_1);
final CartItem savedCartItem = cartItemDao.save(new CartItem(3, MEMBER_1, savedProduct));
final List<CartItem> cartItems = List.of(savedCartItem);
final Order savedOrder = orderRepository.save(Order.of(MEMBER_1, cartItems));
//when
final MvcResult result = mockMvc
.perform(get("/orders/" + savedOrder.getId())
.header("Authorization", MEMBER_1_AUTH_HEADER))
.andDo(print())
.andExpect(status().isOk())
.andReturn();
//then
final String resultJsonString = result.getResponse().getContentAsString();
final OrderResponseDto response = OBJECT_MAPPER.readValue(resultJsonString, OrderResponseDto.class);
assertThat(response)
.usingRecursiveComparison()
.isEqualTo(OrderResponseDto.from(savedOrder));
}
}
mockMvc에서 결과를 자바객체로 매핑해주는 기능을 지원하지 않기에, ObjectMapper를 이용하여서, 직접 역직렬화를 해줘야 합니다.
하지만, WebEnviroment가 RandomPort가 아닌 Mock이기에, @Transactional을 사용할 수 있습니다. 별도의 trucnate를 하는 sql을 작성하고 관리할 필요는 없죠.
덕분에 테스트를 격리하는 데 굉장히 편합니다.
두 테스트 방법을 모두 사용해봤는데, 의미적인 차이는 딱 서블릿 컨테이너를 모킹했냐, 직접 웹 환경을 띄우냐
정도의 차이입니다.
가장 크게 와닿았던 차이는 @Transactional을 사용할 수 있냐, 사용할 수없냐 정도의 차이가 있습니다.
그리고, 실제 MockMvc는 실제 서블릿 컨테이너를 띄우지 않기에, 속도가 조금 더 빠르고, andDo(print())로 콘솔로 봤을 때 실제로 어떤 요청이 오갔는지 파악하기 편한다는 장점이 있습니다.
각자의 장단점이 있기에 본인에게 편한 방식을 사용하면 좋을 것 같습니다.
잘 읽고 감둥