최근 사내에서 라이브러리 버전을 올리고 나서 배포를 하려고 생각해보니, 어디서 오류가 날지 알 수 없어 매우 불안해하며 배포했던 기억이 있다.
자연스레 머릿속에 통합테스트와 인수테스트가 떠올랐고, 통합 테스트 환경 구축에 대한 이야기를 해보고자 한다.
스프링에서는 @SpringBootTest를 통해 보다 쉽게 통합 테스트 환경을 구축할 수 있다.
이러한 환경을 구성하는 다양한 방법이 있는데 이에 대해 알아보자.
webEnvironment = WebEnvironment.MOCK 와 mockMvc를 이용한 통합 테스트
@SpringBootTest의 기본 설정인 WebEnvironment.Mock은 WebApplicationContext를 실제로 로드하긴 하지만 SpringBoot의 Servletcontainer인 내장 톰캣이 구동되지 않고, 서블릿을 Mocking한 것이 동작한다. 즉, 실제 네트워크 요청을 받지 않는다.
대신, Spring MVC에서 제공하는 MockMvc 객체를 사용하여 요청을 시뮬레이션할 수 있도록 도와준다.
전체 애플리케이션 컨텍스트가 로드되기 때문에, 모든 빈이 생성된다. (Service, Controller 등등)
테스트 예시는 다음과 같다.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc // MockMvc 타입 빈 등록
public class MyControllerTest {
@Autowired
private MockMvc mockMvc; // Mock으로 등록된 서블릿에 접근
@Test
public void testMyController() throws Exception {
mockMvc.perform(get("/my-endpoint")) // url과 매핑되는 컨트롤러에 요청 전달
.andExpect(status().isOk())
.andExpect(content().string("Expected Response"));
}
}
MockMvc를 통한 테스트에서 @Transactional을 통한 테스트 격리
만약, 여기서 조회가 아닌 삽입, 수정 등에 대한 Test를 하고자 할 때는 어떻게 해야할까? 테스트 간의 격리가 필요하기 때문에 다른 테스트들과 마찬가지로 변경한 데이터에 대해 삭제가 필요하다.
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureMockMvc
class MockTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private StationService stationService;
// 아래 테스트가 먼저 수행되면 실패하게 됨
@Test
@DisplayName("지하철 역을 조회한다.")
void findStations() throws Exception {
stationService.saveStation(new StationRequest("강남역"));
mockMvc.perform(get("/stations"))
.andExpect(status().isOk())
.andExpectAll(jsonPath("$.length()").value(1));
}
@Test
@DisplayName("지하철 역을 생성한다.")
void createStation() throws Exception {
mockMvc.perform(
post("/stations")
.contentType(MediaType.APPLICATION_JSON)
.content("{\\"name\\":\\"강남역\\"}")
)
.andExpect(status().isCreated());
mockMvc.perform(get("/stations"))
.andExpect(status().isOk())
.andExpectAll(jsonPath("$.length()").value(1));
}
}
여기서는 여타 일반적인 테스트와 같이 @Transactional을 통해 롤백을 수행할 수 있다. @Transactional을 통해 롤백을 할 수 있는 이유는 MockMvc를 통해 요청을 수행하면, Spring의 DispatcherServlet이 내부적으로 요청을 처리하는데, @SpringBootTest로 실행된 테스트 메서드와 동일한 스레드에서 작동하기 때문이다. 즉, 모두 한 스레드에서 실행되기 때문에 롤백이 정상적으로 수행될 수 있다.
다른 스레드에서 실행되게 되면 롤백이 잘 안이루어질 수 있는데, 스프링의 @Transactional은 ThreadLocal을 기반으로 동작하기 때문에 그렇다. (여기서 주로 다룰 내용은 아니므로 참고만 하도록 하자.)
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureMockMvc
class MockTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private StationService stationService;
@Test
@DisplayName("지하철 역을 조회한다.")
@Transactional
void findStations() throws Exception {
stationService.saveStation(new StationRequest("강남역"));
mockMvc.perform(get("/stations"))
.andExpect(status().isOk())
.andExpectAll(jsonPath("$.length()").value(1));
}
@Test
@DisplayName("지하철 역을 생성한다.")
@Transactional
// 이 테스트를 수행하는 스레드와
void createStation() throws Exception {
// 요청을 수행하는 스레드가 같아 테스트가 끝나고 롤백이 정상적으로 수행됨
mockMvc.perform(
post("/stations")
.contentType(MediaType.APPLICATION_JSON)
.content("{\\"name\\":\\"강남역\\"}")
)
.andExpect(status().isCreated());
mockMvc.perform(get("/stations"))
.andExpect(status().isOk())
.andExpectAll(jsonPath("$.length()").value(1));
}
}
webEnvironment = WebEnvironment.DEFINED_PORT 와 webEnvironment = WebEnvironment.RANDOM_PORT 를 통한 통합테스트
위에서도 볼 수 있듯이 위 두 설정은 실제 내장 톰캣을 띄운다. 즉, 실제 네트워크 요청을 할 수 있고 MockMvc 보다는 실제 환경에 가까운 통합 테스트가 가능하다.
위 두 설정의 차이는 application.yml에 설정한 port를 통해 요청을 받을지 랜덤한 port를 통해 요청을 받을지 정도의 차이가 있다.
(실제 구동 중인 서버와의 포트 충돌 등의 문제 때문에 RandomPort를 사용하는 것이 좋아보인다.)
이렇게 띄워진 서버에 요청을 보내기 위해서는 우리가 스프링 어플리케이션에서 RestTemplate로 요청을 보내듯이 Test에서는 RestAssured를 사용해서 요청을 보낼 수 있다. 다만, 주의해야할 점은 위처럼 Port를 지정하면 어떤 port에서 서버가 요청을 받고 있는지 RestAssured는 알 길이 없으므로 port를 지정해주어야한다.
예시 코드는 다음과 같다.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class RestAssuredTest {
@LocalServerPort
int port;
@BeforeEach
void setUp() {
// 이 설정을 해주지 않으면 요청을 보낼 때 어떤 port로 요청을 보낼지 모르기 때문에 ConnectionRefused 오류가남
RestAssured.port = port;
}
@DisplayName("지하철역을 조회한다.")
@Test
void findStations() {
ExtractableResponse<Response> response = RestAssured.given().log().all()
.when().get("/stations")
.then().log().all()
.extract();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
}
}
RestAssured를 통한 테스트에서의 테스트 격리
RestAssured를 사용할 때의 테스트 격리도 @Transactional을 쓰면 테스트간 데이터 격리를 이뤄낼 수 있을까?
Mock 설정이 아닌 환경에서는 실제 서버를 띄우고 요청을 받는 것과 똑같다. 스프링 MVC에서는 1 request 당 1 스레드를 할당하므로 RestAssured를 통해 보내는 요청과 테스트를 수행하는 스레드는 다르다. 즉, @Transactional을 사용할 때 같은 트랜잭션을 사용할 수 없다.
따라서 RestAssured 요청 안에서 이루어진 일들은 @Transactional로 롤백할 수 없다는 문제점이 있다.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class RestAssuredTest {
@LocalServerPort
int port;
@BeforeEach
void setUp() {
// 이 설정을 해주지 않으면 요청을 보낼 때 어떤 port로 요청을 보낼지 모르기 때문에 ConnectionRefused 오류가남
RestAssured.port = port;
}
@DisplayName("지하철역을 생성한다.")
@Test
@Transactional
void findStations() {
// 이 생성 요청은 테스트 스레드와 다른 스레드에서 실행되기 때문에 롤백이 되지 않는다.
RestAssured.given().log().all()
.body("{\\"name\\":\\"강남역\\"}")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when().post("/stations")
.then().log().all()
.extract();
}
}
또 @Transactional을 사용할 때 다음과 같은 문제가 생길 수 있다.
- 직접 Repository 혹은 Service를 주입받아 데이터를 insert함
- 이 요청은 테스트를 수행하는 스레드와 같은 스레드에서 수행되므로 같은 트랜잭션 내에서만 읽을 수 있음
- RestAssured의 요청은 다른 스레드에서 수행되므로 다른 트랜잭션이 사용되고 위에 저장된 데이터를 읽을 수 없음
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class RestAssuredTest {
@Autowired
private StationService stationService;
@LocalServerPort
int port;
@BeforeEach
void setUp() {
// 이 설정을 해주지 않으면 요청을 보낼 때 어떤 port로 요청을 보낼지 모르기 때문에 ConnectionRefused 오류가남
RestAssured.port = port;
}
@DisplayName("지하철역을 조회한다.")
@Test
@Transactional
void findStations() {
// 이 요청은 테스트 스레드와 같은 스레드에서 실행되기 때문에 테스트 스레드가 끝나기 전까지 커밋되지 않는다.
stationService.saveStation(new StationRequest("강남역"));
// RestAssured를 사용하여 요청을 보낼 때는 테스트 스레드와 다른 스레드에서 실행되기 때문에 아직 커밋되지 않은 데이터를 읽을 수 없다.
ExtractableResponse<Response> result = RestAssured.given().log().all()
.when().get("/stations")
.then().log().all()
.extract();
assertThat(result.statusCode()).isEqualTo(HttpStatus.OK.value());
assertThat(result.body().jsonPath().getList("$").size()).isEqualTo(1);
}
}
즉, @Transactional을 써도 예상하는 동작과 전혀 다르게 동작하는 경우가 많기 때문에 사용하지 않는 것이 좋다.
그러면 어떻게 테스트간 데이터 격리를 할 수 있을까?
@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD)
이 어노테이션을 통해 스프링 컨테이너를 테스트마다 아예 재생성하여 ApplicationContext 공유를 막아 테스트간 격리를 할 수 있는데 몇백 몇천개가 되는 테스트마다 컨텍스트를 모두 다시 띄우는 것은 시간이 너무 오래걸리므로 좋은 선택은 아닌 것 같다.
실제 데이터 지워주기
stationRepository.deleteAll();
이러한 메서드를 만들어 @BeforeEach마다 불러주면 테스트간 격리를 이뤄낼 수 있다. 다만, 사람은 실수하기 마련이고 통합 테스트 내에서 어떤 Repository에 데이터가 모두 쌓이는지 추적하기는 매우 힘들다. 또, id 같은 값들이 Auto Increment에 의해 계속 증가하므로 완전히 격리되었다고 보기는 힘들다.
즉, 모든 테이블을 그냥 Truncate를 시켜주는 것이 오히려 편하다.
위 빈을 이제 인수 테스트를 수행하는 테스트 마다 의존성 주입해주고 execute 메서드를 @BeforeEach 메서드에 실행시켜주면 테스트간 격리를 이뤄낼 수 있다.
코드의 중복 줄이기 - 상속
모든 테스트마다 위처럼 의존성 주입, @BeforeEach에서 실행해줄 수도 있겠지만 이는 모든 통합 테스트에서 실행될 공통의 관심사로 보인다.
이를 상속을 통해 해결할 수 있다.
@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AcceptanceTest {
@LocalServerPort
int port;
@Autowired
private DatabaseCleanup databaseCleanup;
@BeforeEach
public void setUp() {
RestAssured.port = port;
databaseCleanup.execute();
}
}
public class Test extends AcceptanceTest {
}
이렇게 AcceptanceTest를 하나 만들고, 사용처에서는 이 AcceptanceTest를 상속 받아 테스트하면 깔끔하게 문제를 해결할 수 있다.
코드 중복 줄이기 - 어노테이션
위와 같은 방법도 좋지만 조금 더 간편하게 상속이 아닌 어노테이션 기반으로도 만들고 싶다면 TestExectutionListner를 사용하면 된다.
public class AcceptanceTestExecutionListener extends AbstractTestExecutionListener {
@Override
public void beforeTestMethod(TestContext testContext) throws Exception {
DatabaseCleanup databaseCleanup = testContext.getApplicationContext().getBean(DatabaseCleanup.class);
Integer port = testContext.getApplicationContext().getEnvironment().getProperty("local.server.port", Integer.class);
if (port == null) {
throw new IllegalStateException();
}
RestAssured.port = port;
// DatabaseCleanup 실행
databaseCleanup.execute();
}
}
어노테이션으로 TestExecutionListeners를 등록할 때 주의할 점은 mergeMode를 Default로 해야 기존에 존재하는 다른 TestExecutionListener들과 함께 동작할 수 있다는 것이다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Retention(RetentionPolicy.RUNTIME)
@TestExecutionListeners(listeners = {AcceptanceTestExecutionListener.class}, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
public @interface Acceptance {
}
@Acceptance
public class Test{
}
이렇게 어노테이션 기반 혹은 상속을 이용해서 코드의 중복을 줄일 수 있었다.
컨테이너 캐싱
위처럼 코드 중복을 해결할 때 숨겨진 장점이 하나 더 있는데 스프링의 테스트 컨테이너 캐싱 전략을 보다 더 잘 활용할 수 있다는 것이다.
스프링 공식 문서에 의하면 스프링에서는 테스트 실행 시 반복적으로 컨테이너를 생성하고 삭제하는 오버헤드를 줄이기 위해, 테스트 컨텍스트나 데이터베이스 컨테이너 같은 리소스를 캐시하여 성능을 향상시킨다.
다만, 테스트 컨테이너의 설정 및 환경이 달라지면 컨테이너가 여러번 뜨게 된다.
// 컨테이너가 두번 뜨게 됨
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class Test1 {
}
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
public class Test2 {
}
상속 혹은 어노테이션 기반으로 문제를 해결하면 환경을 항상 똑같게 강제하므로 위와 같이 환경이 달라져 컨테이너가 여러번 뜨는 문제를 막는 장점이 있다.
마무리
스프링에서 테스트를 할 수 있는 여러 방법에 대해 알아보았다.
더 좋은 방법이 있을까?
DB 환경 뿐만 아니라 레디스나 카프카 같은 것들이 쓰이는 환경에서는 어떻게 통합테스트를 할 수 있지?
인증이 필요한 요청은 어떻게 할 수 있을까?
등등 여러 좋은 고민들을 남긴 주제였다 🙂
'서버 > 스프링' 카테고리의 다른 글
ThreadLocal과 MDC를 통해 알아보는 스프링에서의 로깅 원리 (0) | 2024.11.03 |
---|---|
[스프링 AOP] 3. 스프링 AOP 스프링에서 빈이 프록시로 변환되는 과정 (0) | 2024.11.03 |
[스프링 AOP] 2. 스프링 AOP 프록시 생성 원리 (0) | 2024.10.02 |
[스프링 AOP] 1. 스프링 AOP와 Proxy 패턴 (6) | 2024.09.28 |
[JPA] 기본 키 매핑 전략 정리 (0) | 2022.10.30 |