20230703

아홍·2023년 7월 3일

2023.07

목록 보기
1/12

API 문서, API 스펙(사양) : API 사용을 위한 정보가 담긴 문서

API 문서화 : 클라이언트가 백엔드 애플리케이션에 요청을 전송하기 위해 필요한 요청 정보를 문서로 정리하는 것


Swagger : 자바 기반의 애플리케이션 API 문서 자동화 오픈 소스
장점 : Postman처럼 API 요청툴로도 사용 가능
단점 : Swagger로 API 문서를 만드려면 코드에 애너테이션을 추가해야하는데 기능 구현과 무관한 애너테이션이 추가되는 건 바람직하지 않다고 생각하는 개발자가 있음

Spring Rest Docs : 애너테이션을 사용하지 않고 컨트롤러를 위한 테스트 클래스에 API 문서를 위한 정보를 추가한다.
테스트를 패스하지 않는다면 API 문서는 만들어지지 않는다 > 애플리케이션과 API 문서 정보의 불일치를 막을 수 있다.


Spring Rest Docs API 문서 생성 과정
1. 컨트롤러의 슬라이스 테스트 코드 작성 후 API 스펙 정보를 추가해준다.
2. test task를 실행시켜 API 문서 스니펫snippet을 생성한다.
스니펫 : 문서의 일부. 테스트 케이스 하나 당 스니펫 하나가 만들어진다.
3. 테스트 결과가 passed라면 스니펫이 .adoc 파일로 생성된다.
4. 생성된 문서를 HTML 파일로 변환한다.

plugins {
	...
	id "org.asciidoctor.jvm.convert" version "3.3.2"    //AsciiDoc을 만들어주는 Asciidoctor
}

ext {
	set('snippetsDir', file("build/generated-snippets")) //스니펫이 생성될 경로
}

configurations {
	asciidoctorExtensions //AsciiDoctor 의존 그룹
}

dependencies {
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' 
  
	asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'

}

//test task 실행 시 생성되는 스니펫의 경로 설정
tasks.named('test') {
	outputs.dir snippetsDir
	useJUnitPlatform()
}

// asciidoctor task 실행 시 Asciidoctor를 사용하기 위한 설정
tasks.named('asciidoctor') {
	configurations "asciidoctorExtensions"
	inputs.dir snippetsDir
	dependsOn test
}

// build task 실행 전에 실행되는 task
task copyDocument(type: Copy) {
	dependsOn asciidoctor
	from file("${asciidoctor.outputDir}")   //해당 경로에 생성되는 index.html을 copy
	into file("src/main/resources/static/docs")   //해당 경로에 index.html 추가
}

build {
	dependsOn copyDocument  //build task 실행 전에 위의 copyDocument가 실행되게 설정
}

//애플리케이션 실행 파일이 생성하는 bootJar 설정
bootJar {
	dependsOn copyDocument    //bootJar task 실행 전에 copyDocument task가 실행되도록 설정
	from ("${asciidoctor.outputDir}") {
		into 'static/docs'   
	}
}

이후 src/docs/asciidoc/ 에 index.adoc를 생성해준다.


@SpringBootTest : 프로젝트에서 사용하는 모든 빈을 등록하여 사용한다. 실행 속도가 상대적으로 느리다
@WebMvcTest : 컨트롤러 테스트에 필요한 빈만 등록하여 사용한다. 실행속도가 상대적으로 빠르다.


package com.codestates.restdocs.member;

import com.codestates.member.controller.MemberController;
import com.codestates.member.dto.MemberDto;
import com.codestates.member.entity.Member;
import com.codestates.member.mapper.MemberMapper;
import com.codestates.member.service.MemberService;
import com.codestates.stamp.Stamp;
import com.google.gson.Gson;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

import java.util.List;

