스프링 테스트

헙크·2023년 4월 29일
1

테스트

목록 보기
1/1
post-thumbnail

0. 개요

@SpringBootTest@WebMvcTest, @JdbcTest를 어떻게 사용하는지 알아보고자 합니다. 테스트 코드를 작성하는 방법보다는, 이 애너테이션들을 어느 상황에서 사용하고, 어떤 문제가 있을지 살펴보았습니다.

(현재 저는 인메모리 환경에서 db 테스트를 진행하는 것을 가정하고 글을 작성하였습니다.)

1. @SpingBootTest

1.1. @SpringBootTest의 특징

기본적으로 @SpringBootTest는 서버를 시작하지 않습니다. 따라서 설정을 통해 테스트를 어떤 환경에서 시작할 건지 설정을 달리 할 수 있습니다. 설정값을 살펴보면 다음과 같습니다.

  1. MOCK (default) : 내장 서버가 실행되지 않음. mock 기반 테스트를 실행함
  2. RANDOM_PORT : 랜덤 포트 번호로 실제 웹서버와 함께 WebServerApplicaitonContext가 로드됨
  3. DEFINE_PORT : 설정된 포트 번호로 실제 웹서버와 함께 WebServerApplicaitonContext가 로드됨
  4. NONE : SpringApplicaiton을 통해 ApplicaitonContext를 로드하지만 어떠한 웹 환경도 제공하지 않음

1.2. @SpringBootTest는 언제 사용할까?

기본적으로 이 애너테이션은 스프링 빈을 전체적으로 컨테이너에 올려놓고 사용하고 싶을 때에 사용합니다. 하나의 테스트 클래스에서 여러 개의 빈을 엮어 테스트할 일이 있어서 사용하겠죠.

만약 여러 개의 빈을 엮어 테스트하지 하지 않음에도 불구하고, 이 애너테이션을 사용한다면 불필요한 빈들까지 컨테이너에 올려놓는 작업 때문에 테스트가 무거워질 것입니다. 서비스의 크기가 커진다면 테스트를 수행하는 데에 많은 시간과 리소스가 발생할 우려도 있습니다.

따라서 이 애너테이션은 기본적으로 내가 만든 빈들을 모두 올려 테스트하고 싶을 때 한정적으로 사용하는 것이 좋을 것 같습니다.

1.3. RANDOM_PORT, DEFINE_PORT

특히 RANDOM_PORTDEFINE_PORT는 실제 서블릿 환경을 제공하여 테스트 코드와 애플리케이션 코드가 서로 다른 스레드에서 동작합니다. 그렇다면 테스트 코드를 클라이언트, 애플리케이션 코드를 서버라고 생각할 수 있을 것 같네요! 이를 통해 각각의 end point 환경을 분리시킬 수 있고, 마치 실제로 클라이언트와 서버가 요청과 응답을 받을 수 있는 상황을 만들 수 있습니다.

그렇다면 이 두 속성을 활용해 E2E 테스트를 진행하면 좋을 것 같습니다.

2. @SpringBootTest와 RestAssured

2.1. RestAssured란?

이 테스트 도구는 E2E 테스트를 하는 데에 사용됩니다. 웹으로부터 넘어오는 클라이언트의 입력에 대해, 실제로 우리의 서비스가 어떤 결과를 반환하는지 db관련 로직도 모두 함께 실행하여 정상적인 값이 반환되는지 확인합니다. 따라서 위에서 살펴본 @SpringBootRANDOM_PORTDEFINE_PORT 환경에서 이 테스트 도구를 활용하면 좋겠네요!

2.2. RestAssured 사용

@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의 자세한 사용법은 다음 글에서 다루겠습니다 :)

2.3. Trouble Shooting

만약 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 되었고, 결과적으로 두 번째 테스트에 영향을 미치게 되었습니다.

2.4. 문제 원인

위의 문제가 발생했던 이유는 @SpringBootTest의 속성을 RANDOM_PORTDEFINE_PORT로 설정해놓으면 @Transactional 애너테이션을 달아도 테스트마다 rollback 적용이 안되는 특징 때문입니다

위에서 설명했듯이, RANDOM_PORTDEFINE_PORT 속성은 실제 서블릿 환경을 제공하여 테스트 코드와 애플리케이션 코드가 서로 다른 스레드에서 동작한다고 설명했습니다. 또한 테스트 코드를 클라이언트, 애플리케이션 코드를 서버라고 생각할 수 있다고도 했죠.

정리하자면 이렇게 생각할 수 있을 것입니다.

트랜잭션은 테스트 코드에 적용했는데 롤백은 애플리케이션 코드에 적용할 수 있을까?

답은 불가능하다입니다. 테스트가 종료되고 이를 rollback 시키기 위해서는 하나의 트랜잭션으로 묶여있어야 하는데 두 환경은 현재 각각 독립적인 스레드에서 실행되고 있기 때문입니다.

2.5. 해결책

