[Spring] RestAssured로 Controller 테스트 하기

🌩 es·2023년 9월 27일
0
post-thumbnail

RestAssured라는 라이브러리로 Controller를 테스트 하는 방법을 정리해보려고 한다.

테스트의 목적

Controller의 어떤 것을 테스트해야 할지 생각해보았다. 우선 Controller에서 하는 일이 무엇일까? Controller의 책임은 일반적으로 다음과 같다.

  1. HTTP 요청을 객체로 매핑(= 역직렬화deserialize )
  2. 권한 검사
  3. 입력 유효성 검증
  4. 서비스 계층의 입력 모델로 매핑
  5. 서비스(비즈니스 로직) 호출
  6. 서비스의 출력을 HTTP로 매핑
  7. HTTP 응답을 반환

그렇다면 테스트의 목적은 Controller가 맡은바 책임을 다하고 있는지 검사하는 것이다. 클라이언트로부터 HTTP 요청을 받아서 예상한 대로의 응답을 반환해주는지 테스트 해보는 것이다.

통합 테스트? e2e 테스트? 인수 테스트?

Controller를 검증하는 테스트는 뭐라고 불러야 할까? 어디서는 통합 테스트, 또 다른 곳에서는 e2e 테스트, 인수 테스트라고 하는데,,, 뭐가 맞을까?

테스트 목적과 테스트 대상의 범위

  • 통합 테스트

    애플리케이션의 여러 컴포넌트 또는 모듈 간의 상호 작용을 검증한다. 모듈 간의 통합이 올바르게 이루어지는지 확인하고, 인터페이스 간의 데이터 전달 및 상호 작용을 테스트한다.

  • e2e 테스트

    시스템의 전체 기능과 흐름을 검증한다. 사용자 관점에서 시스템이 예상대로 동작하는지 확인한다.

  • 인수 테스트

    사용자 또는 이해 관계자의 요구사항과 기대사항을 충족하는지 확인하기 위해 수행된다. 프로젝트 이해관계자들이 모여 사용자 스토리(시나리오)를 만들고 개발자가 이를 기반으로 코드로 테스트를 작성할 수 있다.

테스트 환경

  • 통합 테스트

    개발팀 내에서, 개발 환경 또는 테스트 환경에서 수행된다. 모듈 간의 통합을 검증하기 위해 실제 운영 환경은 필요하지 않다.

  • e2e 테스트

    시스템의 전체 플로우를 시뮬레이션하므로 실제 운영 환경과 가장 유사한 환경(프로덕션과 동일한 하드웨어, 소프트웨어, 네트워크 설정 등)에서 수행되어야 한다.

  • 인수 테스트

    실제 사용자 또는 고객이 직접 수행할 수 있으므로 실제 운영 환경 또는 프로덕션 환경에서 수행되어야 한다.

테스트 수행

  • 통합 테스트

    개발자가 주로 자동화된 테스트 프레임워크 또는 테스트 도구를 사용한다. 각 컴포넌트를 단위 테스트한 후, 이러한 테스트 케이스를 통합하여 실행한다.

  • e2e 테스트

    자동화된 도구를 사용하여 사용자 시나리오를 시뮬레이션하고 결과를 확인하는 것이 일반적이다.

  • 인수 테스트

    주로 사용자 또는 고객이 직접 시스템을 사용하며 요구사항을 확인하고 사용자 경험을 평가한다. 경우에 따라 자동화된 스크립트 또는 도구를 사용하기도 한다.

내 생각

정리를 하고 보니 용어들이 좀 모한 부분이 있다. 내가 이해한 바로는,,, 개발자 입장에서 Controller 테스트는 테스트 코드 작성 방향에 따라 어떤 테스트인지 달라질 수 있다고 생각한다.

HTTP 요청과 응답을 다루기 때문에 단위 테스트 보다는 통합 테스트라고 보는 게 적절하다.

여기서 사용자 시나리오까지 검증을 한다면 인수 테스트 또는 e2e 테스트라고 보는 것이 맞을텐데, 개발자 입장에서 인수 테스트를 자동화하는 방법 중 하나로 e2e 테스트를 할 수 있다고 이해했다.


테스트 도구

테스트 서버

스프링에서는 @SprignBootTest를 테스트 클래스에 붙여서(실제 어플리케이션을 실행하는 것처럼 테스트 환경을 구성해줌 ➡️ Bean 스캔, 설정 등) 통합 테스트를 할 수 있다.

