공부/프로그래밍

[SpringBoot] Spring Rest Docs + Spock 사용하기

demonic_ 2018. 10. 30. 19:16

가급적이면 모든 문서들은 자동화 해야겠다는 취지 + 별도 관리하지 않도록 하기위해 이것저것 알아보다가 이걸로 하기로 결정했습니다. 무엇보다 테스트를 하지 않으면 문서생성이 안된다는 점이 맘에 들었습니다.(실패하면 문서생성 안됨)


반대로 단점이 있다면 실행한 메서드별로 폴더생성되고, 동일한 이름으로 생성할 경우 마지막실행된 것으로 덮어씌웁니다. 그래서 API가 추가될때마다 문서도 추가/수정 해줘야 한다는 문제점이 있습니다. 그냥 엑셀에 관리해도 괜찮지 않을까 생각도 드네요.


여기서는 테스트 코드를 spock 를 활용(groovy 문법) 했고 gradle을 사용했습니다. 그리고 API를 호출하는 것으로는 REST Assured을 사용했습니다. Spring Rest Docs 공식문서에 REST Assured 로 작성된 테스트가 있어 사용했습니다.

Spring Rest Docs + Spock + Rest Assure



- build.gradle 설정

plugins, dependencies, ext, test, asciidoctor, build, copyDocument, bootJar 등 설정합니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
buildscript{
    ...    
}
 
// Spring Rest Docs
plugins {
    id "org.asciidoctor.convert" version "1.5.3"
}
 
...
 