이것저것 찾아 봤을 때, 매 테스트가 끝날 때마다 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)
);

2.6. 주의할 점

한편으로 위와 같은 방법은 인메모리 db를 사용하는 상황에서 한정적으로 사용할 수밖에 없을 것입니다. 만약 실제 테스트용 db와 연결시켜 놓고 테스트를 진행한다면, 기존의 데이터들을 매 번 모두 삭제해야 하기 때문입니다.

테스트 db를 매 번 지우고, 테이블이 몇 개 없는 상황이라면 위와 같은 방법을 사용할 수도 있을 것 같긴 하지만, 상황에 따라 주의하여 사용하는 것이 좋을 것 같습니다😃

3. @WebMvcTest

3.1. @WebMvcTest의 특징

@SpringBootTest와는 달리 @WebMvcTest는 웹 계층을 테스트하는 데에 주 목적이 있습니다. 웹 관련 부분의 애플리케이션 컨텍스트만 로드하기 때문에 상대적으로 빠릅니다. 주로 컨트롤러, 필터, 인터셉터 등 웹 관련 계층의 컴포넌트를 테스트하고 싶을 때 사용합니다.

@WebMvcTest를 사용하면 컨트롤러 테스트를 위한 기본적인 빈들(핸들러, 뷰 리졸버, 필터, 인터셉터 등)과 우리가 @Controller를 붙인 빈들을 찾아 테스트시에 컨테이너에 올려놓습니다. 참 편하죠!? ㅎㅎㅎ 이 외에 개발자가 등록해 놓은 빈들은 컨테이너에 올라오지 않습니다. (위의 사진처럼 여러 개의 @Controller가 있다면 모두 컨테이너에 올려줍니다)

추가적으로 애너테이션의 속성값으로 특정 컨트롤러 클래스만 명시하면 해당 컨트롤러만 컨테이너에 올려주므로, 불필요하게 여러 개의 컨트롤러를 올려놓고 테스트하지 않을 수도 있습니다.

개인적인 생각으로는 아무리 컨트롤러의 갯수가 적더라도, 어떤 컨트롤러에 대한 테스트를 진행하는지 명시적으로도 확인할 수 있도록 속성값으로 컨트롤러 클래스를 명시해주는 것이 좋아보입니다!

3.2. @WebMvcTest의 사용

@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);
    }
}

3.3. Trouble Shooting

아마 이 부분에서 테스트가 실패하실 수도 있을 것입니다. 저도 한동안 계속해서 테스트가 실패했는데, 이상한 점은 요청에 대한 응답은 200으로 잘 오는데 바디에 아무 것도 담겨있지 않았다는 것입니다.

3.4. 문제 원인과 해결책

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를 재정의해 값객체처럼 사용하는 것이 옳바른 방식인지는 잘 모르겠습니다. 당장은 문제가 없어보이지만 추후에 이로인한 문제가 없을까 생각되지만, 아직은 감이 오지 않네요 ㅎㅎㅎ

4. @JdbcTest

4.1. @JdbcTest 사용과 특징

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로 값을 찾아와 특정 값들을 비교하도록 테스트 로직을 구성하는 것을 지향합시다!)

4.2. @JdbcTest 사용

@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 블록에서 연결해주어야 합니다.

4.3. Trouble Shooting

한편으로는, 빈의 의존관계를 매 테스트마다 초기화 해주어야만 하나..라는 생각이 들었습니다. 이 두 개만 따로 빈에 등록할 수는 없을까 생각하다가 다음과 같은 방법을 찾았습니다.

@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를 사용해 주입받을 수 있게 되었습니다.

4.4. 주의할 점

@Import를 통해 필요한 빈들을 테스트할 때에 올려 놓는 방식은 사실 정형화되어 있는 방법은 아닌 것 같습니다. 보통 이 애너테이션은 외부에 @TestConfiguration을 적용한 클래스를 불러올 때 사용하는 것으로 알고 있습니다. 저처럼 사용하는 예는 아직 못보긴 했습니다..😂 이에 대해서 조금 더 알아보고 문제가 있다면 관련 포스팅을 이어가겠습니다.

이와는 별개로 @Import를 사용한다면 같은 팀원들에게 혼란을 일으킬 수 있다는 점, 여러 빈을 등록하는 상황에서는 가독성이 떨어진다는 점 등은 주의해야 할 것입니다. 간단하게 한 두개의 빈을 올려놓고 싶을 때에만 사용하도록 합시다!

5. 마치며

스프링에서 제공하는 여러 테스트 방법에 대해서 알아보았습니다. 각각은 어떤 부분을 테스트하고 싶은지에 따라 목적에 맞게 존재하고, 각 목적에 맞게 우리는 이들을 택해서 사용하면 더 효율적인 테스트를 할 수 있을 것입니다.

2개의 댓글

comment-user-thumbnail
2023년 4월 29일

Mock기반 테스트도 알려주세요!

1개의 답글