티스토리 뷰

반응형

이전 포스팅에서 Swagger 입문기에 대해 글을 썼었다. 이후 신규 프로젝트에 API 명세서 역할로 Swagger를 사용 중이다. 

요즘 들어 Swagger의 장단점을 몸소 겪고 있는데, 추후에 관련 내용을 정리 후 포스팅 해보려고 한다.

(궁금하신 분은 아래 포스팅 참고!)

 

 

2024년12월 Swagger 입문기

2줄 요약프런트엔드 개발자와 협업을 위해 Swagger 적용각 어노테이션별 특징 및 사용법 정리 개발환경Spring boot : 3.3.2Swagger(=OpenAPI): 2.6.0Java: 17  배경설명한동안 신규 프로젝트의 기능 개발에만

better-tomorrow-than-today.tistory.com

 

 

3줄 요약

- 장점: 테스트 강제, 소스코드 침범 안 함
- 단점: 설정 겁나 복잡함
- 테스트 코드 작성하는 회사라면 Swagger보다 Spring Rest Docs 가 더 좋은 듯

 

환경 구성 및 Spring Rest Docs  설정

1. 의존성

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

 

2. build.gradle 설정

※ build.gradle 문서 전체(접은 글 참고)

더보기
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.1'
    id 'io.spring.dependency-management' version '1.1.7'
    id 'org.asciidoctor.jvm.convert' version '3.3.2'
}

group = 'com'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    asciidoctorExt
}

repositories {
    mavenCentral()
}

ext {
    set('snippetsDir', file("build/generated-snippets"))
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    // Rest docs
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' // 추가
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}

tasks.named('test') {
    outputs.dir snippetsDir
    useJUnitPlatform()
}

ext { // 전역변수
    snippetsDir = file('build/generated-snippets')
}

test {
    outputs.dir snippetsDir
}

asciidoctor{
    inputs.dir snippetsDir
    configurations 'asciidoctorExt'

    dependsOn test
}

bootJar{
    dependsOn asciidoctor
    from("${asciidoctor.outputDir}"){
        into 'static/docs'
    }
}
  • asciidoctor 플러그인 추가
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.1'
    id 'io.spring.dependency-management' version '1.1.7'
    id 'org.asciidoctor.jvm.convert' version '3.3.2' // asciidoctor 추가
}

 

  • asciidoctorExt Configuration 추가
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    asciidoctorExt //asciidoctor Extension 선언
}

 

  • Rest Docs 의존성 추가
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    // Rest docs
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' // asciidoctor extension 연결
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}

 

  • 기타 설정
ext { // 전역변수 : snippets의 경로 선언
    snippetsDir = file('build/generated-snippets')
    // snippets : 문서들의 조각
}

test { // 테스트가 끝난 결과물을 snippet 디렉토리로 선언
    outputs.dir snippetsDir
}

asciidoctor{ // task
    inputs.dir snippetsDir
    configurations 'asciidoctorExt' // 앞에서 선언한 Extension 설정
    
    sources { // 특정 adoc(index.html)만 html로 만든다.
        include("**/index.adoc")
    }
	baseDirFollowsSourceFile() // 다른 adoc 파일을 include 할 때 경로를 baseDir로 맞춘다.
    dependsOn test // dependsOn : 작업순서, 의미 : 테스트가 수행된 이후에 assciidoctor를 수행한다
}

bootJar{ // jar를 만드는 과정
    dependsOn asciidoctor // asciidoctor 이후에 수행, 전체 빌드 순서 : 테스트 -> asciidoctor -> bootJar
    from("${asciidoctor.outputDir}"){ // 문서를 정적 파일로 보기 위해 소스코드 내에 static/docs 하위에 위치
        into 'static/docs'
    }
}

 

  • Intellij plugin 추가(asciidoctor 미리 보기 지원)

 

간단한 API 개발

게시글 저장 및 조회하는 간단한 API를 만들었다. 세부 코드는 아래 깃허브에서 확인이 가능하다.

