@SpringBootTest
와 @WebMvcTest
, @JdbcTest
를 어떻게 사용하는지 알아보고자 합니다. 테스트 코드를 작성하는 방법보다는, 이 애너테이션들을 어느 상황에서 사용하고, 어떤 문제가 있을지 살펴보았습니다.
(현재 저는 인메모리 환경에서 db 테스트를 진행하는 것을 가정하고 글을 작성하였습니다.)
기본적으로 @SpringBootTest
는 서버를 시작하지 않습니다. 따라서 설정을 통해 테스트를 어떤 환경에서 시작할 건지 설정을 달리 할 수 있습니다. 설정값을 살펴보면 다음과 같습니다.
MOCK
(default) : 내장 서버가 실행되지 않음. mock 기반 테스트를 실행함RANDOM_PORT
: 랜덤 포트 번호로 실제 웹서버와 함께 WebServerApplicaitonContext가 로드됨DEFINE_PORT
: 설정된 포트 번호로 실제 웹서버와 함께 WebServerApplicaitonContext가 로드됨NONE
: SpringApplicaiton을 통해 ApplicaitonContext를 로드하지만 어떠한 웹 환경도 제공하지 않음기본적으로 이 애너테이션은 스프링 빈을 전체적으로 컨테이너에 올려놓고 사용하고 싶을 때에 사용합니다. 하나의 테스트 클래스에서 여러 개의 빈을 엮어 테스트할 일이 있어서 사용하겠죠.
만약 여러 개의 빈을 엮어 테스트하지 하지 않음에도 불구하고, 이 애너테이션을 사용한다면 불필요한 빈들까지 컨테이너에 올려놓는 작업 때문에 테스트가 무거워질 것입니다. 서비스의 크기가 커진다면 테스트를 수행하는 데에 많은 시간과 리소스가 발생할 우려도 있습니다.
따라서 이 애너테이션은 기본적으로 내가 만든 빈들을 모두 올려 테스트하고 싶을 때 한정적으로 사용하는 것이 좋을 것 같습니다.
특히 RANDOM_PORT
와 DEFINE_PORT
는 실제 서블릿 환경을 제공하여 테스트 코드와 애플리케이션 코드가 서로 다른 스레드에서 동작합니다. 그렇다면 테스트 코드를 클라이언트, 애플리케이션 코드를 서버라고 생각할 수 있을 것 같네요! 이를 통해 각각의 end point 환경을 분리시킬 수 있고, 마치 실제로 클라이언트와 서버가 요청과 응답을 받을 수 있는 상황을 만들 수 있습니다.
그렇다면 이 두 속성을 활용해 E2E 테스트를 진행하면 좋을 것 같습니다.
이 테스트 도구는 E2E 테스트를 하는 데에 사용됩니다. 웹으로부터 넘어오는 클라이언트의 입력에 대해, 실제로 우리의 서비스가 어떤 결과를 반환하는지 db관련 로직도 모두 함께 실행하여 정상적인 값이 반환되는지 확인합니다. 따라서 위에서 살펴본 @SpringBoot
의 RANDOM_PORT
와 DEFINE_PORT
환경에서 이 테스트 도구를 활용하면 좋겠네요!
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class WebControllerTest {
@LocalServerPort
int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
}
@Test
void runRacingGame() {
final UserRequestDto userRequestDto = new UserRequestDto("헙크, 채채", 5);
RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(userRequestDto)
.when().post("/plays")
.then().log().all()
.statusCode(HttpStatus.OK.value())
.body("$", hasKey("winners"))
.body("racingCars", hasSize(2));
}
}
보통 위의 코드처럼 테스트를 진행합니다. 사용자의 입력을 웹으로부터 입력받아야 하기 때문에 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
를 사용합니다. 이번 글에서는 각 테스트 애너테이션의 사용을 다루는 것이 목적이기에, RestAssured의 자세한 사용법은 다음 글에서 다루겠습니다 :)
만약 RestAssured를 사용한 여러 번의 테스트를 진행한다면, 자연스레 db에 데이터가 쌓이게 되고 의도치 않게 이전의 테스트가 다른 테스트의 결과에 영향을 미칠 것입니다. 테스트의 독립적인 환경이 보장되지 않는 것이죠.
이때 생각할 수 있는 것이 @Transactional
입니다. 보통 우리는 테스트를 진행함에 있어, 데이터베이스에 commit 되지 않고 rollback 되길 기대한다면 클래스 레벨에 @Transactional
을 붙여 이를 실현할 것입니다.
(@Transactional
에 대한 Spring AOP 기술도 추후에 더 자세히 정리해보겠습니다 ㅎㅎ)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
public class WebControllerTest {
@LocalServerPort
int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
}
@Test
void runRacingGame() {
final UserRequestDto userRequestDto = new UserRequestDto("헙크, 채채", 5);
RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(userRequestDto)
.when().post("/plays")
.then().log().all()
.statusCode(HttpStatus.OK.value())
.body("$", hasKey("winners"))
.body("racingCars", hasSize(2)); // db에 데이터 하나 쌓이고, 롤백될 것으로 기대
}
@Test
void getHistory() {
final UserRequestDto userRequestDto = new UserRequestDto("헙크, 채채", 5);
RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(userRequestDto)
.post("/plays");
RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(userRequestDto)
.post("/plays");
RestAssured.given().log().all()
.accept(MediaType.APPLICATION_JSON_VALUE)
.when().get("/plays")
.then().log().all()
.statusCode(HttpStatus.OK.value())
.body("size()", is(2)); // db에 데이터 두 개 쌓이고, 롤백될 것으로 기대
}
}
첫 번째 테스트는 정상 실행되지만, 두 번째 테스트는 실패합니다. @Transactional
을 붙임으로써, 처음 진행된 테스트에서의 쿼리가 rollback 될 것으로 기대했으나 commit 되었고, 결과적으로 두 번째 테스트에 영향을 미치게 되었습니다.
위의 문제가 발생했던 이유는 @SpringBootTest
의 속성을 RANDOM_PORT
와 DEFINE_PORT
로 설정해놓으면 @Transactional
애너테이션을 달아도 테스트마다 rollback 적용이 안되는 특징 때문입니다
위에서 설명했듯이, RANDOM_PORT
와 DEFINE_PORT
속성은 실제 서블릿 환경을 제공하여 테스트 코드와 애플리케이션 코드가 서로 다른 스레드에서 동작한다고 설명했습니다. 또한 테스트 코드를 클라이언트, 애플리케이션 코드를 서버라고 생각할 수 있다고도 했죠.
정리하자면 이렇게 생각할 수 있을 것입니다.
트랜잭션은 테스트 코드에 적용했는데 롤백은 애플리케이션 코드에 적용할 수 있을까?
답은 불가능하다입니다. 테스트가 종료되고 이를 rollback 시키기 위해서는 하나의 트랜잭션으로 묶여있어야 하는데 두 환경은 현재 각각 독립적인 스레드에서 실행되고 있기 때문입니다.
이것저것 찾아 봤을 때, 매 테스트가 끝날 때마다 truncate하는 방법 등이 있는 것으로 보이지만, 저는 이부분에서 @Sql
애너테이션을 사용하여 해결하였습니다. 이 애너테이션은 매 테스트마다 해당 경로에 있는 sql 파일을 실행시켜주는데, 이 파일에 DROP TABLE IF EXISTS ~~
구문을 사용한다면 간단하게 해결할 수 있습니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Sql("/data.sql")
public class WebControllerTest {
@LocalServerPort
int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
}
@Test
void runRacingGame() {
...
}
...
// resources 하위의 data.sql파일
DROP TABLE IF EXISTS game_result CASCADE;
DROP TABLE IF EXISTS car CASCADE;
CREATE TABLE game_result
(
id BIGINT NOT NULL AUTO_INCREMENT,
try_count INT NOT NULL,
played_at DATETIME NOT NULL default current_timestamp,
PRIMARY KEY (id)
);
CREATE TABLE car
(
id BIGINT NOT NULL AUTO_INCREMENT,
player_name VARCHAR(255) NOT NULL,
final_position INT NOT NULL,
is_winner BOOLEAN NOT NULL,
game_result_id INT NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (game_result_id) REFERENCES game_result (id)
);
한편으로 위와 같은 방법은 인메모리 db를 사용하는 상황에서 한정적으로 사용할 수밖에 없을 것입니다. 만약 실제 테스트용 db와 연결시켜 놓고 테스트를 진행한다면, 기존의 데이터들을 매 번 모두 삭제해야 하기 때문입니다.
테스트 db를 매 번 지우고, 테이블이 몇 개 없는 상황이라면 위와 같은 방법을 사용할 수도 있을 것 같긴 하지만, 상황에 따라 주의하여 사용하는 것이 좋을 것 같습니다😃
@SpringBootTest
와는 달리 @WebMvcTest
는 웹 계층을 테스트하는 데에 주 목적이 있습니다. 웹 관련 부분의 애플리케이션 컨텍스트만 로드하기 때문에 상대적으로 빠릅니다. 주로 컨트롤러, 필터, 인터셉터 등 웹 관련 계층의 컴포넌트를 테스트하고 싶을 때 사용합니다.
@WebMvcTest
를 사용하면 컨트롤러 테스트를 위한 기본적인 빈들(핸들러, 뷰 리졸버, 필터, 인터셉터 등)과 우리가 @Controller
를 붙인 빈들을 찾아 테스트시에 컨테이너에 올려놓습니다. 참 편하죠!? ㅎㅎㅎ 이 외에 개발자가 등록해 놓은 빈들은 컨테이너에 올라오지 않습니다. (위의 사진처럼 여러 개의 @Controller
가 있다면 모두 컨테이너에 올려줍니다)
추가적으로 애너테이션의 속성값으로 특정 컨트롤러 클래스만 명시하면 해당 컨트롤러만 컨테이너에 올려주므로, 불필요하게 여러 개의 컨트롤러를 올려놓고 테스트하지 않을 수도 있습니다.
개인적인 생각으로는 아무리 컨트롤러의 갯수가 적더라도, 어떤 컨트롤러에 대한 테스트를 진행하는지 명시적으로도 확인할 수 있도록 속성값으로 컨트롤러 클래스를 명시해주는 것이 좋아보입니다!
@WebMvcTest(WebController.class) // WebController만 컨테이너에 올려놓음
public class WebControllerMockTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private RacingGameService service;
@Test
void test() throws Exception {
// 요청값 구성
final UserRequestDto requestDto = new UserRequestDto("헙크, 채채", 10);
// 응답값 구성
final List<CarResponseDto> carResponseDtos = List.of(
new CarResponseDto("헙크", 5),
new CarResponseDto("채채", 10)
);
final GameResultResponseDto resultDto = new GameResultResponseDto(List.of("채채"), carResponseDtos);
// mocking (service의 getResult 메서드 호출시, resultDto를 바로 반환하도록)
when(service.getResult(requestDto)).thenReturn(resultDto);
mockMvc.perform(post("/plays")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(new ObjectMapper().writeValueAsString(requestDto))
.characterEncoding(StandardCharsets.UTF_8)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.winners").value(String.join(", ", resultDto.getWinners())))
.andExpect(jsonPath("$.racingCars[0].name").value("헙크"))
.andExpect(jsonPath("$.racingCars[0].position").value(5))
.andExpect(jsonPath("$.racingCars[1].name").value("채채"))
.andExpect(jsonPath("$.racingCars[1].position").value(10));
verify(service, times(1)).getResult(requestDto);
}
}
아마 이 부분에서 테스트가 실패하실 수도 있을 것입니다. 저도 한동안 계속해서 테스트가 실패했는데, 이상한 점은 요청에 대한 응답은 200으로 잘 오는데 바디에 아무 것도 담겨있지 않았다는 것입니다.
when(service.getResult(requestDto)).thenReturn(resultDto);
위의 코드를 자세히 살펴보면 mocking을 하는 부분에 우리가 만든 requestDto
를 넣어주고 있는 것을 확인하실 수 있습니다. 그 후에 아래에서 실제 테스트를 수행하는 부분에서 new ObjectMapper().writeValueAsString(requestDto)
를 통해 요청값을 바디에 넣어주고 있는데, 이 부분이 문제였습니다.
요청 바디를 가지고 요청값이 들어온다면, 로직이 흘러가다가 우리가 mocking한 service.getResult
의 인자값과 같은지 동등성 비교를 진행합니다.
하지만 저는 해당 Dto에 equals & hashcode
를 재정의하지 않았고, 이 때문에 우리가 mocking한 상황과 다르다고 판단되어 mocking을 진행하지 않은 것입니다.
따라서 Dto에 equals & hashcode
를 재정의함으로써, mocking을 성공적으로 수행할 수 있고 기대한 결과값을 테스트할 수 있을 것입니다.
한편으로는 Dto에 equals & hashcode
를 재정의해 값객체처럼 사용하는 것이 옳바른 방식인지는 잘 모르겠습니다. 당장은 문제가 없어보이지만 추후에 이로인한 문제가 없을까 생각되지만, 아직은 감이 오지 않네요 ㅎㅎㅎ
db관련 컨텍스트를 한정해서 테스트를 진행해야 하는 경우에 사용됩니다. @WebMvcTest
처럼 @JdbcTest
또한 빈을 모두 올리지 않으니 상대적으로 실행 속도가 빠릅니다.
@WebMvcTest
가 개발자가 등록해 놓은 @Controller
를 빈으로 올려주는 것을 위에서 확인했기에, 그렇다면 @JdbcTest
는 @Repository
로 등록해 놓은 빈을 컨테이너에 올려주지 않을까하여 확인해보았지만 그렇지 않았습니다. 아무 것도 띄워주지 않더군요.. ㅎㅎㅎ 따라서 필요한 의존관계들은 @BeforeEach
로 직접 의존관계를 연결해 주어야 합니다. (아래에서 코드와 함께 다시 설명하겠습니다)
이 애너테이션을 사용하면 각각의 테스트 이후에 자동으로 rollback됩니다. 실제로 @JdbcTest
애너테이션에 들어가보면 @Transactional
이 붙어있는 것을 확인할 수 있습니다. 이를 통해 테스트들은 모두 자동적으로 롤백되게 됩니다. 따라서 다른 테스트에 영향을 주지 않고, db에 CRUD하는 기능들을 테스트할 수 있겠군요!
다만 주의할 점은 테스트를 진행할 때에, 값을 인메모리 db에 집어 넣는 순간 db에서 관리하는 auto_increment
는 증가한다는 것입니다. 이는 롤백과 무관하게 다시 이전 값으로 돌아가지 않습니다. 이 부분은 반드시 유의하고 테스트를 진행해야 합니다.
값을 insert하고 auto_increment
로 설정한 pk값을 확인하도록 테스트를 짠다면, 매 번 1L
을 반환하는 것이 보장되지 않습니다. 따라서 되도록이면 auto_increment
의 pk값으로 테스트 로직을 구성하는 것을 지양해야 할 것입니다. (저는 간단하게 db에 잘 들어갔는지 확인하기 위해, pk값을 확인하도록 했습니다..ㅎㅎㅎ save 후 findById로 값을 찾아와 특정 값들을 비교하도록 테스트 로직을 구성하는 것을 지향합시다!)
@JdbcTest
class CarDaoWebImplTest {
@Autowired private JdbcTemplate jdbcTemplate;
private CarDao carDao;
private GameResultDao gameResultDao;
@BeforeEach
void setUp() {
// 필요한 의존관계를 직접 연결
carDao = new CarDaoWebImpl(jdbcTemplate);
gameResultDao = new GameResultDaoWebImpl(jdbcTemplate);
}
@Test
void save() {
final RacingGame racingGame = new RacingGame(new Names("헙크, 채채"), new TryCount(3));
racingGame.run(new DefaultMovingStrategy());
Long gameResultId = gameResultDao.save(DtoMapper.toRacingGameDto(racingGame));
assertThat(1L).isEqualTo(gameResultId);
Car car = new Car("헙크", 3, true);
Long carId = carDao.save(DtoMapper.mapToCarDto(gameResultId, car));
assertThat(carId).isEqualTo(1L);
}
}
JdbcTemplate 같은 db 관련 기술들은 자동으로 컨테이너에 띄워줍니다. 하지만 개발자가 자동으로 등록한 빈들은 하나도 올려주지 않습니다. 이에 따라 필요한 의존성을 직접 @BeforeEach
블록에서 연결해주어야 합니다.
한편으로는, 빈의 의존관계를 매 테스트마다 초기화 해주어야만 하나..라는 생각이 들었습니다. 이 두 개만 따로 빈에 등록할 수는 없을까 생각하다가 다음과 같은 방법을 찾았습니다.
@JdbcTest
@Import({CarDaoWebImpl.class, GameResultDaoWebImpl.class})
class CarDaoWebImplTest {
@Autowired private JdbcTemplate jdbcTemplate;
@Autowired private CarDao carDao;
@Autowired private GameResultDao gameResultDao;
@Test
void save() {
...
}
}
이와 같이 클래스 레벨에 @Import
문으로 컨테이너에 올려 놓을 빈 들만 명시해준다면, 이 클래스를 실행하기 위한 컨테이너를 생성할 때에 해당 빈들을 import 해줍니다.
따라서 우리는 더이상 @BeforeEach
를 사용하여 매 번 의존관계를 설정해주지 않아도 되고, 그저 @Autowired
를 사용해 주입받을 수 있게 되었습니다.
@Import
를 통해 필요한 빈들을 테스트할 때에 올려 놓는 방식은 사실 정형화되어 있는 방법은 아닌 것 같습니다. 보통 이 애너테이션은 외부에 @TestConfiguration
을 적용한 클래스를 불러올 때 사용하는 것으로 알고 있습니다. 저처럼 사용하는 예는 아직 못보긴 했습니다..😂 이에 대해서 조금 더 알아보고 문제가 있다면 관련 포스팅을 이어가겠습니다.
이와는 별개로 @Import
를 사용한다면 같은 팀원들에게 혼란을 일으킬 수 있다는 점, 여러 빈을 등록하는 상황에서는 가독성이 떨어진다는 점 등은 주의해야 할 것입니다. 간단하게 한 두개의 빈을 올려놓고 싶을 때에만 사용하도록 합시다!
스프링에서 제공하는 여러 테스트 방법에 대해서 알아보았습니다. 각각은 어떤 부분을 테스트하고 싶은지에 따라 목적에 맞게 존재하고, 각 목적에 맞게 우리는 이들을 택해서 사용하면 더 효율적인 테스트를 할 수 있을 것입니다.
Mock기반 테스트도 알려주세요!