본문 바로가기
Lecture

RestDocs로 API 문서를 만들어보자 (MockMvc 테스트, 커스터마이징 옵션 설정하기)

by Renechoi 2023. 7. 13.

RestDocs란 ?

 

- 테스트 코드 기반으로 Restful Api 문서 생성을 돕는 도구 

- Asciidoctor를 이용해서 Html 등등 다양한 포맷으로 문서를 자동으로 출력할 수 있다.

- 가장 큰 장점은 테스트 코드 기반으로 문서를 작성한다는 것이다.

  -> 테스트 코드 강제화 -> 검증된 문서 보장 

 

Swagger Vs RestDocs

 

Swagger는 Api에 대한 명세 기능을 제공하면서도 호출에 좀 더 초점을 맞추고 있다. 또한 Swagger는 애노테이션 기반으로 구성해야 한다. 예를 들면 다음과 같은 방식이다. 

 

 

실제 코드에 영향을 미치지는 않지만 지속적으로 명세를 추가해야 하기 때문에 복잡성이 증대된다. 집중해야 하는 비즈니스 로직에 덧붙이는 코드가 많기 때문에 가독성을 해친다.

 

반면 RestDocs는 다음과 같다.

 

 

 

 

 

무엇보다도 RestDocs는 다음과 같이 테스트 코드를 기반으로 하므로 API의 신뢰성을 보장한다. 

 

 

최종 결과물은 다음과 같다. 

 

멤버 API에 대한 스펙을 정리한다. 

 

 


 

문서화를 위한 API 만들기 

 

먼저 build.gradle를 다음과 같이 구성한다.

plugins {
   id 'java'
   id 'org.springframework.boot' version '2.7.13'
   id 'io.spring.dependency-management' version '1.0.15.RELEASE'
   id 'org.asciidoctor.jvm.convert' version '3.3.2'
}

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

java {
   sourceCompatibility = '11'
}

configurations {
   compileOnly {
      extendsFrom annotationProcessor
   }
}



repositories {
   mavenCentral()
}

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

dependencies {
   implementation 'org.springframework.boot:spring-boot-starter-actuator'
   implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
   implementation 'org.springframework.boot:spring-boot-starter-validation'
   implementation 'org.springframework.boot:spring-boot-starter-web'
   compileOnly 'org.projectlombok:lombok'
   runtimeOnly 'com.h2database:h2'
   annotationProcessor 'org.projectlombok:lombok'
   testImplementation 'org.springframework.boot:spring-boot-starter-test'
   testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'


}

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

tasks.named('asciidoctor') {
   inputs.dir snippetsDir
   dependsOn test
}



 

 

yml 설정은 다음과 같다. 

 

server:
  port: 8080


spring:
  jpa:
    database: h2
    hibernate:
      ddl-auto: create
    show-sql: true
    open-in-view: false

management:
  endpoints:
    web:
      exposure:
        include:
          - "*"

logging:
  level:
    root: info

 

 

멤버 객체 생성 

 

@Entity
@Table(name = "member")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;

   @Column(name = "email", nullable = false, unique = true)
   private String email;

   @Column(name = "name", nullable = false)
   private String name;

   @Enumerated(EnumType.STRING)
   @Column(name = "status", nullable = false)
   private MemberStatus status;

   @Column(name = "created_at", nullable = false)
   private LocalDateTime createdAt;

   @Column(name = "updated_at", nullable = false)
   private LocalDateTime updatedAt;

   public Member(
      final String email,
      final String name,
      final MemberStatus status
   ) {
      this.email = email;
      this.name = name;
      this.status = status;
      this.createdAt = LocalDateTime.now();
      this.updatedAt = LocalDateTime.now();
   }

   public void modify(final String name) {
      this.name = name;
   }
}

 

 

테스트를 위한 간단한 DataSetup 클래스를 만들어서 초기 데이터를 만들어주도록 하자. 

 

@Component
@AllArgsConstructor
public class DataSetup implements ApplicationRunner {

    private final MemberRepository memberRepository;

    @Override
    public void run(ApplicationArguments args) {
        final List<Member> members = new ArrayList<>();

        members.add(new Member("yun@bbb.com", "yun", MemberStatus.BAN));
        members.add(new Member("jin@bbb.com", "jin", MemberStatus.NORMAL));
        members.add(new Member("han@bbb.com", "han", MemberStatus.NORMAL));
        members.add(new Member("jo@bbb.com", "jo", MemberStatus.LOCK));

        memberRepository.saveAll(members);
    }
}

 

어플리케이션이 매번 실행될 때마다 해당 데이터들을 추가해준다. 

 

API 컨트롤러를 만든다. 간소화를 위해 서비스 레이어는 생략한다. 

 

@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
public class MemberApi {

   private final MemberRepository memberRepository;

   @GetMapping("/{id}")
   public MemberResponse getMember(@PathVariable Long id) {
      return new MemberResponse(memberRepository.findById(id)
         .orElseThrow(() -> new IllegalArgumentException("Notfound")));
   }