https://github.com/gomshiki/rest-docs-practice

 

GitHub - gomshiki/rest-docs-practice

Contribute to gomshiki/rest-docs-practice development by creating an account on GitHub.

github.com

 

 

테스트 코드 작성

1) Spring Rest Docs 설정(추상클래스 - RestDocsSupport)

  • @ExtendWith : Rest Docs 익스텐션 추가
  • MockMvcBuilders.standaloneSetup() : Spring 의존성을 사용하지 않음
@ExtendWith(RestDocumentationExtension.class)
// @SpringBootTest : api 명세 문서를 만드는데 스프링 서버를 띄울 필요가 없기에 사용하지 않음
public abstract class RestDocsSupport {
    protected MockMvc mockMvc;
    protected ObjectMapper objectMapper = new ObjectMapper();

   // standaloneSetup() 적용 시 Spring 의존성을 사용하지 않음
    @BeforeEach
    void setUp(RestDocumentationContextProvider provider) {
        this.mockMvc = MockMvcBuilders.standaloneSetup(initController()) // 문서로 만들 Controller 를 주입해서 넣어주면 됨.
                .apply(MockMvcRestDocumentation.documentationConfiguration(provider))
                .build();
    }

    protected abstract Object initController(); // 추상 메서드로 선언하고, 하위 구현 클래스에서 해당 클래스를 주입받도록 설정
}

 

2) 테스트 코드 작성 - PostControllerDocsTest

  • mockMvc를 통한 presentation 테스트 코드 작성 후 Rest Docs을 위한 코드 작성(MockMvcRestDocument 이용)
  • preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) : json을 더 보기 좋게 수정

perttyPrint() 적용 전후 비교

  • PayloadDocumentation.requestFields() : Request Dto에 정의한 필드 정의
  • PayloadDocumentation.fieldWithPath("title") : 필드명
  • ... type(JsonFieldType.STRING) : 해당 필드의 타입 (숫자일 경우 JsonFieldType.NUMBER)
  • ... description("글 제목") : 해당 필드 의미 
package com.restdocspractice;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.BDDMockito;
import org.mockito.Mockito;
import org.springframework.http.MediaType;
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.restdocs.payload.PayloadDocumentation;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

import static org.mockito.ArgumentMatchers.any;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;

class PostControllerDocsTest extends RestDocsSupport {
    // Mock 설정
    private final PostService postService = Mockito.mock(PostService.class);

    @Override
    protected Object initController() {
        return new PostController(postService);
    }

    @DisplayName("게시글을 등록한다.")
    @Test
    void createPost() throws Exception {
        // given
        String title = "모각코 인원 모집";
        String writer = "김준성";
        String contents = "주말에 모각코 하실분?";
        PostRequest request = new PostRequest(title, writer, contents);

        BDDMockito.given(
                        postService.create(any(PostRequest.class)))
                .willReturn(new PostResponse(title, writer, contents));

		// when // then
        mockMvc.perform(
                        MockMvcRequestBuilders.post("/api/v1/posts")
                                .content(objectMapper.writeValueAsString(request))
                                .contentType(MediaType.APPLICATION_JSON)
                )
                .andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.status().isCreated())
                .andExpect(jsonPath("$.title").value(title))
                .andExpect(jsonPath("$.writer").value(writer))
                .andExpect(jsonPath("$.contents").value(contents))