import static com.codestates.util.ApiDocumentUtils.getRequestPreProcessor;
import static com.codestates.util.ApiDocumentUtils.getResponsePreProcessor;
import static org.hamcrest.Matchers.startsWith;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(MemberController.class) //컨트롤러를 테스트하기 위한 전용 애너테이션
@MockBean(JpaMetamodelMappingContext.class) //JPA에서 사용하는 빈들을 Mock 객체로 주입. 스프링부트 기반의 테스트는 항상 최상위 Application클래스를 찾아서 실행한다
@AutoConfigureRestDocs //Spring Rest Docs 자동 구성
public class MemberControllerRestDocsTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean //테스트 대상 컨트롤러가 의존하는 객체 주입받기
    private MemberService memberService;

    @MockBean
    private MemberMapper mapper;

    @Autowired
    private Gson gson;

    @Test
    public void postMemberTest() throws Exception {
        //given
        MemberDto.Post post = new MemberDto.Post("test@test.com", "테스트", "010-1234-5678");
        String content = gson.toJson(post);

        given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class))).willReturn(new Member());

        Member mockResultMember = new Member();
        mockResultMember.setMemberId(1L);
        given(memberService.createMember(Mockito.any(Member.class))).willReturn(mockResultMember);

        //when
        ResultActions actions = mockMvc.perform(
                post("/v11/members")
                        .accept(MediaType.APPLICATION_JSON)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(content));

        //then
        actions.andExpect(status().isCreated())
                .andExpect(header().string("Location", Matchers.is(startsWith("/v11/members/"))))
                .andDo(document( //API 문서 스펙 추가
                        "post-member", //API 문서 스니펫의 식별자. 문서 스니펫은 post-member 디렉토리 하위에 생성된다.
                        getRequestPreProcessor(), //스니펫 생성 전, request에 해당하는 문서 영역을 전처리
                        getResponsePreProcessor(), //위와 마찬가지로 response에 해당하는 문서 영역을 전처리
                        requestFields( //문서로 표현될 request body
                                List.of( //리스트의 원소는 request body에 포함될 데이터를 표현한다.
                                        fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
                                        fieldWithPath("name").type(JsonFieldType.STRING).description("이름"),
                                        fieldWithPath("phone").type(JsonFieldType.STRING).description("휴대폰 번호")
                                )
                        ),
                        responseHeaders( //문서로 표현될 response header
                                headerWithName(HttpHeaders.LOCATION).description("Location header. 등록된 리소스의 URI")
                        )
                ));
    }

    @Test
    public void patchMemberTest() throws Exception {
        //given
        long memberId = 1L;
        MemberDto.Patch patch = new MemberDto.Patch(memberId, "테스트", "010-1111-1111", Member.MemberStatus.MEMBER_ACTIVE);
        String content = gson.toJson(patch);

        MemberDto.Response responseDto =
                new MemberDto.Response(1L,
                        "test@test.com",
                        "테스트",
                        "010-1111-1111",
                        Member.MemberStatus.MEMBER_ACTIVE,
                        new Stamp());

        given(mapper.memberPatchToMember(Mockito.any(MemberDto.Patch.class))).willReturn(new Member());
        given(memberService.updateMember(Mockito.any(Member.class))).willReturn(new Member());
        given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);

        //when
        ResultActions actions =
                mockMvc.perform(
                        patch("/v11/members/{member-id}", memberId)
                                .accept(MediaType.APPLICATION_JSON)
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(content)
                );

        //then
        actions
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data.memberId").value(patch.getMemberId()))
                .andExpect(jsonPath("$.data.name").value(patch.getName()))
                .andExpect(jsonPath("$.data.phone").value(patch.getPhone()))
                .andExpect(jsonPath("$.data.memberStatus").value(patch.getMemberStatus().getStatus()))
                .andDo(document("patch-member",
                        getRequestPreProcessor(),
                        getResponsePreProcessor(),
                        pathParameters( //path variable 정보
                                parameterWithName("member-id").description("회원 식발자")),
                        requestFields(
                                List.of(
                                        fieldWithPath("memberId").type(JsonFieldType.NUMBER).description("회원 식별자").ignored(), //memberId는 path variable로 전달받기 때문에 매핑되지 않기 때문에 ignored()로 API 스펙 정보에서 제외했다.
                                        fieldWithPath("name").type(JsonFieldType.STRING).description("이름").optional(), //필수가 아닌 선택 젇보로 설정
                                        fieldWithPath("phone").type(JsonFieldType.STRING).description("휴대폰 번호").optional(),
                                        fieldWithPath("memberStatus").type(JsonFieldType.STRING).description("회원 상태:MEMBER_ACTIVE/MEMBER_SLEEP/MEMBER_QUIT").optional()
                                )
                        ),
                        responseFields(
                                List.of(
                                        fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
                                        fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"),
                                        fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"),
                                        fieldWithPath("data.name").type(JsonFieldType.STRING).description("이름"),
                                        fieldWithPath("data.phone").type(JsonFieldType.STRING).description("휴대폰 번호"),
                                        fieldWithPath("data.memberStatus").type(JsonFieldType.STRING).description("회원 상태:활동중/휴면 상태/탈퇴 상태"),
                                        fieldWithPath("data.stamp").type(JsonFieldType.NUMBER).description("스탬프 갯수")
                                )
                        )
                        ));
    }
}

0개의 댓글