[SpringBootTest] Spring RestDocs 작성하기

hyozkim·2021년 9월 30일
4

SpringBootTest

목록 보기
4/4
post-thumbnail

들어가면서 👋

테스트 코드를 작성하면 자연스럽게 Spring RestDocs를 스쳐지나보게 된다. Spring RestDocs를 사용하게 되면 우선 테스트는 거쳐진 코드로 생각할 수 있어 안정적인(?) 코드라 생각할 수 있다. Swagger와 다르게 직접 실행을 할 수 있는 기능은 없지만 Postman 과 같은 툴이 있으니 크게 문제가 되질 않을 것 같다. 또한, Swagger를 사용하게 되면 코드에 Annotation 이 덕지덕지 붙게 되는데 이러한 코드를 제거하는 것도 개인적으로는 좋은 거 같다.

Spring RestDocs와 함께 사용하는 asciidocs 도 함께 소개하고, asciidoc build.gradle 설정 및 asciidoc 작성 방법, 로컬 서버에서 보는 방법, IDE에서 실시간으로 보는 방법 등을 작성하려 한다.

그럼 RestDocs 기본적인 세팅과 사용하면서 부딪힌 이슈들을 다루고, 새로운 기능에 대해 지속적인 업데이트를 하자ㅏ.

RestDocs 작성하기

우선 테스트코드 작성이 걸음마 단계이기에 given-when-then 스타일을 사용해서 테스트 코드를 작성했다.

given-when-then 스타일이란 테스트에 필요한 사전 작업을 진행하고, 반환되는 값을 매핑해두는 상태 stubbing 이다. 이렇게 각각의 단계를 나눔으로써 각 단계가 정확하게 실행이 되었는지를 쉽게 확인할 수 있으며 Mocking한 데이터로 예상되는 반환값(성공/실패)을 문서화할 수 있다.

기본 구조

import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;

import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.request.RequestDocumentation.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ExtendWith({RestDocumentationExtension.class})
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
class TestControllerTest {

    @MockBean
    private TestService testService;

    @Test
    @DisplayName("테스트 조회 Test")
    void findHomeByUserIdTest() throws Exception {
        // given
        given(testService.findHomeByUserId(any(Long.class))).willReturn(getHomeByUserId(userId));

        // mockMvc perform
        ResultActions result = mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/test/home")
                .header("Authorization", jwtToken)
                .contentType(MediaType.APPLICATION_JSON));

        // result
        result.andExpect(status().isOk())
                .andDo(document("test/home",
                        getDocumentRequest(),
                        getDocumentResponse(),
                        requestParameters(
                                parameterWithName("searchCount").description("검색 갯수"),
                                parameterWithName("searchPage").description("검색 페이지")
                        ),
                        responseFields(
                                beneathPath("response"), // JSON 데이터 response 하위 경로
                                fieldWithPath("data1").type(JsonFieldType.NUMBER).description("상품 ID"), // JSON Key : data1
                                fieldWithPath("data2").type(JsonFieldType.STRING).description("상품 이름"), // JSON Key : data2
                                fieldWithPath("data3").type(JsonFieldType.ARRAY).description("생성 일시"), // JSON Key : data3
                                fieldWithPath("data4").type(JsonFieldType.VARIES).description("기타(비고)") // JSON Key : data4
                        )
                ))
                .andDo(print());
    }

ApiDocumentUtils

RestDocs 기본 document 설정
getDocumentRequest() - HTTP request 설정
getDocumentResponse() - HTTP response 설정

import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor;
import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor;

import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;

public interface ApiDocumentUtils {

    static OperationRequestPreprocessor getDocumentRequest() {

        return preprocessRequest(
                modifyUris()
                        .scheme("https")
                        .host("velog.io")
                        .port(8080),
                prettyPrint());
    }

    static OperationResponsePreprocessor getDocumentResponse() {
        return preprocessResponse(prettyPrint());
    }
}

request(요청)

HTTP request 종류에 따라 RestDocs 작성에 필요한 메소드가 다르다.

  • requestHeader
  • requestParameter
  • pathParameter
  • requestBody
  • Multipart