   @PostMapping
   public void createMember(@RequestBody @Valid MemberSignUpRequest dto) {
      memberRepository.save(dto.toEntity());
   }

   @PutMapping("/{id}")
   public void modify(
      @PathVariable Long id,
      @RequestBody @Valid MemberModificationRequest dto
   ) {
      final Member member = memberRepository.findById(id).get();
      member.modify(dto.getName());
      memberRepository.save(member);
   }

   @GetMapping
   public Page<MemberResponse> getMembers(
      @PageableDefault(sort = "id", direction = Direction.DESC) Pageable pageable) {
      return memberRepository.findAll(pageable).map(MemberResponse::new);
   }
}

 

생성, 수정, 조회, 페이징 조회를 하는 간단한 API 이다. 

 

API에 대한 호출은 다음과 같이 작성한다. 

 

 

# 1. Member 단일 조회
GET {{host}}/api/members/1
Content-Type: application/json

###

# 2. Member 생성
POST {{host}}/api/members
Content-Type: application/json

{
  "email": "choi@email.com",
  "name": "choi",
  "status": "NORMAL"
}

###
# 3. Member 수정
PUT {{host}}/api/members/1
Content-Type: application/json

{
  "name": "new-yun"
}

###

# 4. Member 페이징
GET {{host}}/api/members?page=0&size=10
Content-Type: application/json

 

 

문서화를 위한 API 테스트 코드 작성 

 

 

MockMvc를 이용한 베이스 테스트 코드를 작성한다. 

 

@SpringBootTest
@AutoConfigureMockMvc
class MemberApiTest {

   @Autowired
   private MockMvc mockMvc;

   @Autowired
   private ResourceLoader resourceLoader;

   @Test
   public void member_page_test() throws Exception {
      mockMvc.perform(
            get("/api/members")
               .param("size", "10")
               .param("page", "0")
               .contentType(MediaType.APPLICATION_JSON)
         )
         .andExpect(status().isOk())
      ;
   }

   @Test
   public void member_get() throws Exception {
      // 조회 API -> 대상의 데이터가 있어야 합니다.
      mockMvc.perform(
            get("/api/members/{id}", 1L)
               .contentType(MediaType.APPLICATION_JSON)
         )
         .andExpect(status().isOk())
      ;
   }

   @Test
   public void member_modify() throws Exception {
      mockMvc.perform(
            put("/api/members/{id}", 1)
               .contentType(MediaType.APPLICATION_JSON)

         )
         .andExpect(status().isOk())
      ;
   }

   @Test
   public void member_create() throws Exception {
      mockMvc.perform(
         post("/api/members")
            .contentType(MediaType.APPLICATION_JSON)
            .content(readJson("/json/member-api/member-create.json")
            )).andExpect(status().isOk());

   }

   private String readJson(final String path) throws IOException {
      return IOUtils.toString(resourceLoader.getResource("classpath:" + path).getInputStream(),
         StandardCharsets.UTF_8);
   }

}

 

이때 create에서 필요한 json 데이터를 별도의 파일로 지정해서 제공하였다. 

 

member-create.json

 

{
  "email": "choi@email.com",
  "name": "choi",
  "status": "NORMAL"
}

 

해당 json의 위치는 test의 resources에 위치하도록 한다. 

 

 

 

필드 값 등 커스터마이징을 위한 옵션을 추가할 수도 있다. 

 

.andDo(
    restDocs.document(
        pathParameters(
            parameterWithName("id").description("Member ID")
        ),
        responseFields(
            fieldWithPath("id").description("ID"),
            fieldWithPath("name").description("name"),
            fieldWithPath("email").description("email")
        )
    )
)
.andDo(
    restDocs.document(
        requestFields(
            fieldWithPath("name").description("name").attributes(field("length", "10")),
            fieldWithPath("email").description("email").attributes(field("length", "30")),
            fieldWithPath("status").description("Code Member Status 참조")
        )
    )
)

 

API RestDocs 생성하기 

 

이 테스트 코드를 기반으로 RestDocs가 생성되는 코드를 작성해보자.

 

다음과 같은 방식으로 restDoc 스니펫을 생성하는 코드를 작성할 수 있다. 

 

.andDo(
    restDocs.document(
        pathParameters(
            parameterWithName("id").description("Member ID")
        ),
        responseFields(
            fieldWithPath("id").description("ID"),
            fieldWithPath("name").description("name"),
            fieldWithPath("email").description("email")
        )
    )
)


andDo 메서드를 통해 MockMvc의 결과에 대한 추가적인 작업으로 수행하도록 하며 이를 통해 RestDocs를 생성하기 위한 restDocs.document를 호출한다.

 

restDocs의 의존성은 다음과 같다. 

@Autowired
protected RestDocumentationResultHandler restDocs;

 

해당 handler의 document 구현 메서드를 보면 다음과 같이 되어 있다. 

 

