Spring REST Docs

bp.chys·2020년 5월 26일
0

Spring Framework

목록 보기
11/15

Spring REST Docs란?

  • API문서 작성을 자동화해주는 도구는 여러가지가 있지만 대표주자로 SwaggerSpring Rest Docs가 있다.
  • Swagger는 API 동작을 테스트하는 용도에 더 특화되어있다. 문서 상에도 실제로 API call이 가능하다.
  • 반면 Spring REST Docs는 성공하는 Test Case를 기반으로 API 스펙을 작성하기 때문에 프로덕션 코드를 건드릴 필요가 없고, 작성되는 문서의 가독성도 상당히 좋은 편이다.

Demo

1. Asciidoctor 설정

  • Rest Docs가 문서 작성에 필요한 코드 조각을 만드는 도구라면 Asciidoctor는 Adoc 파일을 활용하여 html 문서를 만들어주는 도구이다.
  • 템플릿을 통해 http-request, http-response 등 문서에 정의하고 싶은 양식을 지정할 수 있다.
  • build.gradle에 아래와 같이 필요한 의존성과 플러그인 등을 추가해주자.
// build.gradle
plugins {
    ... 
    id "org.asciidoctor.convert" version "1.5.9.2"  // 추가
    ...
}

dependencies {
    ...
    
    asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor:2.0.4.RELEASE'  // 추가
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:2.0.4.RELEASE'  // 추가
    
    ...
}

// 밑에 전부 추가
ext {
    snippetsDir = file('build/generated-snippets')
}

test {
    useJUnitPlatform()
    outputs.dir snippetsDir
}

asciidoctor {
    inputs.dir snippetsDir
    dependsOn test
}

bootJar {
    dependsOn asciidoctor
    from ("${asciidoctor.outputDir}/html5") {
        into 'static/docs'
    }
}

2. API 테스트 코드 작성

  • titlecontent를 가지고 있는 Post모델을 CRUD(Create, Read, Update, Delete)할 수 있는 API를 만들어보자.
  • @WebMvcTest 어노테이션을 사용하면 Spring MVC와 관련된 빈들만 애플리케이션 컨텍스트에 로드하여 보다 가벼운 slice 테스트가 가능하다.
@WebMvcTest(PostController.class)
class PostControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private PostService postService;

    @Test
    public void create() throws Exception {
        final PostResponse post = PostResponse.builder()
                .id(1L)
                .title("first_post")
                .content("hello_my_world")
                .build();
        given(postService.createPost(any())).willReturn(post);

        mvc.perform(post("/posts")
                .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"title\":\"first_post\",\"content\":\"hello_my_world\"}"))
                .andExpect(status().isCreated())
                .andExpect(header().stringValues("location", "/posts/" + 1L))
                .andDo(print());
    }

    @Test
    public void readAll() throws Exception {
        final List<PostResponse> posts = new ArrayList<>();
        posts.add(PostResponse.builder().id(1L).title("one").content("111").build());
        posts.add(PostResponse.builder().id(2L).title("two").content("222").build());

        given(postService.getPosts()).willReturn(posts);

        mvc.perform(get("/posts"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("\"id\":1,\"title\":\"one\",\"content\":\"111\"")))
                .andExpect(content().string(containsString("\"id\":2,\"title\":\"two\",\"content\":\"222\"")))
                .andDo(print());
    }
    
    @Test
    public void read() throws Exception {
        final Post post = Post.builder()
                .id(63L)
                .title("ys")
                .content("is bossdog")
                .build();

        given(postService.getPostById(any())).willReturn(post);

        mvc.perform(get("/posts/" + post.getId()))
                .andExpect(status().isOk())
                .andExpect(content().string(
                        containsString("\"id\":63,\"title\":\"ys\",\"content\":\"is bossdog\"")))
                .andDo(print());
    }

    @Test
    public void update() throws Exception {
        final Post post = Post.builder()
                .id(5L)
                .title("before_title")
                .content("before_content")
                .build();

        given(postService.getPostById(any())).willReturn(post);

        mvc.perform(put("/posts/" + post.getId())
                .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"title\":\"changed_title\",\"content\":\"changed_content\"}"))
                .andExpect(status().isOk())
                .andDo(print());

        verify(postService).updatePost(eq(5L), any());
    }

    @Test
    public void destroy() throws Exception {
        final Post post = Post.builder()
                .id(100L)
                .title("title")
                .content("content")
                .build();

        mvc.perform(delete("/posts/" + post.getId()))
                .andExpect(status().isNoContent())
                .andDo(print());

        verify(postService).destroyPost(eq(100L));
    }

}

3. Documentation 작업

  • 추가적으로 문서에 쓰일 조각을 만들기 위한 Documentation 작업이 필요하다.
  • 테스트 모듈안에 별도의 Documentation을 위한 패키지를 생성하고 그 안에 PostDocumentation을 작성해준다.
  • 테스트 코드에 이어 작성해도되지만, 관심사를 분리하는 것이 코드를 관리하기 편하다.
public class PostDocumentation {
    public static RestDocumentationResultHandler createPost() {
        return document("posts/create",
                requestFields(
                        fieldWithPath("title").type(JsonFieldType.STRING).description("This is post title."),
                        fieldWithPath("content").type(JsonFieldType.STRING).description("This is post content")
                )
        );
    }