1) requestHeader

  • Spring Test
ResultActions result = mockMvc.perform(MockMvcRequestBuilders.get("service URL")
                .header("Authorization", jwtToken)
                // ...
  • RestDocs document
document("path/...",
	getDocumentRequest(),
	getDocumentResponse(),
    	requestHeaders(
        	headerWithName("Authorization").description("Basic auth credentials")
        ), 
	requestParameters(
		parameterWithName("searchCount").description("검색 갯수"),
    		parameterWithName("searchPage").description("검색 페이지")
	),

2) requestParameter

  • URL
http://localhost:8080/api/product/list?searchCount=10&searchPage=1
  • Spring Controller
@GetMapping(value = "/api/product/list")
public ApiResult<ResponseDto> findProductList(
            HttpServletRequest httpServletRequest,
            @RequestParam("searchCount") int searchCount,
            @RequestParam("searchPage") int searchPage) throws Exception {
  • Spring Test
ResultActions result = mockMvc.perform(MockMvcRequestBuilders.get("/api/product/list")
                .header("Authorization", jwtToken)
                .contentType(MediaType.APPLICATION_JSON)
                .param("searchCount", String.valueOf(searchCount))
                .param("searchPage", String.valueOf(searchPage)));
  • RestDocs document
document("product/list",
	getDocumentRequest(),
	getDocumentResponse(),
	requestParameters(
		parameterWithName("searchCount").description("검색 갯수"),
    		parameterWithName("searchPage").description("검색 페이지")
	),

3) pathParameter

  • URL
http://localhost:8080/api/product/{productId}/detail
  • Spring Controller
@GetMapping(value = "/api/product/{productId}/detail")
public ApiResult<ResponseDto> findProductDetail(
            HttpServletRequest httpServletRequest,
            @PathVariable("productId") Long productId) throws Exception{
  • Spring Test
ResultActions result = mockMvc.perform(RestDocumentationRequestBuilders.get("/api/product/{productId}/detail", productId)
                .header("Authorization", jwtToken)
                .contentType(MediaType.APPLICATION_JSON));
  • RestDocs document
document("product/detail",
	getDocumentRequest(),
	getDocumentResponse(),
	pathParameters(
		parameterWithName("productId").description("상품 ID")
	),

4) requestBody

  • URL
http://localhost:8080/api/update/password
  • Body
{
  "password" : "1234"
}
  • Spring Controller
@PatchMapping(value = "/api/update/password")
public ApiResult<ResponseDto> updatePassword(HttpServletRequest httpServletRequest,
    @RequestBody PasswordRequestDto passwordRequestDto) throws Exception {
  • Spring Test
ResultActions result = mockMvc.perform(RestDocumentationRequestBuilders.patch("/api/update/password")
                .header("Authorization", jwtToken)
                .content(objectMapper.writeValueAsString(passwordRequestDto))
                .contentType(MediaType.APPLICATION_JSON));
  • RestDocs document
document("user/update/password",
	getDocumentRequest(),
	getDocumentResponse(),
    	requestFields(
        	fieldWithPath("password").type( JsonFieldType.STRING).description("패스워드")
        ),
        responseFields(
        	beneathPath("response"),
            	fieldWithPath("updateResult").type( JsonFieldType.BOOLEAN).description("업데이트 성공 여부")
	)
)

5) Multipart

  • URL
http://localhost:8080/api/inquiry?inquiryCodeType=C021&subject=문의제목&content=문의내용
  • Multipart File
updateImage.png
  • Spring Controller
@PostMapping(value = "/api/inquiry", produces = {MediaType.APPLICATION_JSON_UTF8_VALUE})
public ApiResult<InquiryResponseDto> inquiry(
    @RequestParam("inquiryCodeType") String inquiryCodeType,
    @RequestParam("subject") String subject,
    @RequestParam("content") String content,
    @RequestParam(required = false) MultipartFile multipartFile) throws Exception {
  • Spring Test
MockMultipartFile mockMultipartFile = getMockMultipartFile(fileName, contentType, filePath);

given(userService.inquiry(any(Long.class),any(String.class),anyObject)).willReturn(inquiryResponseDto);

ResultActions result = mockMvc.perform(MockMvcRequestBuilders.multipart("/api/inquiry")
                .file(mockMultipartFile)
                .header("Authorization", jwtToken)
                .param("inquiryCodeType", inquiryCodeType)
                .param("subject", subject)
                .param("content", content));
  • RestDocs document
document("inquiry",
	getDocumentRequest(),
	getDocumentResponse(),
	requestParts(
		partWithName("inquiryTestImage").optional().description("업로드 이미지 파일")
	),
	requestParameters(
		parameterWithName("inquiryCodeType").description("문의 종류 코드"),
		parameterWithName("subject").description("문의 제목"),
		parameterWithName("content").description("문의 내용")
	),
	responseFields(
		beneathPath("response"),
		fieldWithPath("inquiryId").type(JsonFieldType.NUMBER).description("문의 ID")
	)
)

RestDocs 작성중 아래와 같은 에러를 만난다면❗️

urlTemplate Not Found issue.

  • Error Message
urlTemplate not found. If you are using MockMvc did you use RestDocumentationRequestBuilders to build the request?
  • 해결방법

pathParameters 의 경우, RestDocumentationRequestBuilders 사용
requestParameters 의 경우, MockMvcRequestBuilders 사용

허니몬(Honeymon)의 자바guru 참고

http Result Response Content is NULL.

주로 Post, Patch, Multipart 등 데이터 생성에 필요한 API 호출에서 필요하다.
given-when-then 구문을 따라서 작성할 때 , 주의할 점은 Mocking 하는 메소드 파라메터에 Dummy 데이터로 매핑시켜 이 에러를 해결하였다.

  • 유연자 인자 매칭
    - Mockito.any()
    - Mockito.anyObject()
    - Mockito.anyList()
    - Mockito.anyString()
    - Mockito.anyLong()
    - Mockito.anyByte()
    - Mockito.anyBoolean()
    - Mockito.isA()

response(응답)

기본적으로 HTTP REST API 요청(request) 후 응답(response)받는 데이터는 JSON 형태로 받게 되면 매핑되는 정보이다.

1) 단일 처리 방식

document("user/inquiry",
	getDocumentRequest(),
    	getDocumentResponse(),
    	responseFields(
        	beneathPath("response"), // response 하위 경로
            	fieldWithPath("inquiryId").type(JsonFieldType.NUMBER).description("문의 ID"),
                fieldWithPath("userId").type(JsonFieldType.NUMBER).description("유저 ID"),
                fieldWithPath("userEmail").type(JsonFieldType.STRING).description("이메일"),
                fieldWithPath("inquiryTypeCode").type(JsonFieldType.STRING).description("문의 구분"),
                fieldWithPath("subject").type(JsonFieldType.STRING).description("문의 제목"),
                fieldWithPath("content").type(JsonFieldType.STRING).description("문의 내용")
	)
)

2) Array 처리 방식

document("user/cancel/list",
	getDocumentRequest(),
    	getDocumentResponse(),
        requestParameters(
    		// ...                       
	),
    	responseFields(
          beneathPath("response"), // response 하위 경로
          fieldWithPath("cancelList[].productId").type(JsonFieldType.NUMBER).description("상품 ID"),
          fieldWithPath("cancelList[].productName").type(JsonFieldType.STRING).description("상품명"),
          fieldWithPath("cancelList[].cancelYn").type(JsonFieldType.BOOLEAN).description("취소 상태(대기/완료)"),
          fieldWithPath("cancelList[].refundAmount").type(JsonFieldType.NUMBER).description("취소 금액"),
          fieldWithPath("cancelList[].cancelDt").type(JsonFieldType.VARIES).description("취소 일시"),
          fieldWithPath("totalCount").type(JsonFieldType.NUMBER).description("전체 수"),
          fieldWithPath("searchCount").type(JsonFieldType.NUMBER).description("검색 수"),
          fieldWithPath("searchPage").type(JsonFieldType.NUMBER).description("조회 페이지")
        )
)

3) Object 처리 방식

document("delivery",
	getDocumentRequest(),
    	getDocumentResponse(),
	    pathParameters(
    		// ...
            ),
            responseFields(
            	beneathPath("response"), // response 하위 경로
                fieldWithPath("iceCream.name").type(JsonFieldType.NUMBER).description("아이스크림 이름"),
                fieldWithPath("iceCream.price").type(JsonFieldType.NUMBER).description("아이스크림 가격"),
                fieldWithPath("snack.name").type(JsonFieldType.NUMBER).description("과자 이름"),
                fieldWithPath("snack.price").type(JsonFieldType.NUMBER).description("과자 가격"),
                fieldWithPath("drink.name").type(JsonFieldType.NUMBER).description("음료 이름"),
                fieldWithPath("drink.price").type(JsonFieldType.NUMBER).description("음료 가격"),
                fieldWithPath("cupNoodle.name").type(JsonFieldType.NUMBER).description("컵라면 이름"),
                fieldWithPath("cupNoodle.price").type(JsonFieldType.NUMBER).description("컵라면 가격")
	)
)

asciidocs 설정 및 작성

1) build.gradle 작성