public RestDocumentationResultHandler document(Snippet... snippets) {
   return new RestDocumentationResultHandler(this.delegate.withSnippets(snippets)) {

      @Override
      public void handle(MvcResult result) {
         Map<String, Object> configuration = new HashMap<>(retrieveConfiguration(result));
         configuration.remove(RestDocumentationGenerator.ATTRIBUTE_NAME_DEFAULT_SNIPPETS);
         getDelegate().handle(result.getRequest(), result.getResponse(), configuration);
      }

   };
}

 

즉 결과 값에 대해서 mapping을 시켜주고 최종적으로 snippet을 리턴한다. 따라서 파라미터와 fields를 매핑해주는 코드가 필요하다.


pathParameters 메서드는 엔드포인트의 경로 변수에 대한 문서화를 정의한다. parameterWithName("id").description("Member ID")는 id라는 경로 변수에 대한 설명을 추가하는 것을 나타낸다.

responseFields 메서드는 응답 필드에 대한 문서화를 정의한다. fieldWithPath("id").description("ID")는 id 필드에 대한 설명을 추가하는 것을 나타내고, fieldWithPath("name").description("name")과 fieldWithPath("email").description("email")은 각각 name과 email 필드에 대한 설명을 추가한다.

 

 

이렇게 작성하고 테스트 코드를 실행하면 다음과 같은 Snippets이 자동으로 생성된다. 

 

 

 

성공한 테스트에 대해 스니펫을 생성한 것을 볼 수 있으며 총 6개 파일로 구성되어 있다. 

 

- curl-request.adoc

- http-request.adoc

- http-response.adoc

- httpie-request.adoc

- request-body.adoc

- response-body.adoc

 

각각의 결과물들은 다음과 같다. 

 

curl-reqeust

 

http-request

 

http-response

 

httpie-reqeust

 

 

response-body

 

이제 테스트 코드 기반으로 완성된 스니펫을 사용하여 문서화하는 작업을 작성해보자. 

 

src 폴더 밑에 docs 폴더를 생성한다. 

 



다음과 같이 Member-API.adoc을 작성한다.

 

[[Member-API]]
== Member API

[[Member-단일-조회]]
=== Member 단일 조회

operation::member-api-test/member_get[snippets='http-request,path-parameters,http-response,response-fields']

[[Member-페이징-조회]]
=== Member 페이징 조회

operation::member-api-test/member_page_test[snippets='http-request,request-parameters,http-response']

[[Member-생성]]
=== Member 생성

operation::member-api-test/member_create[snippets='request-fields,http-request,http-response']

[[Member-수정]]
=== Member 수정

operation::member-api-test/member_modify[snippets='path-parameters,request-fields,http-request,http-response']

 

즉, 테스트 코드에서 성공한 메서드들에 대한 스니펫을 불러오는 것이다. 이때 request-parameters, path-parameters, request-fileds 등 커스텀 목록으로서 필요한 정보를 작성할 수도 있다. 

 

이를 index 파일에서 include 문법을 통해 불러오면 다음과 같은 결과물을 생성한다. 

 

 

= REST Docs
Andy Wilkinson;
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:


include::Member-API.adoc[]

 

 

 

해당 문서를 실제 웹서버의 주소를 통해 접근할 수 있도록 해보자. 

 

 

Spring Rest Docs의 공식문서에서 제공하는 gradle 빌드 형식에 맞게 build script를 짜고 추가로 html 파일로 output을 생성할 수 있도록 작성하면 된다. 

 

공식 문서가 제공하는 build.gradle 

plugins { 
	id "org.asciidoctor.jvm.convert" version "3.3.2"
}

configurations {
	asciidoctorExt 
}

dependencies {
	asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:{project-version}' 
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:{project-version}' 
}

ext { 
	snippetsDir = file('build/generated-snippets')
}

test { 
	outputs.dir snippetsDir
}

asciidoctor { 
	inputs.dir snippetsDir 
	configurations 'asciidoctorExt' 
	dependsOn test 
}

 

 

이를 조금 변경하여 다음과 같이 작성하였다. 

 


bootJar {
    dependsOn asciidoctor
    copy {
        from "${asciidoctor.outputDir}"
        into 'static/docs'
    }
}

ext {
    snippetsDir = file('build/generated-snippets')
}

test {
    useJUnitPlatform()
    outputs.dir snippetsDir
}

asciidoctor {
    inputs.dir snippetsDir
    dependsOn test
    attributes["snippets"] = snippetsDir
    sources { include("**/index.adoc") }
    baseDirFollowsSourceFile()
}

 

 

index 파일을 만들고 이를 static 디렉토리로 옮기는 작업이다. 

 

http://localhost:8080/docs/index.html url을 통해 다음과 같은 페이지를 확인할 수 있다. 

 

 

참고로 index.html을 만드는 작업은 그림과 같이 adoc에서 IDE의 도움을 받아 직접 생성도 가능하다. 

 

 

 

 


참고 자료

- Spring Rest Docs Reference Doc: https://docs.spring.io/spring-restdocs/docs/current/reference/htmlsingle/#getting-started

- 패스트캠퍼스: 한 번에 끝내는 Spring 완.전.판 초격차 패키지 Online.

반응형