테스트 클라이언트

테스트 서버에 요청을 보내기 위한 클라이언트 객체를 설정해야 하는데, MockMVC, RestAssured 등 여러 선택지가 있다. 나는 그 중에 RestAssured를 사용해보기로 하였다. 이유는...

전체 시스템을 테스트하고 싶다.
MockMVC@SpringBootTest가 아니라 @WebMvcTest를 사용하는데, 이때 Controller의 의존성들(서비스 등)을 모킹해서 사용해야 하기 때문에 순수하게 Controller만 단위 테스트할 수 있는 환경이 만들어진다. 나는 비즈니스 로직과 이에 대한 단위 테스트를 따로 만들었기 때문에, 실제 서비스 클래스를 Bean으로 등록하여 사용하여 전체 흐름을 테스트하고 싶었다.


테스트 데이터 설정 & 초기화

각각의 테스트는 독립적으로 실행되어야 한다. 한 테스트 케이스가 다른 테스트 케이스에 의존하거나 영향받지 않도록 하기 위함이다.

그러기 위해서는 각 테스트 케이스 실행 전과 후에 데이터를 초기화 해줘야 한다. 그리고 조회, 수정, 삭제 테스트를 하는 경우 테스트 데이터가 미리 들어있어야 성공 케이스에 대한 테스트를 할 수 있을 것이다. 이것도 여러 방안이 있다.

테스트 데이터 설정

  1. Respository 호출 🤔
    Repository에 별도의 메서드를 만들어서 테이블을 채우는 방법이다. 틀린 방법은 아니지만 테스트를 위한 코드가 프로덕션 코드 안에 있는 게 매우 찝찝하다. 협업하는 상황이라면 누군가가 이 코드를 비즈니스 로직에 가져다 쓸 가능성도 있지 않을까? 프로덕션 코드와 섞이면 테스트 코드도 깨질 가능성이 있다.

  2. @Sql (내가 사용한 방법)
    테스트 클래스 레벨에 붙이면 테스트 메소드 실행 전/후에 지정한 SQL 스크립트를 실행할 수 있다. insert 쿼리를 작성한 .sql 파일을 따로 만들어두는 것이다.

테스트 데이터 초기화

  1. @Transactional
    Rest Assured로 테스트할 때 @Transactional을 붙여서 트랜잭션 롤백하는 방법은 사용할 수 없다.
    @Transactional 어노테이션을 사용하여 메서드에 트랜잭션을 적용하면, 해당 메서드가 호출될 때 트랜잭션이 시작되고, 메서드 내에서 수행되는 모든 데이터베이스 작업은 이 트랜잭션 범위 내에서 실행된다. 메서드가 성공적으로 실행되면 트랜잭션은 커밋되고, 예외가 발생하면 롤백된다.
    그러나 Rest Assured와 같은 라이브러리를 사용하면, HTTP 요청이 별도의 스레드에서 실행되기 때문에 HTTP 요청과 관련된 작업은 데이터베이스 트랜잭션과는 별개의 작업으로 처리된다. 따라서 테스트 메서드 종료 후 트랜잭션 롤백이 안 된다.

  2. @Sql (내가 사용한 방법)
    테스트 데이터 설정과 마찬가지로 truncate 쿼리를 작성한 .sql 파일을 따로 만들어두는 것이다.


Rest Assured로 테스트 작성하기

RestController 작성

Todo 라는 도메인에 대한 조회/생성 API이다.

@RestController
@RequiredArgsConstructor
public class TodoApiController {

    private final TodoService todoService;

    @GetMapping("/api/v1/todos")
    public Response<List<GetTodosData>> getTodos() {
        List<Todo> todos = todoService.findTodos();
        List<GetTodosData> collect = todos
                .stream()
                .map(GetTodosData::of)
                .toList();

        return Response.<List<GetTodosData>>builder()
                .code(200)
                .message("Todo list is loaded successfully")
                .data(collect)
                .build();
    }

    @PostMapping("/api/v1/todos")
    @ResponseStatus(HttpStatus.CREATED)
    public Response<PostTodoData> postTodos(
            @RequestBody @Valid PostTodoRequest request
    ) {
        UUID newTodoId = todoService.createTodo(request.getContent());

        return Response.<PostTodoData>builder()
                .code(201)
                .message("Todo is created successfully")
                .data(PostTodoData.of(newTodoId))
                .build();
    }
}

SQL 스크립트 작성

insert.sql

truncate.sql

폴더 지정