plugins {
	//...
	id 'org.asciidoctor.convert' version '1.5.8'
}

dependencies {
	// restDocs
	implementation 'org.springframework.restdocs:spring-restdocs-asciidoctor:2.0.4.RELEASE'
}

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

test {
	useJUnitPlatform()
	outputs.dir snippetsDir
}

asciidoctor {
	inputs.dir snippetsDir
	dependsOn test
}

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

내 프로젝트의 경우 War로 만들어서 bootWar 이지만 Jar의 경우 bootJar 로 하면 된다.

2) asciidocs 생성 경로

asciidocs .adoc 확장자로 작성하면 위 build.gradle 생성 후 다음과 같은 경로로 .html 이 생성되는 것을 볼 수 있다.

  • Maven / Gradle 에 따라 다른 점 참고
종류소스 경로생성 경로
Mavensrc/main/asciidoc/*.adoctarget/generated-docs/*.html
Gradlesrc/docs/asciidoc/*.adocbuild/asciidoc/html5/*.html

gradle build 후 WAS에 띄우면 다음 URL로 접속하여 작성된 API 문서를 볼 수 있다.

  • RestDocs asciidocs URL
{serviceURL}/docs/index.html

3) snippet 경로

  • curl-request.adoc : 호출에 대한 curl 명령을 포함 하는 문서
  • request-headers.adoc : 호출에 대한 http 헤더를 포함하는 문서
  • httpie-request.adoc : 호출에 대한 http 명령을 포함 하는 문서
  • http-request.adoc : http 요청 정보 문서
  • request-parts.adoc : http MultiPart 요청 정보 문서
  • request-body.adoc : 전송된 http 요청 본문 문서
  • request-parameters.adoc : 호출에 parameter 에 대한 문서
  • path-parameters.adoc : http 요청시 url 에 포함되는 path parameter 에 대한 문서
  • request-fields.adoc : http 요청 object 에 대한 문서
  • http-response.adoc : http 응답 정보 문서
  • response-body.adoc : 반환된 http 응답 본문 문서
  • response-fields.adoc : http 응답 object 에 대한 문서

4) Asciidocs 문서

intellij IDE에서 보는 방법

AsciiDoc Plugin 을 설치하여 미리보기를 볼 수 있다.
참고- 기억보다 기록을

로컬 서버에서 보는 방법

build.gradle

task copyDocument(type: Copy) {
	dependsOn asciidoctor

	from file("build/asciidoc/html5/")
	into file("src/main/resources/static/docs")
}

bootWar {
	dependsOn copyDocument
}
  1. bootWar 에서 copyDocument 실행
  2. build/asciidoc/html5/ 에서 생성된 .html 파일들을
    src/main/resources/static/docs 경로로 복사
  3. 로컬 서버에서도 /docs/index.html 다음 경로에서 확인

참고

Introduction to Spring REST Doc
Spring REST Doc

profile
차근차근 develog

0개의 댓글