REST Assured + Rest Docs

๋ฐฉ์Šน์žฌยท2023๋…„ 5์›” 8์ผ
0
post-thumbnail

Rest Assured์™€ Rest Docs๋ฅผ ํ†ตํ•œ ํ…Œ์ŠคํŠธ ๋ฐ API๋ฌธ์„œํ™”



๐Ÿณ ์ธ์ˆ˜ ํ…Œ์ŠคํŠธ(Acceptance Test)๋ž€? ๐Ÿณ

์ธ์ˆ˜ ํ…Œ์ŠคํŠธ(acceptance test)๋Š” ์‹œ์Šคํ…œ์ด ์˜ˆ์ƒ๋Œ€๋กœ ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ณ , ์š”๊ตฌ ์‚ฌํ•ญ์— ๋งž๋Š”์ง€ ํ™•์‹ ํ•˜๊ธฐ ์œ„ํ•ด ํ•˜๋Š” ํ…Œ์ŠคํŠธ์ด๋‹ค.
์‹œ์Šคํ…œ์„ ์ธ์ˆ˜ํ•˜๊ธฐ ์ „ ์š”๊ตฌ ๋ถ„์„ ๋ช…์„ธ์„œ์— ๋ช…์‹œ๋œ ๋Œ€๋กœ ๋ชจ๋‘ ์ถฉ์กฑ์‹œํ‚ค๋Š”์ง€๋ฅผ ์‚ฌ์šฉ์ž๊ฐ€ ํ…Œ์ŠคํŠธํ•œ๋‹ค.
์ธ์ˆ˜ ํ…Œ์ŠคํŠธ๋Š” ์‚ฌ์šฉ์ž ์ฃผ๋„๋กœ ์ด๋ฃจ์–ด์ง€๋ฉฐ, ์˜ค๋ฅ˜ ๋ฐœ๊ฒฌ๋ณด๋‹ค๋Š” ์ œํ’ˆ์˜ ์ถœ์‹œ ์—ฌ๋ถ€๋ฅผ ํŒ๋‹จํ•˜๋Š” ๊ฒƒ์ด ์ฃผ๋ชฉ์ ์ด๋‹ค
๋ฐฑ์—”๋“œ ๊ฐœ๋ฐœ์—์„œ UI ๋ ˆ๋ฒจ์—์„œ ์ธ์ˆ˜ ํ…Œ์ŠคํŠธ๋ฅผ ํ•˜๋Š”๊ฑด ๋ฌด๋ฆฌ๊ฐ€ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— API๋ ˆ๋ฒจ์—์„œ ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ๋งŒ์กฑ์‹œํ‚ค๊ธฐ ์œ„ํ•ด ํ…Œ์ŠคํŠธํ•œ๋‹ค.

๐Ÿ“Œ REST Assured vs MockMvc

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
}


๐Ÿ“Œ ํšŒ์›๊ฐ€์ž… API

@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 ๋ฐฉ์‹์œผ๋กœ ๋ฐ›์Šต๋‹ˆ๋‹ค.



๐Ÿ“Œ ๋กœ๊ทธ์ธ API

@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;
    }
}
  • requestParts - multi-part๋กœ ์–ด๋–ค ๋ฐ์ดํ„ฐ๊ฐ€ ๋“ค์–ด์˜ค๋Š”์ง€ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.
  • requestPartFields - ๊ฐ part์— ๋ฐ์ดํ„ฐ์˜ ํ•„๋“œ์— ๋Œ€ํ•ด ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.
  • fieldWithPath - ์‘๋‹ต ๋ฐ์ดํ„ฐ์— ๋Œ€ํ•ด ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.


๐Ÿ“Œ ๋กœ๊ทธ์ธ ํ…Œ์ŠคํŠธ


	...
        @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("ํŒจ์Šค์›Œ๋“œ")
        );
    }
}

    
  • requestFields - @requestBody๋กœ ์š”์ฒญ์„ ํ•  ๊ฒฝ์šฐ ์‚ฌ์šฉํ•˜๋Š” ๊ฒ€์ฆ์ž…๋‹ˆ๋‹ค.
  • fieldWithPath - json๊ฐ ํ•„๋“œ์— ๋Œ€ํ•ด ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.
  • responseHeaders - ์‘๋‹ตํ•˜๋Š” ํ—ค๋”๊ฐ’์— ๋Œ€ํ•ด ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.
  • headerWithName - ๊ฐ ํ—ค๋”๊ฐ’์— ๋Œ€ํ•ด ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.


ํ…Œ์ŠคํŠธ๋ฅผ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค.

./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

๊ทธ๋Ÿฌ๋ฉด ์ด๋Ÿฐ์‹์œผ๋กœ ๋ฌธ์„œํ™”๊ฐ€ ์ง„ํ–‰๋ฉ๋‹ˆ๋‹ค.



์‚ฌ์šฉ ํ›„๊ธฐ

  • Swagger์— ๋น„ํ•ด ๊ณต์ˆ˜๊ฐ€ ๋งŽ์ด๋“ค๊ธด ํ•˜์ง€๋งŒ Swagger์˜ ๊ธฐ์กด ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์— ๋ฌธ์„œํ™” ์ฝ”๋“œ๊ฐ€ ์นจ๋ฒ”ํ•˜๋Š”๊ฒŒ ๋ถˆ๋งŒ์กฑ ์Šค๋Ÿฝ๋˜ ์ €๋Š” ๋งŒ์กฑ์Šค๋Ÿฝ์Šต๋‹ˆ๋‹ค.
  • ๋น ๋ฅด๊ฒŒ ๊ฐœ๋ฐœํ•ด์•ผ ํ•˜๋Š” ํ”„๋กœ์ ํŠธ์—๋Š” ์–ด์šธ๋ฆฌ์ง„ ์•Š์„๊ฑฐ ๊ฐ™์Šต๋‹ˆ๋‹ค.





์ฐธ๊ณ 

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

profile
ํ˜„์žฌ ๋Œ€ํ•™๊ต์— ์žฌํ•™์ค‘์ž…๋‹ˆ๋‹ค.

2๊ฐœ์˜ ๋Œ“๊ธ€

comment-user-thumbnail
2023๋…„ 6์›” 21์ผ

์ž˜ ๋ณด๊ณ ๊ฐ‘๋‹ˆ๋‹ค.

๋‹ต๊ธ€ ๋‹ฌ๊ธฐ

๋งŽ์ด ์„ฑ์žฅํ•˜์…จ๋„ค์š” ใ…Žใ…Ž ๋ฉ‹์ง‘๋‹ˆ๋‹ค!

๋‹ต๊ธ€ ๋‹ฌ๊ธฐ

๊ด€๋ จ ์ฑ„์šฉ ์ •๋ณด