-
Spring REST Docs 적용하기(+ html 생성안됨 에러 해결, ./grdlew build 에러 해결, 각종에러해결)프로젝트/게시판 프로젝트 2022. 6. 7. 01:11728x90
지난 포스팅을 통해 API 문서화를 위해 Swagger와 Spring Rest Docs를 비교하여 보았습니다.
그리고 분석을 통하여 좀 더 깔끔 명료한 문서를 만들 수 있고 문서와 코드의 괴리감이 생길 수 없는 Spring Rest Docs를 적용해보고자 합니다.
추가적으로 공부하다가 알게 된 점으로는 Postman으로도 API 문서를 만들 수 있습니다.
하지만 테스트 코드를 강제화 할 수 있는 Spring REST Docs를 사용하겠습니다.
공식문서와 다른 분들이 적용한 예시를 보고 포스팅을 작성해보겠습니다.
Swagger vs Spring REST Docs
https://junuuu.tistory.com/318?category=997278
Spring REST Docs란?
Restful 서비스에 대한 정확하고 읽기 쉬운 문서를 자동으로 생성하도록 도와주는 라이브러리
Asciidoctor를 이용하여 HTML을 생성
각 테스트에서 스니펫이라고 하는 문서 조각을 만들어 주는데 이를 모아서 하나의 문서로 꾸밈
요구조건으로 테스트 케이스가 반드시 필요함
API Spec과 문서화를 위한 테스트 코드가 일치하지 않으면 테스트 빌드는 실패하게 됨
즉, 테스트 코드로 검증된 문서 (실제 코드에 추가되는 코드가 없는 장점)
Spring REST Docs 최소 요구사항
Java 8
Spring Framework 5 ( 5.0.2 or later)
빌드를 위한 build.gradle 의존 설정
바뀐 정보가 있을 수 있으니 여기 링크를 눌러 공식문서로 이동하여 참고하시면 좋을 것 같습니다.
plugins { id "org.asciidoctor.jvm.convert" version "3.3.2" } configurations { asciidoctorExt } dependencies { asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' } ext { snippetsDir = file('build/generated-snippets') } test { outputs.dir snippetsDir } asciidoctor { inputs.dir snippetsDir configurations 'asciidoctorExt' dependsOn test }
주의할 점은 공식문서를 그대로 받아오셨다면 :{project-version}는 제거해주셔야 합니다.
다음 설정을 추가하면 스프링 부트에 의해 정적 파일이 제공됩니다.
bootJar { dependsOn asciidoctor from ("${asciidoctor.outputDir}/html5") { into 'static/docs' } }
jar가 빌드되기 전에 문서가 생성되고 생성된 문서는 jar에 포함됩니다.
JUnit 5 Test를 위한 세팅
@ExtendWith({RestDocumentationExtension.class, SpringExtension.class}) public class JUnit5ExampleTests { private MockMvc mockMvc; @Autowired private WebApplicationContext context; @Before public void setUp() { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context) .apply(documentationConfiguration(this.restDocumentation)) .build(); } }
너무 길다고요?
Junit5 기준으로 Spring Boot를 사용하지 않으면 위와 같은 설정을 해주어야 합니다.
하지만 Spring Boot를 사용하면 어노테이션 한 개만 달아주면 됩니다.
@AutoConfigureRestDocs
문서화를 시키기 위해서는 테스트 코드에. andDo(document("이름"))을 달아주면 됩니다.
간단하게 중복 테스트 코드를 다음과 같이 변경하였습니다.
package anthill.Anthill.controller; import anthill.Anthill.service.MemberService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; 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.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import static org.mockito.BDDMockito.given; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @AutoConfigureRestDocs @WebMvcTest(DuplicateController.class) class DuplicateControllerTest { @Autowired private MockMvc mvc; @MockBean private MemberService memberService; @Test @DisplayName("닉네임 중복 테스트 false 반환") public void checkNicknameDuplicateFalseTest() throws Exception { //given String response = "false"; boolean test = false; String nickName = "test"; given(memberService.checkNicknameDuplicate(nickName)).willReturn(test); //when ResultActions resultActions = mvc.perform(get("/user-nickname/" + nickName)); //then resultActions .andExpect(status().isOk()) .andExpect(content().string(response)) .andDo(document("nick-name-non-duplicate" )); } @Test @DisplayName("닉네임 중복 테스트 true 반환") public void checkNicknameDuplicateTrueTest() throws Exception { //given String response = "true"; boolean test = true; String nickName = "test"; given(memberService.checkNicknameDuplicate(nickName)).willReturn(test); //when ResultActions resultActions = mvc.perform(get("/user-nickname/" + nickName)); //then resultActions .andExpect(status().isOk()) .andExpect(content().string(response)) .andDo(document("nick-name-duplicate")); } }
이후에 테스트를 실행시키면 build -> generated-snippets에 adoc 파일들이 만들어집니다.
build.gradle에서 우리가 설정했던 경로로 들어간 것입니다.
Setting -> Plugins -> AsciiDoc 다운로드
adoc 파일 작성의 편의를 위해 AsciiDoc 플러그인을 설치합니다.
이제 생성된 스니펫들을 활용하겠습니다.
.adoc 파일을 생성해야 합니다. Gradle의 경우 src/docs/asciidoc/*. adoc 경로로 생성하면 됩니다.
저 같은 경우에는 api-doc.adoc 대신에 index.adoc으로 다음과 같이 작성했습니다
= REST Docs 문서 만들기 (글의 제목) 부제목(부제) :doctype: book :icons: font :source-highlighter: highlightjs // 문서에 표기되는 코드들의 하이라이팅을 highlightjs를 사용 :toc: left // toc (Table Of Contents)를 문서의 좌측에 두기 :toclevels: 2 :sectlinks: [[Member-API]] == Member API [[Member-중복-조회]] === Member 중복 조회 operation::nick-name-duplicate[snippets='http-request,path-parameters,http-response,response-fields'] operation::nick-name-non-duplicate[snippets='http-request,path-parameters,http-response,response-fields']
여기에 nick-name-duplicate는. andDo()에서 생성한 문서의 이름을 넣어주시면 됩니다.
만약 위에서 플러그인을 설치했다면 로컬에서 바로 문서가 어떻게 이루어지는지 확인할 수 있습니다.
하지만 현재 html 파일이 생성되지 않습니다.
인텔리제이 하단 상태바를 보면 terminal이 존재합니다.
여기서 Terminal로 이동하여 빌드를 위해./gradlew build 명령어를 입력했습니다.
저는 이때 Java compile 에러가 계속해서 발생하였습니다
이때 자바 설정도 올바르게 했는데 뭔가 이상하다 싶으시면 java -version 명령어를 입력하여 환경변수로 등록된 자바 버전을 확인해야 합니다.
저 같은 경우에는 프로젝트 JAVA 16 버전을 사용하였지만 환경변수에는 JAVA1.8버전이 등록되어 있어서 계속 에러가 발생했습니다.
이를 해결하고 난 뒤 ./gradlew build 명령어를 입력하니 파일들이 정상적으로 만들어졌습니다.
성공적으로 생성이 완료되었습니다
기능 고도화
현재는 document의 이름만 주었습니다.
좀 더 기능을 고도화 하는 방법에 대해 알아보겠습니다.
인자에 대한 설명과 결과를 설명하기 위해 다음과 같은 코드를 추가했습니다.
pathParameters( parameterWithName("nickname").description("멤버 닉네임") ), responseFields( fieldWithPath("result").description("결과") )
테스트가 제대로 수행되지 않고 IllegalArgumentException이 발생합니다.
java.lang.IllegalArgumentException: urlTemplate not found. If you are using MockMvc did you use RestDocumentationRequestBuilders to build the request?
코드를 다음과 같이 수정했습니다.
ResultActions resultActions = mvc.perform(RestDocumentationRequestBuilders.get("/user-nickname/" + nickName));
이번에는 새로운 에러가 발생합니다.
org.springframework.restdocs.snippet.SnippetException: Path parameters with the following names were not found in the request: [nickname]
음 Path Parameters로 nickname이라는 request를 찾을 수 없다고 합니다.
경로를 인식하지 못하는건가? 라는 생각이 들었습니다.
"user-nickname/" + nickName 으로 되어있던 부분을 "user-nickname/{nickname}, nickName 으로 변경해보았습니다.
ResultActions resultActions = mvc.perform(RestDocumentationRequestBuilders.get("/user-nickname/{nickname}",nickName));
새로운 에러입니다..
java.lang.ClassCastException: class java.lang.Boolean cannot be cast to class java.util.List (java.lang.Boolean and java.util.List are in module java.base of loader 'bootstrap')
뭔가 반환값이 Boolean으로 들어오는데 List형식으로 변환할 수 없다는 에러가 나왔습니다.
우선 어디가 문제일까 하고 다음 부분을 주석처리했습니다.
/* , responseFields( fieldWithPath("result").description("결과") ) */
이제 테스트는 에러 없이 동작합니다.
중간점검으로 PathParameters가 잘 반영되었는지 확인해보겠습니다.
다시 terminal을 열고 ./gradlew build를 입력합니다.
local에는 잘 적용되었는데 localhost:8080/docs/index.html에는 반영되지 않았습니다.
음.. 뭔가 빌드 쪽에서 이상이 발생한 것 같습니다.
src/docs/asciidoc/index.adoc로 가서 저장을 다시 하고 build를 다시 수행하니까 잘 반영되었습니다.
이제 Path parameters까지 되었습니다.
에러가 발생했던 Response filed를 다시 보겠습니다.
주의할 점!
컨트롤러에서 객체를 반환하지 않는데 responseFields를 사용한다면 ClassCastException 예외가 발생할 수 있다.
추가로 컨트롤러에서 String 객체를 반환하면 content as it could not be parsed as JSON or XML 오류가 발생한다.
두 경우 모두 일반적인 객체를 반환하도록 설정해주어야 한다.
즉, 현재 String, Boolean을 반환하기 때문에 발생하는 에러 같습니다.
이를 해결하기 위해 MemberDuplicateResponseDTO를 생성했습니다.
package anthill.Anthill.dto.member; import lombok.Builder; import lombok.Getter; @Getter public class MemberDuplicateResponseDTO { private String message; @Builder public MemberDuplicateResponseDTO(String message) { this.message = message; } }
그리고 반환된 boolean을 포장하여 객체화시켰습니다.
public ResponseEntity<MemberDuplicateResponseDTO> checkNicknameDuplicate(@PathVariable String nickname) { return ResponseEntity.ok(MemberDuplicateResponseDTO.builder() .message(String.valueOf(memberService.checkNicknameDuplicate(nickname))) .build());
이제 테스트가 정상적으로 잘 수행됩니다.
수많은 삽질 후 드디어 깔끔한 문서 하나가 완성되었습니다
위의 예시들은 Get 요청에 대해서만 나타냈습니다.
POST요청에 대해서도 한번 문서화를 해보겠습니다.
회원가입과 로그인을 수행하는 MemberController에 대해 문서화를 진행해보겠습니다.
회원가입 테스트코드
@Test @DisplayName("회원 중복 발생안됬을 시 201 상태코드 반환") public void memberNonDuplicateTest() throws Exception { //given MemberRequestDTO memberRequestDTO = getMemberRequestDTO(); String body = (new ObjectMapper()).writeValueAsString(memberRequestDTO); boolean duplicateResult = false; given(memberService.validateIsDuplicate(any())).willReturn(duplicateResult); //when ResultActions resultActions = mvc.perform(post("/members") .content(body) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) ); //then resultActions .andExpect(status().isCreated()) .andDo(document("member-join-success", requestFields( fieldWithPath("userId").description("아이디"), fieldWithPath("name").description("이름"), fieldWithPath("nickName").description("닉네임"), fieldWithPath("password").description("비밀번호"), fieldWithPath("phoneNumber").description("전화 번호"), fieldWithPath("address").description("주소") ) )); }
index.adoc에서 operation은 이제 잘 추가하실 수 있을거라 믿고 더 이상 설명하지 않겠습니다.
조금 아쉬운점은 요청하는 JSON이 가독성이 너무 떨어집니다.
.andDo(document("member-join-success", preprocessRequest(prettyPrint()), requestFields( fieldWithPath("userId").description("아이디"), fieldWithPath("name").description("이름"), fieldWithPath("nickName").description("닉네임"), fieldWithPath("password").description("비밀번호"), fieldWithPath("phoneNumber").description("전화 번호"), fieldWithPath("address").description("주소") ) ));
위의 코드에서 preprocessRequest(prettyPrint())가 추가되었습니다.
이렇게하면 json이 가독성좋게 보입니다.
깔끔해진 모습
결론
공식문서를 참조하여 차근 차근 에러를 해결할 수 있다.
출처
https://backtony.github.io/spring/2021-10-15-spring-test-3/
https://me-analyzingdata.tistory.com/entry/Rest-Docs-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0
https://tecoble.techcourse.co.kr/post/2020-08-18-spring-rest-docs/
https://velog.io/@tmdgh0221/Spring-Rest-Docs-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0
'프로젝트 > 게시판 프로젝트' 카테고리의 다른 글
AWS가입 및 EC2 인스턴스 생성 (0) 2022.06.09 SpringBoot CI/CD 도입전 분석(젠킨스 vs 트레비스) (0) 2022.06.08 JPA Table에 Unique Index 달기 (0) 2022.06.03 API 문서화를 위한 Swagger와 Spring Rest Docs 비교 (0) 2022.05.31 테스트 코드 리팩토링하기 (0) 2022.05.31