// SPOCK 설정
apply plugin: 'groovy'
 
 
dependencies{
    ...
 
    // Spock
    testCompile('org.spockframework:spock-core:1.1-groovy-2.4')
    testCompile('org.spockframework:spock-spring:1.1-groovy-2.4')
 
 
    // Spring Rest Docs
    //      asciidoc
    asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor:2.0.2.RELEASE'
    testCompile 'org.springframework.restdocs:spring-restdocs-mockmvc:2.0.2.RELEASE'
 
    testCompile('io.rest-assured:rest-assured:3.0.2'// for rest assured
    testCompile('org.springframework.restdocs:spring-restdocs-restassured'// for rest assured
}
 
// Spring Rest Docs
ext {
    snippetsDir = file('build/generated-snippets')
}
test {
    // 만약 application 안에 spring.profiles=test 설정이 되어있는게 있다면 그 설정을 참조합니다.
    // 다만 아래와 같이 설정하여 application.yml 을 구성한다면 매번 테스트마다 VM options 에 다음의 설정을 넣어주어야 하니 주의해야합니다. (-Dspring.profiles.active=test)
    environment SPRING_PROFILES_ACTIVE: environment.SPRING_PROFILES_ACTIVE ?: "test"
    outputs.dir snippetsDir
}
asciidoctor {
    attributes 'snippets': snippetsDir
    inputs.dir snippetsDir
    dependsOn test
}
// build/asciidoc 폴더에 생성된 html 파일을 static 으로 복사하여 localhost/docs/*.html 로 볼 수 있습니다.
task copyDocument(type: Copy) {
    dependsOn asciidoctor
 
    from file("build/asciidoc/html5/")
    into file("src/main/resources/static/docs")
}
build {
    dependsOn copyDocument
}
bootJar {
    dependsOn asciidoctor
    from ("${asciidoctor.outputDir}/html5") {
        into 'static/docs'
    }
}
cs



- Controller 생성 및 테스트 작성

간단한 테스트를 위해 컨트롤러를 생성합니다.


# UserController.java


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import org.springframework.web.bind.annotation.*;
 
import java.util.HashMap;
import java.util.Map;
 
/**
 * 테스트 컨트롤러
 */
@RestController
public class UserController {
 
    /**
     * GET 방식 호출
     * @param name
     * @return
     */
    @GetMapping("/user-get")
    public Map user_get(@RequestParam String name){
        User user = new User();
        user.setName(name);
        user.setAge(24);
        user.setAddress("서울");
        user.setComment("GET 호출 입니다");
 
        Map result = new HashMap<>();
        result.put("code""0000");
        result.put("message""OK");
        result.put("data", user);
 
        return result;
    }
 
 
    /**
     * POST 방식 호출
     */
    @PostMapping("/user-post")
    public Map user_post(@RequestBody User user){
        user.setComment("POST 호출 입니다");
 
        Map result = new HashMap<>();
        result.put("code""0000");
        result.put("message""OK");
        result.put("data", user);
 
        return result;
    }
}
cs



# User.java


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import org.springframework.web.bind.annotation.*;
 
import java.util.HashMap;
import java.util.Map;
 
/**
 * 테스트 컨트롤러
 */
@RestController
public class UserController {
 
    /**
     * GET 방식 호출
     * @param name
     * @return
     */
    @GetMapping("/user-get")
    public Map user_get(@RequestParam String name){
        User user = new User();
        user.setName(name);
        user.setAge(24);
        user.setAddress("서울");
        user.setComment("GET 호출 입니다");
 
        Map result = new HashMap<>();
        result.put("code""0000");
        result.put("message""OK");
        result.put("data", user);
 
        return result;
    }
 
 
    /**
     * POST 방식 호출
     */
    @PostMapping("/user-post")
    public Map user_post(@RequestBody User user){
        user.setComment("POST 호출 입니다");
 
        Map result = new HashMap<>();
        result.put("code""0000");
        result.put("message""OK");
        result.put("data", user);
 
        return result;
    }
}
cs



부트를 실행한 후 postman 등으로 테스트해봅니다.



정상작동 됩니다.

이제 테스트를 작성해보겠습니다.




- 테스트 작성

테스트를 작성하기 전에 문서에 적힐 test URL을 환경변수로 저장해둡니다.


# application.yml


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server:
  port: 8080
 
# Spring docs 설정하는데 사용.
test.server.http.scheme: http
test.server.http.host: test-server.com
test.server.http.port: 8080
 
spring:
  profiles:
    active: local
 
---
 
spring:
  profiles: dev
cs


테스트 클래스를 작성합니다. 여기서는 SPOCK으로 테스트코드를 작성했습니다.


# UserControllerTest.groovy


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
import io.restassured.builder.RequestSpecBuilder
import io.restassured.http.ContentType
import io.restassured.response.Response
import io.restassured.specification.RequestSpecification
import org.junit.Rule
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.web.server.LocalServerPort
import org.springframework.restdocs.JUnitRestDocumentation
import org.springframework.restdocs.payload.JsonFieldType
import org.springframework.test.context.ContextConfiguration
import spock.lang.Specification
 
import static io.restassured.RestAssured.given
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*
import static org.springframework.restdocs.payload.PayloadDocumentation.*
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName
import static org.springframework.restdocs.request.RequestDocumentation.requestParameters
import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.document
import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.documentationConfiguration
 
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// @Autowired 등 옵션을 설정하는것을 쓰기 위해선 ContexntConfiguration 이 필요
@ContextConfiguration(classes = SpringDocsApplication)
class UserControllerTest extends Specification {
    @Rule
    public JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation()
 
    private RequestSpecification spec
 
    @LocalServerPort
    private int port
 
    @Value('${test.server.http.scheme}')
    String httpScheme;
    @Value('${test.server.http.host}')
    String httpHost;
    @Value('${test.server.http.port}')
    int serverPort;
 
 
    void setup() {
        this.spec = new RequestSpecBuilder()
                .addFilter(documentationConfiguration(this.restDocumentation))
                .build()
    }
 
 
        /**
     * Get 통신
     */
    def "user-get"(){
        expect:
        Response res = given(this.spec)
            .contentType(ContentType.JSON)
            .accept(ContentType.JSON)
            // build/generated-snippets  폴더 내 API 정보가 쌓이는 폴더 지정(이후 웹으로 보여줄때 사용)
            //      - {method-name} 은 실행중인 메서드 이름
            .filter(document("user/{method-name}",
                preprocessRequest(modifyUris()
                        .scheme(httpScheme)
                        .host(httpHost)
                        // 만약 포트가 없다면 removePort() 를 사용하거나 포트번호에 80을 주면 됩니다.
                        // .removePort()
                        .port(serverPort), prettyPrint()),
                preprocessResponse(prettyPrint()),
                // Request Fields (Get 호출)
                //      - 표 형태로 보여진다.
                requestParameters(
                        parameterWithName("name").description("이름")
                ),
                // 결과값에서 하나라도 빠지면 에러발생.
                //      - null 로 리턴된다 하더라도 등록해두어야 함
                //      - null 이 허용된다면 optional() 을 꼭 주어야 함.
                responseFields(
                        fieldWithPath("code").type(JsonFieldType.STRING).description("코드").ignored(),
                        fieldWithPath("message").type(JsonFieldType.STRING).description("메세지").ignored(),
                        // data 오브젝트 안의 데이터
                        fieldWithPath("data.name").type(JsonFieldType.STRING).description("유저 명"),
                        fieldWithPath("data.age").type(JsonFieldType.NUMBER).description("나이").optional(),
                        fieldWithPath("data.address").type(JsonFieldType.STRING).description("주소").optional(),
                        fieldWithPath("data.comment").type(JsonFieldType.STRING).description("유저 메세지"),
                )
            ))
            // 실제 전송할 때 쓸 파라미터 설정
            .param("name","철수")
            // 테스트 URL
            //      - port 는 Random으로 띄워지기 때문에(WebEnvironment.RANDOM_PORT)
            //          위에 @LocalServerPort 을 이용해 테스트에 쓰인 port를 사용하여 호출
            .when().port(this.port).get("/user-get")
 
        // 1) 통신 결과
        res.then().assertThat().statusCode(200)
 
        // 2) 결과 값
        def result = res.then().extract().response().asString()
        println "결과=> " + result
    }
 
    /**
     * Post 통신
     */
    def "user-post"(){
        given:
        Map testParam = new HashMap();
        testParam.put("name""홍길동")
        testParam.put("age"20)
        testParam.put("addr""서울")
 
        expect:
        Response res = given(this.spec)
            .contentType(ContentType.JSON)
            .accept(ContentType.JSON)
            // build/generated-snippets  폴더 내 API 정보가 쌓이는 폴더 지정(이후 웹으로 보여줄때 사용)
            //      {method-name} 은 실행중인 메서드 이름
            .filter(document("user/{method-name}",
              ost("/user-post")
 
        // 1) 통신 결과
        res.then().assertThat().statusCode(200)
 
        // 2) 결과 값
        def result = res.then().extract().response().asString()
        println "결과=> " + result
    }
}
cs


.filter(document(...)) 에 들어가는 내용들은 문서자동화를 할떄 참조하는 옵션들입니다.

@ document(param1, ...): param1에 들어가는 항목으로 폴더가 생성됩니다. 메서드마다 이름을 달리해주지 않으면 마지막으로 실행된 결과값으로 덮어쓰게 됩니다. {method-name} 으로 설정하면 실행하는 메서드이름으로 폴더가 자동생성 됩니다.


@ preprocessRequest: 파일을 생성할때 url, port 등을 설정할 수 있습니다. 만약 설정되어 있지 않다면 localhost:[가상포트번호] 로 등록됩니다.(대상파일: curl-request.adoc, httpie-request.adoc)

    (등록하지 않은 경우) $ curl 'http://localhost:54683/user-get?name=%EC%B2%A0%EC%88%98' -i -X GET \

    -H 'Accept: application/json, application/javascript, text/javascript' \

    -H 'Content-Type: application/json; charset=UTF-8'


    (등록한 경우) $ curl 'http://test-server.com:8080/user-get?name=%EC%B2%A0%EC%88%98' -i -X GET \

    -H 'Accept: application/json, application/javascript, text/javascript' \

    -H 'Content-Type: application/json; charset=UTF-8'


@ requestParameters: GET 호출 시 전송하는 파라미터 정보를 등록합니다. '파라미터명, 설명'으로 구성됩니다. requestParameters(parameterWithName()) 를 사용합니다.


@ requestFields: POST 호출 시 전송하는 파라미터 정보를 등록합니다. '파라미터명', '데이터 타입', '설명'으로 구성됩니다. requestFields(fieldWithPath())를 사용합니다.


@ responseFields: response-fields asciidoc 파일을 생성합니다. responseFields(fieldWithPath())를 사용합니다


@ requestParameters, requestFields, responseFields 이 세가지는 누락되는 필드가 있으면 테스트가 깨집니다. 그리고 만약 null을 허용하려면 .optional() 를 추가해야 합니다.

예) fieldWithPath("data.age").type(JsonFieldType.NUMBER).description("나이").optional(),


@ Spring Rest Docs 문서에 필드 설명을 제거하려면 .ignored() 를 추가하면 됩니다.

예) fieldWithPath("code").type(JsonFieldType.STRING).description("코드").ignored(),




./gradlew build 를 수행하면 다음과 같이 asciidoc의 파일이 생성됩니다.





- Docs 문서파일 생성

해당 문서는 asciidoc 문법을 사용합니다.


src/docs/asciidoc 안에 index.adoc 파일을 생성합니다. 그리고 test 에서 작성한것은 user/메서드명 으로 했기에 user라는 폴더도 만들고 그 안에도 index.adoc 파일을 생성합니다.





# src/docs/asciidoc/index.adoc 파일


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
ifndef::snippets[]
:snippets: ../../../build/generated-snippets
endif::[]
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:sectlinks:
:site-url: /stock-memo/build/asciidoc/html5/
 
 
= Rest Docs Sample Document
 
 
== 소개
Rest Docs Sample Document
 
== 공통
=== Domain
 
|===
| 환경 | domain
 
| 테스트서버
| test-server.com:8080.com
 
| 운영서버
| live-server.com
|===
 
=== 공통 Response Body
 
|===
| field | 설명
 
| `code`
| 응답 코드
 
| `message`
| 응답 메세지
 
| `data`
| 실 데이터
|===
 
== 응답 코드
link:code.html[링크]
 
//== API 목록
//link:api-list.html[링크]
 
 
== API 목록
|===
| API 주소 | 설명
| link:user/index.html#_유저_정보_조회_get[/hello]    | 유저정보 조회(GET)
| link:user/index.html#_유저_정보_조회_post[/hello2]    | 유저정보 조회(POST)
|===
cs


# src/docs/asciidoc/user/index.adoc 파일


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
ifndef::snippets[]
:snippets: ../../../build/generated-snippets
endif::[]
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:sectlinks:
:site-url: /stock-memo/build/asciidoc/html5/
 
 
 
= User API
 
== link:../index.html[> 홈으로]
 
//link:../api-list.html[API 목록으로]
 
 
== 설명
유저 정보 조회 API를 테스트 합니다.
 
 
== 유저 정보 조회(GET)
 
=== Request
 
CURL:
 
include::{snippets}/user/user-get/curl-request.adoc[]
 
Request Parameters:
 
include::{snippets}/user/user-get/request-parameters.adoc[]
 
Request HTTP Example:
 
include::{snippets}/user/user-get/http-request.adoc[]
 
=== Response
 
Response Fields:
 
include::{snippets}/user/user-get/response-fields.adoc[]
 
Response HTTP Example:
 
include::{snippets}/user/user-get/http-response.adoc[]
 
 
 
== 유저 정보 조회(POST)
 
=== Body
 
CURL:
 
include::{snippets}/user/user-post/curl-request.adoc[]
 
Request Parameters:
 
include::{snippets}/user/user-post/request-fields.adoc[]
include::{snippets}/user/user-post/request-body.adoc[]
 
Request HTTP Example:
 
include::{snippets}/user/user-post/http-request.adoc[]
 
=== Response
 
Response Fields:
 
include::{snippets}/user/user-post/response-fields.adoc[]
 
Response HTTP Example:
 
include::{snippets}/user/user-post/http-response.adoc[]
cs


./gradlew build 를 실행하면 생성한 파일폼을 기준으로 build/asciidoc/html5 폴더아래에 생성됩니다. 그리고 위에 build.gradle 파일에서 설정한 것을 통해 src/main/resources/static/docs 폴더에 복사됩니다.


1
2
3
4
5
6
7
// 이부분
task copyDocument(type: Copy) {
    dependsOn asciidoctor
 
    from file("build/asciidoc/html5/")
    into file("src/main/resources/static/docs")
}
cs



해당경로에 가서 html 파일을 브라우저 통해 열어보거나 혹은 springboot를 실행시켜 localhost:8080/docs/index.html 에 접속해봅니다. 다음 그림처럼 메인화면이 보이는 것을 확인할 수 있습니다.





테스트 한 /user-get 를 문서에서 확인해보겠습니다.

메인 화면에서 'API 목록' 항목으로 가서 'API 주소'를 클릭하거나 아래의 경로로 입력해 들어가면 확인할 수 있습니다.

http://localhost:8080/docs/user/index.html





참고링크:

Gradle Multi Module에서 Spring Rest Docs 사용하기

https://jojoldu.tistory.com/294



Spring REST Docs

https://docs.spring.io/spring-restdocs/docs/2.0.2.RELEASE/reference/html5/



REST Assured

http://rest-assured.io/



Asciidoc 기본 사용법

https://narusas.github.io/2018/03/21/Asciidoc-basic.html



관련 코드는 Github에 올려두었습니다.

https://github.com/lemontia/SpringRestDocs-Spock