Rest Assured์ Rest Docs๋ฅผ ํตํ ํ ์คํธ ๋ฐ API๋ฌธ์ํ
์ธ์ ํ
์คํธ(acceptance test)๋ ์์คํ
์ด ์์๋๋ก ๋์ํ๋์ง ํ์ธํ๊ณ , ์๊ตฌ ์ฌํญ์ ๋ง๋์ง ํ์ ํ๊ธฐ ์ํด ํ๋ ํ
์คํธ์ด๋ค.
์์คํ
์ ์ธ์ํ๊ธฐ ์ ์๊ตฌ ๋ถ์ ๋ช
์ธ์์ ๋ช
์๋ ๋๋ก ๋ชจ๋ ์ถฉ์กฑ์ํค๋์ง๋ฅผ ์ฌ์ฉ์๊ฐ ํ
์คํธํ๋ค.
์ธ์ ํ
์คํธ๋ ์ฌ์ฉ์ ์ฃผ๋๋ก ์ด๋ฃจ์ด์ง๋ฉฐ, ์ค๋ฅ ๋ฐ๊ฒฌ๋ณด๋ค๋ ์ ํ์ ์ถ์ ์ฌ๋ถ๋ฅผ ํ๋จํ๋ ๊ฒ์ด ์ฃผ๋ชฉ์ ์ด๋ค
๋ฐฑ์๋ ๊ฐ๋ฐ์์ UI ๋ ๋ฒจ์์ ์ธ์ ํ
์คํธ๋ฅผ ํ๋๊ฑด ๋ฌด๋ฆฌ๊ฐ ์๊ธฐ ๋๋ฌธ์ API๋ ๋ฒจ์์ ์ฌ์ฉ์ ์๋๋ฆฌ์ค๋ฅผ ๋ง์กฑ์ํค๊ธฐ ์ํด ํ
์คํธํ๋ค.
Mock๋ ์๋ธ๋ฆฟ ์ปจํ ์ด๋์ ๊ตฌ๋ ์์ด, ์๋ฎฌ๋ ์ด์ ๋ MVC ํ๊ฒฝ์์ ๋ชจ์ HTTP ์๋ธ๋ฆฟ ์์ฒญ์ ์ ์กํ๋ ๊ธฐ๋ฅ์ ์ ๊ณตํ๋ ์ ํธ๋ฆฌํฐ ํด๋์ค์ด๋ค. ์ฃผ๋ก Controller ๋ ์ด์ด์ ๋จ์ ํ ์คํธ์ ์ฐ์ธ๋ค.
REST Assured๋ REST API๋ฅผ ๊ฒ์ฆํ๊ธฐ ์ํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ด๋ฉฐ End to End Test์ด๋ค.
์์คํ
์ ์ฒด ๋ ์ด์ด๋ฅผ ๊ดํตํ๋ฉฐ ์ฌ์ฉ์ ๊ด์ ์์ ๊ธฐ๋ฅ์ด ์ ๋์ํ๋์ง ํ
์คํธํ ์ ์๋ค.
์ธ์ํ ์คํธ๋ฅผ ํ๋๋ฐ ๋ชฉ์ ์ฌ์ฉํ๋๊ฑด ์ด์ธ๋ฆฌ์ง ์๋๋ค. ํตํฉ์ ์ธ ๊ด์ ์์ ์ค๋ฅ๋ฅผ ์ฐพ์ ์ ์๋ REST Assured๋ฅผ ์ฌ์ฉํ๋๊ฒ ์ด์ธ๋ฆฐ๋ค.
๊ฐ๋จํ ํ์๊ฐ์ ๊ณผ ๋ก๊ทธ์ธ API๋ฅผ ํ ์คํธ ํด๋ณด๊ฒ ์ต๋๋ค.
ํ์๊ฐ์
๋ก๊ทธ์ธ
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.11'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10'
//rest docs
id "org.asciidoctor.jvm.convert" version "3.3.2"
}
group = 'cbnu.io'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
configurations {
asciidoctorExt
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
asciidocVersion = "2.0.6.RELEASE"
snippetsDir = file('build/generated-snippets')
}
dependencies {
...
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
asciidoctorExt "org.springframework.restdocs:spring-restdocs-asciidoctor:${asciidocVersion}"
testImplementation 'org.springframework.security:spring-security-test'
testImplementation "org.springframework.restdocs:spring-restdocs-mockmvc:${asciidocVersion}"
testImplementation 'org.springframework.restdocs:spring-restdocs-restassured'
testImplementation 'io.rest-assured:rest-assured'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.2'
...
}
tasks.named('test') {
outputs.dir snippetsDir
useJUnitPlatform()
}
//-------------------rest docs----------------------------------------------
test {
outputs.dir snippetsDir
}
asciidoctor {
inputs.dir snippetsDir
configurations 'asciidoctorExt'
dependsOn test
}
bootJar {
dependsOn asciidoctor
delete file('src/main/resources/static/docs/*')
copy {
from asciidoctor.outputDir
into "src/main/resources/static/docs"
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
asciidoctorExtensions
}
@RestController
@RequestMapping("/api/member")
public class SignupAPI {
private final SignupCommand signupCommand;
public SignupAPI(SignupCommand signupCommand) {
this.signupCommand = signupCommand;
}
@PostMapping(value = "/signup", consumes = {"multipart/form-data"})
public ResponseEntity<ApiResponse> signup(
@RequestPart @Valid SignupRequest request,
@RequestPart("file")MultipartFile file
) {
Member signup = signupCommand.signup(request, file);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.of(signup.getId(), HttpStatus.CREATED));
}
}
ํ์๊ฐ์ API์ ๊ฒฝ์ฐ mult-part ๋ฐฉ์์ผ๋ก ๋ฐ์ต๋๋ค.
@RestController
@RequestMapping("/api/member")
public class LoginAPI {
private final LoginService loginService;
public LoginAPI(LoginService loginService) {
this.loginService = loginService;
}
@PostMapping("/login")
public ResponseEntity login(@RequestBody @Valid LoginRequest loginRequest) {
Token token = loginService.login(loginRequest);
return ResponseEntity
.status(HttpStatus.OK)
.header(SecurityFilter.AUTHORIZATION_HEADER, token.getAccessToken())
.header(SecurityFilter.SESSION_ID, token.getRefreshToken())
.header(SecurityFilter.AUTHORITY, token.getRole().toString())
.build();
}
}
๋ก๊ทธ์ธ API๋ json ๋ฐ๋๋ก ๋ฐ์ต๋๋ค.
์ ์ด์ ์ ๋ API๋ฅผ ์ธ์ํ
์คํธ ๋ฐ ๋ฌธ์ํ๋ฅผ ์งํํด ๋ณด๊ฒ ์ต๋๋ค.
@DisplayName("ํ์๊ฐ์
๊ธฐ๋ฅ ์ธ์ ํ
์คํธ")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ExtendWith({RestDocumentationExtension.class, SpringExtension.class})
@Import(S3Configuration.class)
public class SignupAcceptanceTest extends DatabaseTestBase {
public static final String BASE_URL = "http://localhost";
@LocalServerPort
private int port = 8080;
private RequestSpecification documentationSpec = new RequestSpecBuilder()
.setPort(port)
.setBaseUri(BASE_URL)
.build();
@BeforeAll
public void configureRestAssured() {
RestAssured.port = port;
baseURI = BASE_URL;
}
@BeforeEach
public void setUpRestDocs(RestDocumentationContextProvider restDocumentation) {
this.documentationSpec = new RequestSpecBuilder()
.addFilter(RestAssuredRestDocumentation.documentationConfiguration(restDocumentation))
.build();
}
@Test
@DisplayName("ํ์๊ฐ์
API ํ
์คํธ")
public void given_valid_signup_info_when_signup_then_id() {
// given - loginId, password, studentNumber
JsonObject jsonObject = getSignupMember();
byte[] fileByte = "Hello, World".getBytes();
RequestSpecification signup = RestAssured.given(documentationSpec)
.multiPart("request", jsonObject.toString(), APPLICATION_JSON_VALUE)
.contentType(APPLICATION_JSON_VALUE)
.multiPart("file", "file.png", fileByte, MULTIPART_FORM_DATA_VALUE)
.contentType(MULTIPART_FORM_DATA_VALUE)
.filter(document(
"users-post",
getRequestPartsSnippet(),
getRequestPartFieldsSnippet(),
getResponseFieldsSnippet()
));
// then - signup
signup.when()
.post("/api/member/signup")
.then()
.statusCode(HttpStatus.CREATED.value());
}
private RequestPartsSnippet getRequestPartsSnippet() {
return requestParts(
partWithName("file").description("ํ์์ฆ ์ฌ์ง"),
partWithName("request").description("์ ์ ์ ๋ณด")
);
}
private RequestPartFieldsSnippet getRequestPartFieldsSnippet() {
return requestPartFields(
"request",
fieldWithPath("loginId").description("๋ก๊ทธ์ธ ์์ด๋"),
fieldWithPath("password").description("ํจ์ค์๋"),
fieldWithPath("studentNumber").description("ํ๋ฒ")
);
}
private ResponseFieldsSnippet getResponseFieldsSnippet() {
return responseFields(
fieldWithPath("eventTime").type(JsonFieldType.STRING).description("์ด๋ฒคํธ ์๊ฐ"),
fieldWithPath("status").type(JsonFieldType.STRING).description("HTTP ์ํ๊ฐ"),
fieldWithPath("code").type(JsonFieldType.NUMBER).description("HTTP ์ํ ์ฝ๋"),
fieldWithPath("data").type(JsonFieldType.NUMBER).description("์ ์ id๊ฐ")
);
}
private static JsonObject getSignupMember() {
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("loginId", "abcd1234");
jsonObject.addProperty("password", "Abcd1234@!");
jsonObject.addProperty("studentNumber", 2020110110);
return jsonObject;
}
}
...
@DisplayName("๋ก๊ทธ์ธ API ํ
์คํธ")
@Test
public void given_when_then() {
// given - loginId, password
String loginId = "abcd1234";
String password = "Abcd1234@!";
JsonObject jsonObject = getLoginMember(loginId, password);
// ํ์๊ฐ์
Member member = signupFixture.signupMember(loginId, password);
// ์น์ธ๋ ๋ฉค๋ฒ๋ก ์ ํ
approvalChangeCommand.changeApproval(member.getId());
// when - login
given(documentationSpec)
.contentType(ContentType.JSON)
.body(jsonObject.toString())
.filter(document(
"login",
getRequestFieldsSnippet(),
getResponseHeadersSnippet()
))
.when().post("/api/member/login").then().statusCode(HttpStatus.OK.value());
}
private static JsonObject getLoginMember(String loginId, String password) {
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("loginId", loginId);
jsonObject.addProperty("password", password);
return jsonObject;
}
private ResponseHeadersSnippet getResponseHeadersSnippet() {
return responseHeaders(
headerWithName(SecurityFilter.AUTHORIZATION_HEADER).description("Access token"),
headerWithName(SecurityFilter.SESSION_ID).description("Refresh token"),
headerWithName(SecurityFilter.AUTHORITY).description("๋ฉค๋ฒ ๊ถํ")
);
}
private RequestFieldsSnippet getRequestFieldsSnippet() {
return requestFields(
fieldWithPath("loginId").type(JsonFieldType.STRING).description("๋ก๊ทธ์ธ ์์ด๋"),
fieldWithPath("password").type(JsonFieldType.STRING).description("ํจ์ค์๋")
);
}
}
ํ ์คํธ๋ฅผ ์งํํฉ๋๋ค.
./gradlew test
๋ฌธ์ํ์ ํ์ํ ํ์ผ๋ค์ด ์๊น๋๋ค.
์ด ํ์ผ๋ค์ ๊ฐ์ง๊ณ ๋ฌธ์ํ ํ์ผ์ ์์ฑํฉ๋๋ค.
[[resources-member-post]]
== ํ์๊ฐ์
=== POST
==== HTTP request
file์ multipart/form-data๋ก ์ฃผ์ธ์.
png, jpeg ๋ฑ
include::{snippets}/users-post/curl-request.adoc[]
include::{snippets}/users-post/request-parts.adoc[]
request์ ๋ฃ์ json ์ ์ ์ ๋ณด
include::{snippets}/users-post/request-part-request-fields.adoc[]
==== HTTP response
include::{snippets}/users-post/http-response.adoc[]
include::{snippets}/users-post/response-fields.adoc[]
[[resources-login]]
== ๋ก๊ทธ์ธ
=== POST
==== http request
include::{snippets}/login/http-request.adoc[]
include::{snippets}/login/request-fields.adoc[]
==== http response
http ํค๋์ ๋ด์์ ๋๋ฆฝ๋๋ค.
include::{snippets}/login/http-response.adoc[]
include::{snippets}/login/response-headers.adoc[]
๊ทธ๋ผ ์ ์ ๊ฐ๋ฅํ htmlํ์ผ์ ๋ง๋ค์ด ๋ด ์๋ค.
./gradlew bootJar
๊ทธ๋ฌ๋ฉด ์ด๋ฐ์์ผ๋ก ๋ฌธ์ํ๊ฐ ์งํ๋ฉ๋๋ค.
https://rest-assured.io/
https://docs.spring.io/spring-restdocs/docs/2.0.7.RELEASE/reference/html5/
http://wiki.hash.kr/index.php/%ED%85%8C%EC%8A%A4%ED%8A%B8
https://scshim.tistory.com/321
์ ๋ณด๊ณ ๊ฐ๋๋ค.