테스트 코드를 작성하면 자연스럽게 Spring RestDocs
를 스쳐지나보게 된다. Spring RestDocs를 사용하게 되면 우선 테스트는 거쳐진 코드로 생각할 수 있어 안정적인(?) 코드라 생각할 수 있다. Swagger와 다르게 직접 실행을 할 수 있는 기능은 없지만 Postman
과 같은 툴이 있으니 크게 문제가 되질 않을 것 같다. 또한, Swagger를 사용하게 되면 코드에 Annotation 이 덕지덕지 붙게 되는데 이러한 코드를 제거하는 것도 개인적으로는 좋은 거 같다.
Spring RestDocs와 함께 사용하는 asciidocs
도 함께 소개하고, asciidoc build.gradle
설정 및 asciidoc 작성 방법, 로컬 서버에서 보는 방법, IDE에서 실시간으로 보는 방법 등을 작성하려 한다.
그럼 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());
}
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());
}
}
HTTP request 종류에 따라 RestDocs 작성에 필요한 메소드가 다르다.
requestHeader
requestParameter
pathParameter
requestBody
Multipart
ResultActions result = mockMvc.perform(MockMvcRequestBuilders.get("service URL")
.header("Authorization", jwtToken)
// ...
document("path/...",
getDocumentRequest(),
getDocumentResponse(),
requestHeaders(
headerWithName("Authorization").description("Basic auth credentials")
),
requestParameters(
parameterWithName("searchCount").description("검색 갯수"),
parameterWithName("searchPage").description("검색 페이지")
),
http://localhost:8080/api/product/list?searchCount=10&searchPage=1
@GetMapping(value = "/api/product/list")
public ApiResult<ResponseDto> findProductList(
HttpServletRequest httpServletRequest,
@RequestParam("searchCount") int searchCount,
@RequestParam("searchPage") int searchPage) throws Exception {
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)));
document("product/list",
getDocumentRequest(),
getDocumentResponse(),
requestParameters(
parameterWithName("searchCount").description("검색 갯수"),
parameterWithName("searchPage").description("검색 페이지")
),
http://localhost:8080/api/product/{productId}/detail
@GetMapping(value = "/api/product/{productId}/detail")
public ApiResult<ResponseDto> findProductDetail(
HttpServletRequest httpServletRequest,
@PathVariable("productId") Long productId) throws Exception{
ResultActions result = mockMvc.perform(RestDocumentationRequestBuilders.get("/api/product/{productId}/detail", productId)
.header("Authorization", jwtToken)
.contentType(MediaType.APPLICATION_JSON));
document("product/detail",
getDocumentRequest(),
getDocumentResponse(),
pathParameters(
parameterWithName("productId").description("상품 ID")
),
http://localhost:8080/api/update/password
{
"password" : "1234"
}
@PatchMapping(value = "/api/update/password")
public ApiResult<ResponseDto> updatePassword(HttpServletRequest httpServletRequest,
@RequestBody PasswordRequestDto passwordRequestDto) throws Exception {
ResultActions result = mockMvc.perform(RestDocumentationRequestBuilders.patch("/api/update/password")
.header("Authorization", jwtToken)
.content(objectMapper.writeValueAsString(passwordRequestDto))
.contentType(MediaType.APPLICATION_JSON));
document("user/update/password",
getDocumentRequest(),
getDocumentResponse(),
requestFields(
fieldWithPath("password").type( JsonFieldType.STRING).description("패스워드")
),
responseFields(
beneathPath("response"),
fieldWithPath("updateResult").type( JsonFieldType.BOOLEAN).description("업데이트 성공 여부")
)
)
http://localhost:8080/api/inquiry?inquiryCodeType=C021&subject=문의제목&content=문의내용
updateImage.png
@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 {
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));
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")
)
)
urlTemplate not found. If you are using MockMvc did you use RestDocumentationRequestBuilders to build the request?
pathParameters
의 경우, RestDocumentationRequestBuilders
사용
requestParameters
의 경우, MockMvcRequestBuilders
사용
주로 Post, Patch, Multipart 등 데이터 생성에 필요한 API 호출에서 필요하다.
given-when-then 구문을 따라서 작성할 때 , 주의할 점은 Mocking 하는 메소드 파라메터에 Dummy 데이터
로 매핑시켜 이 에러를 해결하였다.
기본적으로 HTTP REST API 요청(request) 후 응답(response)받는 데이터는 JSON 형태로 받게 되면 매핑되는 정보이다.
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("문의 내용")
)
)
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("조회 페이지")
)
)
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("컵라면 가격")
)
)
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
로 하면 된다.
asciidocs .adoc
확장자로 작성하면 위 build.gradle 생성 후 다음과 같은 경로로 .html
이 생성되는 것을 볼 수 있다.
종류 | 소스 경로 | 생성 경로 |
---|---|---|
Maven | src/main/asciidoc/*.adoc | target/generated-docs/*.html |
Gradle | src/docs/asciidoc/*.adoc | build/asciidoc/html5/*.html |
gradle build 후 WAS에 띄우면 다음 URL로 접속하여 작성된 API 문서를 볼 수 있다.
{serviceURL}/docs/index.html
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
}
build/asciidoc/html5/
에서 생성된 .html
파일들을src/main/resources/static/docs
경로로 복사/docs/index.html
다음 경로에서 확인