RestAssured
라는 라이브러리로 Controller
를 테스트 하는 방법을 정리해보려고 한다.
Controller의 어떤 것을 테스트해야 할지 생각해보았다. 우선 Controller
에서 하는 일이 무엇일까? Controller
의 책임은 일반적으로 다음과 같다.
HTTP
요청을 객체로 매핑(= 역직렬화deserialize
)HTTP
로 매핑HTTP
응답을 반환그렇다면 테스트의 목적은 Controller
가 맡은바 책임을 다하고 있는지 검사하는 것이다. 클라이언트로부터 HTTP
요청을 받아서 예상한 대로의 응답을 반환해주는지 테스트 해보는 것이다.
Controller
를 검증하는 테스트는 뭐라고 불러야 할까? 어디서는 통합 테스트, 또 다른 곳에서는 e2e 테스트, 인수 테스트라고 하는데,,, 뭐가 맞을까?
애플리케이션의 여러 컴포넌트 또는 모듈 간의 상호 작용을 검증한다. 모듈 간의 통합이 올바르게 이루어지는지 확인하고, 인터페이스 간의 데이터 전달 및 상호 작용을 테스트한다.
시스템의 전체 기능과 흐름을 검증한다. 사용자 관점에서 시스템이 예상대로 동작하는지 확인한다.
사용자 또는 이해 관계자의 요구사항과 기대사항을 충족하는지 확인하기 위해 수행된다. 프로젝트 이해관계자들이 모여 사용자 스토리(시나리오)를 만들고 개발자가 이를 기반으로 코드로 테스트를 작성할 수 있다.
개발팀 내에서, 개발 환경 또는 테스트 환경에서 수행된다. 모듈 간의 통합을 검증하기 위해 실제 운영 환경은 필요하지 않다.
시스템의 전체 플로우를 시뮬레이션하므로 실제 운영 환경과 가장 유사한 환경(프로덕션과 동일한 하드웨어, 소프트웨어, 네트워크 설정 등)에서 수행되어야 한다.
실제 사용자 또는 고객이 직접 수행할 수 있으므로 실제 운영 환경 또는 프로덕션 환경에서 수행되어야 한다.
개발자가 주로 자동화된 테스트 프레임워크 또는 테스트 도구를 사용한다. 각 컴포넌트를 단위 테스트한 후, 이러한 테스트 케이스를 통합하여 실행한다.
자동화된 도구를 사용하여 사용자 시나리오를 시뮬레이션하고 결과를 확인하는 것이 일반적이다.
주로 사용자 또는 고객이 직접 시스템을 사용하며 요구사항을 확인하고 사용자 경험을 평가한다. 경우에 따라 자동화된 스크립트 또는 도구를 사용하기도 한다.
정리를 하고 보니 용어들이 좀 모한 부분이 있다. 내가 이해한 바로는,,, 개발자 입장에서 Controller
테스트는 테스트 코드 작성 방향에 따라 어떤 테스트인지 달라질 수 있다고 생각한다.
HTTP
요청과 응답을 다루기 때문에 단위 테스트 보다는 통합 테스트라고 보는 게 적절하다.
여기서 사용자 시나리오까지 검증을 한다면 인수 테스트 또는 e2e 테스트라고 보는 것이 맞을텐데, 개발자 입장에서 인수 테스트를 자동화하는 방법 중 하나로 e2e 테스트를 할 수 있다고 이해했다.
스프링에서는 @SprignBootTest
를 테스트 클래스에 붙여서(실제 어플리케이션을 실행하는 것처럼 테스트 환경을 구성해줌 ➡️ Bean
스캔, 설정 등) 통합 테스트를 할 수 있다.
테스트 서버에 요청을 보내기 위한 클라이언트 객체를 설정해야 하는데, MockMVC
, RestAssured
등 여러 선택지가 있다. 나는 그 중에 RestAssured
를 사용해보기로 하였다. 이유는...
전체 시스템을 테스트하고 싶다.
MockMVC
는 @SpringBootTest
가 아니라 @WebMvcTest
를 사용하는데, 이때 Controller
의 의존성들(서비스 등)을 모킹해서 사용해야 하기 때문에 순수하게 Controller
만 단위 테스트할 수 있는 환경이 만들어진다. 나는 비즈니스 로직과 이에 대한 단위 테스트를 따로 만들었기 때문에, 실제 서비스 클래스를 Bean
으로 등록하여 사용하여 전체 흐름을 테스트하고 싶었다.
각각의 테스트는 독립적으로 실행되어야 한다. 한 테스트 케이스가 다른 테스트 케이스에 의존하거나 영향받지 않도록 하기 위함이다.
그러기 위해서는 각 테스트 케이스 실행 전과 후에 데이터를 초기화 해줘야 한다. 그리고 조회, 수정, 삭제 테스트를 하는 경우 테스트 데이터가 미리 들어있어야 성공 케이스에 대한 테스트를 할 수 있을 것이다. 이것도 여러 방안이 있다.
Respository
호출 🤔
Repository
에 별도의 메서드를 만들어서 테이블을 채우는 방법이다. 틀린 방법은 아니지만 테스트를 위한 코드가 프로덕션 코드 안에 있는 게 매우 찝찝하다. 협업하는 상황이라면 누군가가 이 코드를 비즈니스 로직에 가져다 쓸 가능성도 있지 않을까? 프로덕션 코드와 섞이면 테스트 코드도 깨질 가능성이 있다.
@Sql
(내가 사용한 방법)
테스트 클래스 레벨에 붙이면 테스트 메소드 실행 전/후에 지정한 SQL
스크립트를 실행할 수 있다. insert
쿼리를 작성한 .sql
파일을 따로 만들어두는 것이다.
@Transactional
❌
Rest Assured
로 테스트할 때 @Transactional
을 붙여서 트랜잭션 롤백하는 방법은 사용할 수 없다.
@Transactional
어노테이션을 사용하여 메서드에 트랜잭션을 적용하면, 해당 메서드가 호출될 때 트랜잭션이 시작되고, 메서드 내에서 수행되는 모든 데이터베이스 작업은 이 트랜잭션 범위 내에서 실행된다. 메서드가 성공적으로 실행되면 트랜잭션은 커밋되고, 예외가 발생하면 롤백된다.
그러나 Rest Assured
와 같은 라이브러리를 사용하면, HTTP
요청이 별도의 스레드에서 실행되기 때문에 HTTP
요청과 관련된 작업은 데이터베이스 트랜잭션과는 별개의 작업으로 처리된다. 따라서 테스트 메서드 종료 후 트랜잭션 롤백이 안 된다.
@Sql
(내가 사용한 방법)
테스트 데이터 설정과 마찬가지로 truncate
쿼리를 작성한 .sql
파일을 따로 만들어두는 것이다.
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();
}
}
@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));
}
}
@Sql(scripts = {"/sql/insert.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = {"/sql/truncate.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
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
- spring mvc 에서 controller 테스트 코드에서는 뭘 테스트 해야할까?
- 단위 테스트 vs 통합 테스트 vs 인수 테스트
- MockMvc VS RestAssured
- [3월 우아한테크세미나] 우아한ATDD <- 설명 최고임...👍
- API test + transactional rollback(stackoverflow)
- 만들면서 배우는 클린 아키텍처(톰 홈버그 지음)