Spring RestDocs

4 min read

https://docs.spring.io/spring-restdocs/docs/current/reference/html5 > https://spring.io/guides/gs/testing-restdocs/

Swagger vs Rest Docs

  • Swagger
    • 코드에 어노테이션을 추가하는 방식
    • API 동작을 테스트하는 용도에 특화되어 있다.
    • 문서와 코드가 동기화 되지 않을 수 있다.
  • Rest Docs
    • 테스트로 문서를 생성하는 방식
    • 테스트가 성공해야 문서가 작성된다.
    • 문서 제공용으로 사용하기 적합하다.

코드

예제 코드는 Github을 참고한다.

테스트

테스트를 수행하면 build/generated-snippets 경로에 다음 파일이 생성된다.

curl-request.adoc   // curl 요청
http-request.adoc   // 테스트 수행시 컨트롤러에 전달된 Request. 예제로 사용.
http-response.adoc  // 테스트 수행시 전달받은 http . 예제로 사용.
httpie-request.adoc
request-body.adoc   // 요청의 본문
response-body.adoc  // 응답의 본문

To make the path parameters available for documentation, the request must be built using one of the methods on RestDocumentationRequestBuilders rather than MockMvcRequestBuilders.

조합

  • generated-snippets에 생성된 adoc 파일들을 include 해서 전체 문서를 만든다.
  • Gradle의 경우 src/docs/asciidoc/*.adoc 파일이 build/asciidoc/html5/*.html로 변환된다.

src/docs/asciidoc/API.adoc 파일을 다음과 같이 생성한다.

= Car API
:author: Raegon Kim
:email: raegon@gmail.com
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:sectnums:
:sectlinks:
:operation-http-request-title: Request structure
:operation-http-response-title: Example response

== Car

=== Create Car

operation::create-car[snippets='http-request,request-fields,response-fields,http-response']

=== Get Car

operation::get-car[snippets='http-request,path-parameters,response-fields,http-response']

=== Update Car

operation::update-car[snippets='http-request,path-parameters,request-fields,response-fields,http-response']

=== Delete Car

operation::delete-car[snippets='http-request,path-parameters,http-response']

다음 명령어를 실행한다

gradlew asciidoctor

build/asciidoc/html5/API.html 파일이 생성된다.

코드 재사용

리턴되는 모델을 미리 정의해서 재사용할 수 있다.

protected final LinksSnippet pagingLinks = links(
        linkWithRel("first").optional().description("The first page of results"),
        linkWithRel("last").optional().description("The last page of results"),
        linkWithRel("next").optional().description("The next page of results"),
        linkWithRel("prev").optional().description("The previous page of results"));

...

this.mockMvc.perform(get("/").accept(MediaType.APPLICATION_JSON))
    .andExpect(status().isOk())
    .andDo(document("example", this.pagingLinks.and(
            linkWithRel("alpha").description("Link to the alpha resource"),
            linkWithRel("bravo").description("Link to the bravo resource"))));

속성 추가

attributes 메소드로 다음과 같이 custom 속성을 추가할 수 있다

fieldWithPath("company").attributes(key("custom").value("커스텀값")).type(JsonFieldType.STRING).description("제조사").optional()

ConstrainedFields

도메인 객체의 필드에 대한 제한 사항을 문서에 포함할 수 있다.

ConstraintDescriptions을 사용해서 타입의 필드에 대한 설명을 가져온 뒤 constraints 속성을 추가한다.

ConstraintDescriptions constraintDescriptions = new ConstraintDescriptions(input);

fieldWithPath(path).attributes(key("constraints")
    .value(StringUtils.collectionToDelimitedString(
            constraintDescriptions.descriptionsForProperty(path),
            System.lineSeparator())
    ));

Snippet 커스터마이징

{% raw %}

  • test/resources/org/springframework/restdocs/template 경로에 커스터마이징 하고 싶은 snippet 파일을 추가한다.
  • request-fields.snippet 파일을 다음과 같이 정의해서 사용한다. 참고
{{#title}}.{{.}}{{/title}}
|===
|Path|Type|Optional|Custom|Description|Constraints

{{#fields}}
|{{#tableCellContent}}{{path}}{{/tableCellContent}}
|{{#tableCellContent}}{{type}}{{/tableCellContent}}
|{{#tableCellContent}}{{^optional}}true{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}{{#custom}}{{.}}{{/custom}}{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
|{{#tableCellContent}}{{constraints}}{{/tableCellContent}}

{{/fields}}
|===
  • snippet 자체에 속성을 추가할 수 있다.

    requestFields(
        attributes(key("title").value("Field for user creation")),
    
  • 테이블이 깨지는 것을 막기 위해서 ``{{#tableCellContent}}`를 적용했다. 참고

  • {{custom}} 으로 출력시 custom 속성이 없는 필드는 에러가 발생한다.

    • No method or field with name 'custom'
  • #으로 존재 여부를 검사하고 {{.}}으로 값을 출력한다. 참고

  • 속성을 추가할 때 헤더도 같이 추가해야 테이블이 깨지지 않는다.

{% endraw %}

메시지 커스터마이징

  • test/resources/org/springframework/restdocs/constraints/ConstraintDescriptions.properties 파일을 생성한다.
  • 패키지명.클래스명.description=메시지로 정의한다.
javax.validation.constraints.NotNull.description=값이 존재해야 합니다

한글이 깨져서 출력될 경우 properties 파일이 UTF-8로 저장되어 있는지 확인한다.

IntelliJ 의 경우 Settings > Editor > File Encodings 에서 다음과 같이 설정

  • Global Encoding : UTF-8
  • Project Encoding : UTF-8
  • Default encoding for properties files: UTF-8
  • Transparent native-to-ascii conversion 체크

테이블 폭 변경

snippet 에서 변경:

[cols="1,1,1,2,2"]
|Path|Type|Optional|Custom|Description|Constraints
...

adoc 에서 변경:

[cols="1,1,1,2,2"]
include::{snippets}/update-car/request-fields.adoc[]

@AutoConfigureRestDocs

  • 스프링 부트에서 제공하는 자동 설정
  • MockMvc빈이 Spring REST Docs를 사용하도록 초기화한다.
  • @Autowired로 주입받아서 사용할 수 있다.

RestDocsMockMvcConfigurationCustomizer

Spring Boot에서 제공하는 기능 참고

추가적인 속성을 설정하고 싶을 때 RestDocsMockMvcConfigurationCustomizer 인터페이스를 상속받는 @TestConfiguration을 생성한다.

@TestConfiguration
static class CustomizationConfiguration implements RestDocsMockMvcConfigurationCustomizer {
    @Override
    public void customize(MockMvcRestDocumentationConfigurer configurer) {
        configurer.uris()
                .withScheme("https")
                .withHost("www.example.com")
                .withPort(8080);
    }

    @Bean
    public RestDocumentationResultHandler restDocumentationResultHandler() {
        return MockMvcRestDocumentation.document("{method-name}", // output 경로를 파라미터로 설정
                preprocessRequest(prettyPrint()),   // Request body를 보기 좋게 만든다
                preprocessResponse(prettyPrint())); // Response body를 보기 좋게 만든다
    }
}

{method-name} 관련 내용은 공식문서 참고

다른 파일로 생성하고 테스트에 다음과 같이 포함할 수도 있다.

@Import(CustomizationConfiguration.class)
public class CarControllerAutoConfigureAdvanceTest {

Encoding

요청 응답의 샘플 데이터의 한글 인코딩이 깨질 경우, contentTypeMediaType.APPLICATION_JSON_UTF8로 설정한다.

MediaType.APPLICATION_JSON의 경우 깨짐

        ResultActions result = mockMvc.perform(
                put(request.getUri(), request.getVariables())
                        .content(mapper.writeValueAsString(getSample()))
                        .contentType(MediaType.APPLICATION_JSON_UTF8);

참고

© 2023 Raegon Kim