테스트 코드 작성

@SpringBootTest
@Sql(scripts = {"/sql/insert.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = {"/sql/truncate.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class TodoApiControllerTest {
	@BeforeAll
    static void setUp() {
        RestAssured.baseURI = "http://127.0.0.1:8080/api/v1";
    }

    @Test
    void 할_일_조회_성공하면_200_응답을_보낸다() {
        // given

        // when
        RequestSpecification request = RestAssured.given();
        Response response = request.get("/todos");

        // then
        response.then()
                .assertThat()
                .statusCode(200);
        response.then()
                .assertThat()
                .contentType(ContentType.JSON);
        response.then()
                .assertThat()
                .body("code", is(200))
                .body("message", isA(String.class))
                .body("data.size()", is(2));

        response.then()
                .assertThat()
                .body("data[0].id", isA(String.class))
                .body("data[0].isDone", isA(Boolean.class))
                .body("data[0].content", isA(String.class))
                .body("data[0].createdAt", isA(String.class))
                .body("data[1].id", isA(String.class))
                .body("data[1].isDone", isA(Boolean.class))
                .body("data[1].content", isA(String.class))
                .body("data[1].createdAt", isA(String.class));
    }

    @Test
    void 할_일_추가_성공하면_201_응답을_보낸다() throws JSONException {
        // given
        JSONObject requestBody = new JSONObject();
        requestBody.put("content", "이것은 테스트");

        // when
        RequestSpecification request = RestAssured.given();
        Response response = request
                .body(requestBody.toString())
                .contentType("application/json")
                .post("/todos");

        // then
        response.then()
                .assertThat()
                .statusCode(201);
        response.then()
                .contentType(ContentType.JSON);
        response.then()
                .body("code", is(201))
                .body("message", isA(String.class))
                .body("data.id", isA(String.class));
    }
}

설명

  1. 각 테스트 메소드 실행 전에 insert.sql을 실행한다.
@Sql(scripts = {"/sql/insert.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
  1. 각 테스트 메소드 실행 후에 truncate.sql을 실행한다.
@Sql(scripts = {"/sql/truncate.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
  1. 전체 테스트에 적용할 base uri를 설정한다
RestAssured.baseURI = "http://127.0.0.1:8080/api/v1";

가독성 높이기

여러 레퍼런스들을 살펴보면 .given(), .when(), .then()으로 쭉 이어서 응답을 검증하곤 하던데,,, 웬만하면 then()의 뒷부분의 코드가 길어질 확률이 높다고 생각한다. given, when, then을 분리하고, then 안에서도 응답 헤더 검증과 응답 바디 검증 부분을 분리하는 게 더 가독성 있어보여서 내 개인 취향을 반영하였다...😏

리팩터 전

@Test
void 할_일_추가하기_성공하면_201_응답을_보낸다() throws JSONException {
    JSONObject requestBody = new JSONObject();
    requestBody.put("content", "이것은 테스트");

    RestAssured
            .given()
            .body(requestBody.toString())
            .contentType(ContentType.JSON)
            .when()
            .post("/todos")
            .then()
            .assertThat()
            .statusCode(201)
            .contentType(ContentType.JSON)
            .body("code", is(201))
            .body("message", isA(String.class))
            .body("data.id", isA(String.class));
}

리팩터 후

@Test
void 할_일_추가_성공하면_201_응답을_보낸다() throws JSONException {
    // given
    JSONObject requestBody = new JSONObject();
    requestBody.put("content", "이것은 테스트");

    // when
    RequestSpecification request = RestAssured.given();
    Response response = request
            .body(requestBody.toString())
            .contentType("application/json")
            .post("/todos");

    // then
    response.then()
            .assertThat()
            .statusCode(201);
    response.then()
            .contentType(ContentType.JSON);
    response.then()
            .body("code", is(201))
            .body("message", isA(String.class))
            .body("data.id", isA(String.class));
}

Reference

  1. spring mvc 에서 controller 테스트 코드에서는 뭘 테스트 해야할까?
  2. 단위 테스트 vs 통합 테스트 vs 인수 테스트
  3. MockMvc VS RestAssured
  4. [3월 우아한테크세미나] 우아한ATDD <- 설명 최고임...👍
  5. API test + transactional rollback(stackoverflow)
  6. 만들면서 배우는 클린 아키텍처(톰 홈버그 지음)
profile
완벽주의가 아닌 완성주의(블로그 이동 중...)

0개의 댓글