    public static RestDocumentationResultHandler getPosts() {
        return document("posts/getAll",
                responseFields(
                        fieldWithPath("[].id").type(JsonFieldType.NUMBER).description("This is post id"),
                        fieldWithPath("[].title").type(JsonFieldType.STRING).description("This is post title"),
                        fieldWithPath("[].content").type(JsonFieldType.STRING).description("This is post content")
                )
        );
    }

    public static RestDocumentationResultHandler getPost() {
        return document("posts/get",
                pathParameters(
                        parameterWithName("id").description("This is post id")
                ),
                responseFields(
                        fieldWithPath("id").type(JsonFieldType.NUMBER).description("This is post id"),
                        fieldWithPath("title").type(JsonFieldType.STRING).description("This is post title"),
                        fieldWithPath("content").type(JsonFieldType.STRING).description("This is post content")
                )
        );
    }

    public static RestDocumentationResultHandler updatePost() {
        return document("posts/update",
                pathParameters(
                        parameterWithName("id").description("This is post id")
                ),
                requestFields(
                        fieldWithPath("title").type(JsonFieldType.STRING).description("This is post title."),
                        fieldWithPath("content").type(JsonFieldType.STRING).description("This is post content")
                )
        );
    }

    public static RestDocumentationResultHandler deletePost() {
        return document("posts/delete",
                pathParameters(
                        parameterWithName("id").description("This is post id")
                )
        );
    }
}

4. mockMvc에 Documentation 사용 필터 추가

@ExtendWith(RestDocumentationExtension.class)
@WebMvcTest(PostController.class)
class PostControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private PostService postService;

    @BeforeEach
    public void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) {
        mvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .addFilter(new ShallowEtagHeaderFilter())
                .apply(documentationConfiguration(restDocumentation))
                .build();
    }

...
// 각 테스트 마지막에 다음과 같이 .andDo(PostDocumentation.xx)를 호출해준다.
    @Test
    public void create() throws Exception {
        ...
        mvc.perform(post("/posts")
                .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"title\":\"first_post\",\"content\":\"hello_my_world\"}"))
                .andExpect(status().isCreated())
                .andExpect(header().stringValues("location", "/posts/" + 1L))
                .andDo(print())
                .andDo(PostDocumentation.createPost()); // 추가
    }
    
    // 조회와 같이 특정 요소의 id값을 path로 전달해야하는 경우는 RestDocumentationRequestBuilder를 사용한다. 
    @Test
    public void read() throws Exception {
        ...
	mvc.perform(RestDocumentationRequestBuilders.get("/posts/{id}", post.getId()))
                .andExpect(status().isOk())
                .andExpect(content().string(
                        containsString("\"id\":63,\"title\":\"ys\",\"content\":\"is bossdog\"")))
                .andDo(print())
                .andDo(PostDocumentation.getPost());
    }

5. 문저 스펙 정의를 api-guide.adoc 파일을 추가

  • src 폴더 아래에 documentation>asccidoc>api-guide.adoc 파일을 추가한다.
  • api-guide.adoc 파일안에 문서에 적힐 양식을 작성해보자.
ifndef::snippets[]
:snippets: ../../../build/generated-snippets
endif::[]
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
:operation-http-request-title: Example Request
:operation-http-response-title: Example Response

[[resources]]
= Resources

[[resources-posts]]
== Post

[[resources-posts-create]]
=== 포스트 추가
operation::posts/create[snippets='http-request,http-response,request-fields,request-body']

[[resources-posts-getAll]]
=== 포스트 전체 조회
operation::posts/getAll[snippets='http-request,http-response,response-body']

[[resources-posts-get]]
=== 포스트 조회
operation::posts/get[snippets='http-request,http-response,response-body']

[[resources-posts-update]]
=== 포스트 수정
operation::posts/update[snippets='http-request,http-response,request-fields,request-body']

[[resources-posts-delete]]
=== 포스트 삭제
operation::posts/delete[snippets='http-request,http-response']

6. build test

  • 전체 테스트를 돌렸을 때 Documentation에서 작성했던 경로로 snippets이 만들어진다.
  • gradle 작업창에서 Tasks>documentation>asciidoctor를 실행시키면 html문서를 만들 수 있다. (intelliJ 플러그인을 설치하면 IDE안에서 API문서를 볼 수도 있다.)

결론

Spring REST Docs는 Test를 기반으로 API 문서를 자동으로 작성해주는 도구이다.
간단한 CRUD 예제를 통해 API 문서 작성을 자동화해보았다.
Production 코드에 추가적인 코드 작성을 하지 않아도 되는 장점이 있지만, 각각의 API마다 템플릿을 지정해야하는 번거로움이 따른다.
그래서 어노테이션 기반의 swagger를 선호하는 사람들도 많다. 다음에는 swagger를 사용한 API 문서 작성 방법에 대해 공부해봐야겠다.


참고 자료

Spring Rest Docs 적용 - 우형 기술 블로그
Spring REST Docs에 날개를... (feat: Popup) - 우형 기술 블로그

profile
하루에 한걸음씩, 꾸준히

0개의 댓글