REST Assured?
REST API를 단순히 테스트하는 Java DSL(Domain-specific language)이다.
POST, GET, PUT, DELETE, PATCH 및 HEAD Request를 지원하며, 요청과 응답을 검증하는 데 사용한다.
왜 사용하는가?
기존의 단위 테스트, 통합 테스트로 개발자의 안심을 이끌어 낼 수 있지만, 이는 내부 개발자의 관점이다.
Rest-Assured는 외부 사용자의 관점에서 코드에 상관없이 요청과 응답으로 REST API 자동화 테스트를 구성하고 확인할 수 있어서, 사용자의 관점에서 한번 더 안심을 할 수 있다.
어떻게 보면 테스트를 더 추가하는 거라 과도한 테스트 코드라 의심할 수 있지만, 사용법이 매우 단순하기 때문에 간단히 Java를 알면 사용자 친화적인 커스터마이징과 함께 API 호출을 할 수 있다.
애자일 기법인 ATDD에 사용하기 좋고, 레거시에서 복잡한 의존성에 상관없이 테스트 코드를 추가하는 쉬운 방법에 속하기도 한다.
특징
- given-when-then 패턴으로 가독성이 좋다.
- 응답된 JSON, XML를 손쉽게 검증할 수 있다. (여기선 JSON 기준으로 진행한다.)
- 블랙박스 테스트로 오로지 Request(요청)와 Response(응답)로 결과를 확인한다.
의존성 추가하기
가이드에 나와있는 의존성 추가하는 방법이다.
Maven
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.1.1</version>
<scope>test</scope>
</dependency>
Gradle
testImplementation 'io.rest-assured:rest-assured:5.1.1'
정상적으로 추가가 되었으면 io.rest-assured를 확인할 수 있다.
RestAssured 사용하기
간단한 학습용으로 생성했다.
RestAssured는 given-when-then 패턴으로 간단한 구조를 가진다.
given()을 호출하면 RequestSpecification 객체가 생성되는데 다양한 요청 값을 세팅할 수 있다. (RequestSpecification는 말 그대로 요청 사양이다.)
when()은 HTTP Resource를 지정한다. HTTP Method와 path를 지정하면 요청이 보내진다. 만약, 잘못된 요청 값이나 path가 잘못되면 요청은 실패가 되면서 error를 확인할 수 있다.
then()은 응답을 확인할 수 있다. 검증은 RestAssured 기능을 사용하거나, 응답만 추출하여 AssertJ로 검증하는 방법으로 구분된다.
Specifying Request Data 요청 사양 데이터 세팅하기
Parameters
일반적인 파라미터 지정은 param을 사용한다.
given()
.param("param1", "value1")
.param("param2", "value2")
.when()
...
RestAssured는 HTTP 메서드에 따라 자동으로 파라미터 유형(쿼리 파라미터, 폼 파라미터)을 결정해준다.
명시적으로 사용하려면 formParam, queryParam를 사용하면 된다.
given()
.formParam("formParamName", "formValue")
.queryParam("queryParamName", "queryValue")
.when()
...
URL에 직접 설정도 가능하다.
RestAssued.when()
.get("/study?topic=RestAssued");
요청할 값이 없거나 url에 직접 명시하면 given() 생략하고 when()으로 시작해도 된다.
Multi-value parameter
매개 변수 하나에 2개 이상의 값을 넣는 방법은 var-gars 사용하면 단순하다.
given().
param("list", "value1", "value2").
list를 사용해도 된다.
List<String> values = new ArrayList<String>();
values.add("value1");
values.add("value2");
RestAssured
.given()
.param("name1", values);
No-value parameter
만약에 값을 안 보낼 경우 매개 변수명만 지정하면 된다.
given()
.param("paramName")
.when()
...
Path parameters
요청 경로에 매개변수를 설정하는 방법도 여러 가지 있다.
RestAssured.when()
.post("/study/{topic}/{status}", "RestAssured", "모집중");
RestAssured
.given()
.pathParam("topic", "RestAssured")
.pathParam("status", "모집중")
.when()
.post("/study/{topic}/{status}");
Map<String, Object> pathMap = new HashMap<>();
pathMap.put("topic", "Acceptance");
pathMap.put("status", "모집중");
RestAssured
.given()
.pathParams(pathMap)
.when()
.post("/study/{topic}/{status}");
Cookies 쿠키
위의 parameter와 비슷하다.
given()
.cookie("topic", "rest-assured")
.when()...
변수에 여러 쿠키값을 넣는 것도 이전과 동일하다.
given()
.cookie("topic", "rest1", "rest2")
.when()
.get("/study");
RestAssured에서 제공하는 Cookies, Cookie를 이용해도 된다.
Cookie topicCookie = new Cookie.Builder("topic", "rest-assured")
.setSecured(true)
.setComment("Rest Assured study")
.build();
RestAssured
.given().log().all()
.cookie(topicCookie)
.when()
.get("/study");
Cookie cookie1 = new Cookie.Builder("name1", "loop").build();
Cookie cookie2 = new Cookie.Builder("name2", "study").build();
Cookies cookies = new Cookies(cookie1, cookie2);
RestAssured.given().log().all()
.cookies(cookies)
.when().get("/study");
Headers
헤더 설정하는 방법도 이전처럼 매우 단순하다.
RestAssured.given().log().all()
.header("MyHeader", "Something")
.when()
여러 개를 넣는 방법은 2가지다. headers를 이용하거나 Map에 담는다.
RestAssured.given().log().all()
.headers("MyHeader", "Something", "MyOtherHeader", "SomethingElse", ...);
Map<String, Object> headerMap = new HashMap<>();
headerMap.put("MapHeader", "Something");
headerMap.put("MapOtherHeader", "SomethingElse");
RestAssured.given().log().all()
.headers(headerMap)
.when().get("/study");
Content Type
요청하는 API 기준에 맞게 컨텐츠 유형도 간단하게 설정이 되는데 직접 명시하거나
RestAssured에서 제공하는 ContentType 혹은 스프링에서 제공하는 http의 MediaType을 사용해도 된다.
RestAssured.given().log().all()
.contentType(ContentType.JSON) // 혹은 MediaType.APPLICATION_JSON_VALUE
.when().get("/study");
RestAssured.given().log().all()
.contentType("application/json")
.when().get("/study");
Request body
요청 본문 설정도 단순하다. body(값)이면 끝난다.
RestAssured.given().log().all()
.body("some body")
.when()
.post("/study"); // POST, PUT, DELETE에 사용한다.
값은 단순 문자열부터 객체까지 다양하게 넣을 수 있다.
BodyParam bodyParam = new BodyParam();
bodyParam.setName("loop");
bodyParam.setValue("study");
RestAssured.given().log().all()
.body(bodyParam)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when().post("/study"); // POST, PUT, DELETE에 사용한다.
// ==================
class BodyParam {
String name;
String value;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
}
Map<String, Object> bodyMap = new HashMap<>();
bodyMap.put("name", "loop");
bodyMap.put("value", "study");
RestAssured.given().log().all()
.body(bodyMap)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when().post("/study"); // POST, PUT, DELETE에 사용한다.
Authentication
몇몇 API 테스트엔 로그인 여부가 필요한 경우가 있다.
제공하는 기본 인증 방식이다.
given().auth().basic("username", "password"). ..
// 혹은
RestAssured.authentication = basic("username", "password");
그런데 유저를 생성하고 로그인하는 과정을 하나씩 요청하긴 번거롭다.
이것도 FormAuthConfig 사용하면 과정을 줄일 수 있다.
... 로그인 유저 생성 과정 생략
// 로그인과 마이페이지 요청 동시에 처리
RestAssured
.given().log().all()
.auth().form("email@email.com", "q1w2e3r4!", new FormAuthConfig("j_spring_security_check", "j_username", "j_password"))
.when()
.post("/mypage")
<html>
<head>
<title>Login</title>
</head>
<body>
<form action="j_spring_security_check" method="POST">
<table>
<tr><td>User: </td><td><input type='text' name='j_username'></td></tr>
<tr><td>Password:</td><td><input type='password' name='j_password'></td></tr>
<tr><td colspan='2'><input name="submit" type="submit"/></td></tr>
</table>
</form>
</body>
</html>
FormAuthConfig 매개변수는 순서대로 경로, 아이디, 패스워드로 매핑이 된다.
OAuth
OAuth의 경우 버전 2.5.0 기준으로 OAuth, OAuth2 구분된다.
OAuth를 사용한다면 Scribe가 필요하다.
Maven이면 의존성을 추가하지만, 아니라면 직접 수동으로 다운로드한다.
<dependency>
<groupId>com.github.scribejava</groupId>
<artifactId>scribejava-apis</artifactId>
<version>2.5.3</version>
<scope>test</scope>
</dependency>
given().auth().oauth(..). ..
버전 2.5.0 이후에 사용되는 OAuth2는 Scribe 없이 사용하면 된다.
given().auth().oauth2(accessToken). ..
token 방식을 이용한 경우엔 번거로운 과정이 있다.
로그인 요청을 한 뒤에 응답된 token 값을 추출하는 과정이 생긴다.
// ... 로그인 유저 생성 과정 생략
Map<String, String> params = new HashMap<>();
params.put("email", email);
params.put("password", password);
ExtractableResponse<Response> response = RestAssured.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(params)
.when().post("/login/token")
.then().log().all()
.statusCode(HttpStatus.OK.value()).extract();
String accessToken = response.jsonPath().getString("accessToken");
RestAssured.given().log().all()
.auth().oauth2(accessToken)
.accept(MediaType.APPLICATION_JSON_VALUE)
.when().get("/members/me")
.then().log().all()
.statusCode(HttpStatus.OK.value())
.extract();
Verifying Response Data 응답 데이터 확인하기
처음에 언급한 것처럼 검증 방법은 2가지로 구분된다.
RestAssured를 이용한 검증
then에서 검증을 진행하는데 Hamcrest matcher가 이용된다.
given에서 요청 값 세팅하는 것과 비슷한 방식으로 검증하는데
assertThat을 명시적인 의미로 선언하고 검증한다. (없어도 검증은 된다)
- Cookies
get("/x").then().assertThat().cookie("cookieName", "cookieValue"). ..
get("/x").then().assertThat().cookies("cookieName1", "cookieValue1", "cookieName2", "cookieValue2"). ..
get("/x").then().assertThat().cookies("cookieName1", "cookieValue1", "cookieName2", containsString("Value2")). ..
- Status
get("/x").then().assertThat().statusCode(200). ..
get("/x").then().assertThat().statusLine("something"). ..
get("/x").then().assertThat().statusLine(containsString("some")). ..
- Headers
get("/x").then().assertThat().header("headerName", "headerValue"). ..
get("/x").then().assertThat().headers("headerName1", "headerValue1", "headerName2", "headerValue2"). ..
get("/x").then().assertThat().headers("headerName1", "headerValue1", "headerName2", containsString("Value2")). ..
- Content Type
get("/x").then().assertThat().contentType(ContentType.JSON). ..
- body
본문은 전체 검증하거나 일부분만 추출해서 검증하는데 Json 형태는 path 지정으로 원하는 값을 검증할 수 있다.
검증에는 io.restassured.matcher.RestAssuredMatchers를 이용한다.
// 응답 받은 값
HTTP/1.1 201
Location: /study/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 29 Aug 2022 04:33:54 GMT
Keep-Alive: timeout=60
Connection: keep-alive
{
"id": 1,
"topic": "REST-Assured",
"createdDate": "2022-08-29T13:33:54.245068",
"modifiedDate": "2022-08-29T13:33:54.245068",
"innerResponse": {
"jsonName": "json",
"jsonValue": "Path"
}
}
path를 생략하면 전체 본문을 확인한다.
get("/x").then().assertThat().body(equalTo("something")). ..// 전체 분문과 일치하지 않으니 실패.
path를 지정하면 해당 값을 확인한다.
get("/x").then().body("id", response -> equalTo(1);
get("/x").then().body("innerResponse.jsonName", equalTo("json"))
위의 방식을 사용하여 검증하면 아래와 같다.
String topic = "REST-Assured";
Map<String, Object> params = new HashMap<>();
params.put("topic", topic);
RestAssured
.given().log().all()
.body(params)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when()
.post("/study")
.then().log().all()
.assertThat()
.statusCode(HttpStatus.CREATED.value())
.body("topic", response -> equalTo(topic))
.body("id", response -> equalTo(1));
검증이 실패한 경우엔 아래와 같이 표시된다.
AssertJ를 이용하여 검증하기 (응답 추출)
AssertJ를 이용한 검증을 위해선 값을 꺼내야 하는데
RestAssured에서 이를 지원하는 기능이 있다.
then에서 extract()를 사용하면 된다.
ExtractableResponse<Response> createResponse = RestAssured
.given().log().all()
.body(params)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when()
.post("/study")
.then().log().all()
.extract();
ExtractableResponse<Response>를 반환하는데 말 그대로 응답 값이 모두 담겨 있다.
값을 가져오는 방법도 이전처럼 단순하다.
여기서 살펴볼 것은 jsonPath 기능이다. body에서 path를
지정하는 것처럼 jsonPath().get("path")를 이용해서 원하는 값을 선택한다.
이를 이용해서 AssertJ 사용한다.
// 과정 생략...
ExtractableResponse<Response> createResponse = RestAssured
.given().log().all()
.body(params)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when()
.post("/study")
.then().log().all()
.extract();
assertAll(
() -> assertThat(createResponse.statusCode()).isEqualTo(HttpStatus.CREATED.value()),
() -> assertThat(createResponse.jsonPath().get("topic")).isEqualTo(topic)
);
Logging 로깅
RestAssured를 이용하면서 요청과 응답의 값을 편하게 확인할 수 있게 log 기능도 제공한다. (사용 안 하면 직접 하나씩 확인해야 한다.)
사용 방법은 단순하다.
Request Logging
아래는 all() 기준으로 출력되는 logging 포맷이다.
Request method: POST
Request URI: http://localhost:50029/study
Proxy: <none>
Request params: <none>
Query params: <none>
Form params: <none>
Path params: <none>
Headers: Accept=*/*
Content-Type=application/json
Cookies: <none>
Multiparts: <none>
Body:
{...}
원하는 부분만 출력하려면 가이드에서 제공하는 가이드를 참고해보자.
given().log().all(). .. // Log all request specification details including parameters, headers and body
given().log().params(). .. // Log only the parameters of the request
given().log().body(). .. // Log only the request body
given().log().headers(). .. // Log only the request headers
given().log().cookies(). .. // Log only the request cookies
given().log().method(). .. // Log only the request method
given().log().path(). .. // Log only the request path
Response Logging
이전처럼 비슷한 logging 포맷이다.
HTTP/1.1 201
Location: /study/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 29 Aug 2022 04:33:54 GMT
Keep-Alive: timeout=60
Connection: keep-alive
{
"id": 1,
"topic": "REST-Assured",
"createdDate": "2022-08-29T13:33:54.245068",
"modifiedDate": "2022-08-29T13:33:54.245068",
"innerResponse": {
"jsonName": "json",
"jsonValue": "Path"
}
}
요청 로깅과 흡사한 기능을 제공한다.
get("/x").then().log().all() ..
get("/x").then().log().body() ..
get("/x").then().log().statusLine(). .. // Only log the status line
get("/x").then().log().headers(). .. // Only log the response headers
get("/x").then().log().cookies(). .. // Only log the response cookies
조건에 따라 응답을 기록하게 할 수 있다.
get("/x").then().log().ifError(). .. // 오류가 발생하면 기록
get("/x").then().log().ifStatusCodeIsEqualTo(302). .. // 응답 상태코드가 302와 동일할 때 기록
get("/x").then().log().ifStatusCodeMatches(matcher). .. // 응답 상태코드가 일부와 일치할때 기록
리팩토링하기
RestAssured로 작성된 코드를 보면 중복되는 로직이 많다. 예를 들면 스터디 생성하는 로직이다.
스터디 생성, 스터디 수정, 스터디 조회 등 다양한 테스트 케이스에 반복된다.
RestAssured
.given().log().all()
.body(params)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when()
.post("/study")
.then().log().all()
.extract();
/**
* When 스터디 생성 요청을 하면
* Then 스터디 생성이 된다.
*/
@Test
void 스터디_생성() {
String topic = "REST-Assured";
Map<String, Object> params = new HashMap<>();
params.put("topic", topic);
ExtractableResponse<Response> createResponse =
RestAssured
.given().log().all()
.body(params)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when()
.post("/study")
.then().log().all()
.extract();
assertAll(
() -> assertThat(createResponse.statusCode()).isEqualTo(HttpStatus.CREATED.value()),
() -> assertThat(getTopic(createResponse)).isEqualTo(topic)
);
}
/**
* Given 스터디 생성 요청을 하고
* When 스터디 조회 요청을 하면
* Then 스터디가 조회된다.
*/
@Test
void 스터디_조회() {
String topic = "REST-Assured";
Map<String, Object> params = new HashMap<>();
params.put("topic", topic);
ExtractableResponse<Response> createResponse =
RestAssured
.given().log().all()
.body(params)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when()
.post("/study")
.then().log().all()
.extract();
// 조회하는 로직
}
/**
* Given 스터디 생성 요청을 하고
* When 스터디 정보 수정을 요청하면
* Then 스터디 정보가 수정된다.
*/
@Test
void 스터디_수정() {
String topic = "REST-Assured";
Map<String, Object> params = new HashMap<>();
params.put("topic", topic);
ExtractableResponse<Response> create = RestAssured.given().log().all()
.body(params)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when()
.post("/study")
.then().log().all()
.extract();
// 수정하는 로직
}
/**
* Given 스터디 생성 요청을 하고
* When 스터디 삭제 요청을 하면
* Then 스터디가 삭제된다.
*/
@Test
void 스터디_삭제() {
String topic = "REST-Assured";
Map<String, Object> params = new HashMap<>();
params.put("topic", topic);
ExtractableResponse<Response> create = RestAssured.given().log().all()
.body(params)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when()
.post("/study")
.then().log().all()
.extract();
// 삭제하는 로직
}
특징이 있기 때문에 공통 메서드로 추출하여 재사용성과 불필요한 코드를 줄이고 표현력을 높일 수 있다.
private ExtractableResponse<Response> createStudy(String topic) {
Map<String, Object> params = new HashMap<>();
params.put("topic", topic);
return RestAssured.given().log().all()
.body(params)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when()
.post("/study")
.then().log().all()
.extract();
}
추출된 메서드로 리팩토링하면 이전에 비해 많이 보기 편하다.
/**
* When 스터디 생성 요청을 하면
* Then 스터디 생성이 된다.
*/
@Test
void 스터디_생성() {
String topic = "REST-Assured";
ExtractableResponse<Response> create = createStudy(topic);
// 검증 하는 로직
}
/**
* Given 스터디 생성 요청을 하고
* When 스터디 조회 요청을 하면
* Then 스터디가 조회된다.
*/
@Test
void 스터디_조회() {
String topic = "REST-Assured";
createStudy(topic);
// 조회하는 로직...
}
/**
* Given 스터디 생성 요청을 하고
* When 스터디 정보 수정을 요청하면
* Then 스터디 정보가 수정된다.
*/
@Test
void 스터디_수정() {
String topic = "REST-Assured";
ExtractableResponse<Response> create = createStudy(topic);
// 수정하는 로직...
}
/**
* Given 스터디 생성 요청을 하고
* When 스터디 삭제 요청을 하면
* Then 스터디가 삭제된다.
*/
@Test
void 스터디_삭제() {
String topic = "REST-Assured";
ExtractableResponse<Response> create = createStudy(topic);
// 삭제하는 로직...
}
이는 한 가지의 예제일 뿐, 반복되는 RestAssured를 다양한 형태로 공통을 추출하고 사용하면 위의 예제보다 더 좋은 결과로 리팩터링 할 수 있다.
마무리하면서
RestAssured는 내부의 관점에서만 안심하지 않고 외부의 관점에서 안심할 수 있게 도와주는 도구라 할 수 있다.
이런 특징 덕분에 인수 테스트에 사용하고, 레거시 리팩터링에도 활용할 수 있다.
대부분 가이드 기반으로 포스팅을 했지만 언급 안 된 다양한 기능이 가이드에 있으니 자세한 내용이 궁금하면 공식 가이드를 참고하도록 하자.
참고 자료
'개발 & 방법론 > ATDD' 카테고리의 다른 글
ATDD는 무엇인가? (4) | 2022.04.13 |
---|---|
ATDD를 접하게 된 과정 (0) | 2022.04.01 |