                // RestDoc 작성을 위한 메서드 체이닝
                .andDo(MockMvcRestDocumentation.document("post-create", // 식별자
                        preprocessRequest(prettyPrint()), // 한줄로 표현된 json을 보기좋게 변경
                        preprocessResponse(prettyPrint()), // 한줄로 표현된 json을 보기좋게 변경
                        PayloadDocumentation.requestFields(
                                // Controller 요청 DTO 필드 정의
                                PayloadDocumentation.fieldWithPath("title").type(JsonFieldType.STRING)
                                        .description("글 제목"),
                                PayloadDocumentation.fieldWithPath("writer").type(JsonFieldType.STRING)
                                        .description("작성자"),
                                PayloadDocumentation.fieldWithPath("contents").type(JsonFieldType.STRING)
                                        .optional() // templates/request-fields.snippet 을 통해 Optional 체크 설정
                                        .description("글 내용")
                        ),
                        PayloadDocumentation.responseFields(
                                PayloadDocumentation.fieldWithPath("title").type(JsonFieldType.STRING)
                                        .description("글 제목"),
                                PayloadDocumentation.fieldWithPath("writer").type(JsonFieldType.STRING)
                                        .description("작성자"),
                                PayloadDocumentation.fieldWithPath("contents").type(JsonFieldType.STRING)
                                        .description("글 내용")
                        )
                ));
    }
}

 

3) request-fields.snippet과 response-fields.snippet 작성

  • request와 response 필드 중 Optional 적용
  • 파일 경로 : test/resources/org/springframework/restdocs/templates
더보기

- request-fields.snippet

==== Request Fields
|===
|Path|Type|Optional|Description

{{#fields}}

|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{#optional}}O{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}

{{/fields}}

|===

 

- response-fields.snippet

==== Response Fields
|===
|Path|Type|Optional|Description

{{#fields}}

|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{#optional}}O{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}

{{/fields}}

|===

 

 

4) build.gradle 에 정의한 task 실행(asciidoctor)

  • 실행 전 clean으로 build 폴더 삭제 후 asciidoctor 실행
  • build/generated-snippets/post-create에 테스트코드에서 적용했던 RestDoc 코드가. adoc 파일로 생성

 

5) asciidoc 문서 작성

  • 경로 : src/docs/asciidoc
  • index.adoc 및 post.adoc 작성
  • 앞서 생성된. adoc 파일을 Include 하는 형식으로 문서 작성
더보기

- index.adoc

ifndef::snippets[]
:snippets: ../../build/generated-snippets
endif::[]

= 게시판 REST API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:

// 링크
[[POST-API]]
== Post API

// 다른 adoc 파일 include
include::post.adoc[]


- post.adoc

[[post-create]]
=== 게시글 등록

==== HTTP Request
include::{snippets}/post-create/http-request.adoc[]
include::{snippets}/post-create/request-fields.adoc[]

==== HTTP Response
include::{snippets}/post-create/http-response.adoc[]
include::{snippets}/post-create/response-fields.adoc[]

 

5) 빌드 및 실행 후 접속해 보기

  • build.gradle 에서 bootJar 정의한 이유 : 프런트 개발자가 RestDocs 엔드포인트로 접속할 수 있도록 하가 위함!

build.grade - bootJar 설정

  • task - build 클릭 : build/libs/rest-docs-practice-0.0.1-SNAPSHOT.jar 생성 확인
  • 터미널에서 생성된 jar 경로 이동 후 java -jar rest-docs-practice-0.0.1-SNAPSHOT.jar 실행
  • http://localhost:8080/docs/index.html 접속

 

정리

  • Spring Rest Docs를 사용하기 위해선 asciidoctor 가 필요하다.
  • Rest Docs는 MockMvc와 RestAssured를 이용한다.
  • 테스트 코드를 작성 후 RestDocs 관련 코드를 작성해야 한다.
  • asciidoctor 빌드를 후 index.adoc 문서를 작성한다. 이 문서를 기반으로 build/docs/asciidoc/index.html 이 생성된다.
  • build.gradle에서 설정한 bootJar로 서버 실행 후 해당 api 문서에 접근이 가능하다.

 

이상 전달 끝!

 

참고 자료

인프런 강의(박우빈-Practical Testing: 실용적인 테스트 가이드)

 

Practical Testing: 실용적인 테스트 가이드

Spring REST Docs

www.inflearn.com

 

 

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
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
글 보관함