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("스탬프 갯수")
)
)
));
}
}