본문 바로가기
프로젝트

GiftFunding) RestDocs + Swagger 적용하기(feat. Controller 테스트 코드 작성)

by son_i 2024. 4. 13.
728x90

RestDocs란 ?

Spring Rest Docs는 Spring MVC를 사용하는 REST API를 문서화하는데 도움을 줌.

 

Spring Rest Docs는 Spring MVC의 테스트를 실행하면서 생성된 응답을 기반으로 문서를 생성.

 

API Spec과 문서화를 위한 테스트 코드가 일치하지 않으면 테스트 빌드를 실패하게 되어 테스트 코드로 검증된 문서를 보장할 수 있음.

 

테스트 코드에서 명세를 작성하기 때문에 비즈니스 로직의 가독성에 영향을 미치지 않음.

 

Spring Rest Docs는 Asciidoctor를 사용하여 문서를 생성.

 

스프링 프로젝트 팀에서는 Swagger보다 Asciidoctor를 사용하는 것을 권장.

 

Swagger는 어노테이션을 운영코드에 추가해야 함 -> 가독성이 떨어지고 운영코드에 침투적.

 

RestDocs + Swagger

- RestDocs만 사용할 경우 ascciidoc으로 만들어진 문서조각을 직접 합쳐줘야 한다.

- swagger 처럼 직접 테스트 해볼 수 없고 가독성이 클라이언트 친화적이지 않다.

- 디자인이 별로다.

 

따라서 RestDocs와 Swagger를 결합하여 사용하기로 하였다.

 


적용 과정

1. 테스트 코드를 통해 docs 문서 생성

2. docs 문서를 OpenAPI3 스펙으로 변환

3. 만들어진 OpenAPI3 스펙을 SwaggerUI로 생성

4. 생성된 SwaggerUI를 static 패키지에 복사 및 정적 리소스로 배포

 

<핵심 플러그인>

com.epages.restdocs-api-spec

- Spring REST Docs의 결과물을 OpenAPI3 스펙으로 변환

 

org.hidetake.swagger.generator

- OpenAPI3 스펙을 기반으로 SwaggerUI 생성(HTML, CSS, JS)


적용

1. build.gradle

 

//1 OpenAPI 플러그인 추가 (Spring REST Docs의 결과물을 OpenAPI3 스펙으로 변환)

//2. SwaggerUI 플러그인 추가 (OpenApI3 스펙을 기반으로 SwaggerUI 생성)

 

//3. OpenAPI3로 OpenAPI3 스펙을 만들 때 필요한 부가 정보들 입력

 - server : 서버 주소를 설정

 - title : API 문서의 제목

 - description : API 문서의 설명

 - version : API 문서의 버전

 - format : API 문서 출력 포맷 (default : JSON) 

 - outputFileNamePrefix : 결과로 나오게 될 format 형식 파일의 접두사

 - outputDirectory : format 형식으로 변환된 파일을 저장 할 디렉토리 경로

       해당 경로에 open-api-3.0.1.json 생성. (jar 파일만 배포 예정이기에 build에 출력)

 

//4. restdocs, openAPI3, swaggerUI 의존성 설정

 

//5. task 설정 :

 

GenerateSwaggerUI task가 openAPI3 task를 의존하도록 설정

 

기존 파일 삭제했다가 build에 출력한 정적 파일 복사.(안 해도 됨 -> local 확인 용)

 

gradle의 openAPI3 task를 수행하면 .json 파일을 이용해 OpenAPI 스펙을 생성.

./gradlew openapi3 명령을 입력해서 task 수행.

 

task 수행 완료 시 build.gradle에 openAPI3 task 설정 시 지정한 outputDirectory 경로에 {outputFileNamePrefix}.{format}으로 OpenAPI 스펙이 생성.

따로 지정하지 않으면 디폴트 값인 build.api-spen/openapi3.json으로 생성.

 


2. 테스트 코드 리팩토링

* docs 작성 시 MockMvcRestDocumentationWrapper.document()를 사용해야 함

* AutoConfigureRestDocs 필요.

 

기존 컨트롤러 테스트 코드

@Test
    @WithMockUser
    @DisplayName("친구요청 성공 테스트")
    void successRequestTest() throws Exception {
        //given
        MemberAdapter memberAdapter =
                new MemberAdapter("sodfni@naver.com", "afdfbcd");
//                MemberAdapter.from(MemberFixture.soni.createMember());

        FriendRequest.Request request
                = FriendRequest.Request.builder()
                .email("buny@naver.com")
                .build();

        when(friendService.request(any(), any()))
                .thenReturn(FriendRequest.Response.builder()
                        .email(request.getEmail())
                        .message("님에게 친구요청을 보냈습니다.")
                        .build());
        //when
        //then
        mockMvc.perform(post("/friend/request")
                        .header("Authorization", "Bearer AccessToken")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request))
                        .with(csrf())
                ).andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.email").value(request.getEmail()))
                .andExpect(jsonPath("$.message").value("님에게 친구요청을 보냈습니다."));
    }

 

리팩토링 후 테스트 코드

변경 된 곳

 1. mockMvc.perform에서 post로 요청을 보낼 때 기존 MockMvcRequesetBuilder.post()에서 RestDocumentationRequestBuilders.post()로 변경

 

2. MockMvcRestDocumentationWrapper.document() 로 docs 설정

 

  - preprocessResponse() or preprocessRequest() : HTTP 응답을 처리하기 전에 사전 처리 작업을 수행하는 메서드 prettyPrint()를 사용하여 Request와 Response의 JSON을 계층적으로 정리하여 가독성을 높여주는 작업 수행.

preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),

* Request / Response 지정할 때 테스트의 Request / Response 와 동일하게 맞춰줘야 함.

 

- requestParameters : 해당 메서드 내부에서 요청할 파라미터의 이름, 설명 지정 (@RequestParam)

requestParameters(
	parameterWithName("name")
   		.description("유저 이름")
)

 

requestHeaders: 해당 메서드 내부에서 요청할 헤더의 이름, 설명 지정

requestHeaders(
	headerWithName("Authorization")
    	.description("Bearer AccessToken")
)

 

- requestFields : 해당 메서드 내부에서 요청할 바디의 속성 이름, 타입, 설명 지정

requestFields(
	fieldWithPath("email").type(JsonFieldType.STRING)
    	.description("유저 이메일"),
    fieldWithPath("password").type(JsonFieldType.STRING)
    	.description("유저 비밀번호")
),

 

- requestParts: 해당 메서드 내부에서 요청할 파트의 이름, 설명 지정

requestParts(
	partWithName("file")
    	.description("변경 이미지")
),

 

- pathParameters() : 해당 메서드 내부의 주소 요청 값의 이름, 설명 지정 (@PathVariable)

pathParameters(
	parameterWithName("contentId")
    	.description("피드 id")
),

* pathParameters()를 이용하게 될 경우 MockMvcRequestBuilders()로 요청하는 것이 아닌 RestDocumentationRequestBuilders()로 요청해야 한다.

 ex) mockMvc.perform(RestDocumentationRequestBuilders.get("/file/{fileId}", 1L))

 

- responseFields : 해당 메서드의 응답 값의 속성 이름, 타입, 설명 지정

responseFields(
	fieldWithPath("code").type(JsonFieldType.NUMBER)
    	.description("상태 코드"),
    fieldWithPath("code").type(JsonFieldType.STRING)
    	.description("상태 메세지")
)

 


3. Swagger-UI 적용하기

1. 의존성 추가

implementation group: 'org.springdoc', name: 'springdoc-openapi-ui', version: '1.7.0'

 

2. Swagger default JSON 경로 수정

 - application.yaml or properties에서 설정

 - swagger-ui.url : swagger-ui가 읽은 파일 경로

 - swagger-ui.path : swagger-ui 띄울 url 변경

springdoc.default-consumes-media-type= application/json;charset=UTF-8
springdoc.default-produces-media-type= application/json;charset=UTF-8
springdoc.swagger-ui.url= /docs/open-api-3.0.1.json
springdoc.swagger-ui.path= /docs/swagger

 

 

테스트 작성이 완료 되었으면 테스트가 통과하는지 확인한다.

통과했다면 docs를 생성한다.

Build -> documentation -> generateSwaggerUI

 

 


실제 적용기

@Test
    @WithMockUser
    @DisplayName("친구요청 성공 테스트")
    void successRequestTest() throws Exception {
        //given
        MemberAdapter memberAdapter =
                new MemberAdapter("sodfni@naver.com", "afdfbcd");
//                MemberAdapter.from(MemberFixture.soni.createMember());

        FriendRequest.Request request
                = FriendRequest.Request.builder()
                .email("buny@naver.com")
                .build();

        when(friendService.request(any(), any()))
                .thenReturn(FriendRequest.Response.builder()
                        .email(request.getEmail())
                        .message("님에게 친구요청을 보냈습니다.")
                        .build());
        //when
        //then
        mockMvc.perform(RestDocumentationRequestBuilders
                        .post("/friend/request")
                        .header("Authorization", "Bearer AccessToken")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request))
                        .with(csrf())
                ).andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.email").value(request.getEmail()))
                .andExpect(jsonPath("$.message").value("님에게 친구요청을 보냈습니다."))
                .andDo(document("/friend/request",
                                ResourceSnippetParameters.builder()
                                        .tag("friend")
                                        .summary("friend request API")
                                        .description("친구 요청")
                                        .requestSchema(Schema.schema("FriendRequest.Request"))
                                        .responseSchema(Schema.schema("FriendRequest.Response"))
                                , preprocessRequest(prettyPrint())
                                , preprocessResponse(prettyPrint())
                                , requestHeaders(
                                        headerWithName("Authorization").description("Bearer AccessToken"))
                                , requestFields(
                                        fieldWithPath("email").type(JsonFieldType.STRING).description("친구 요청 걸 상대방 이메일"))
                                , responseFields(
                                        fieldWithPath("email").type(JsonFieldType.STRING).description("친구 요청 걸 상대방 이메일"),
                                        fieldWithPath("message").type(JsonFieldType.STRING).description("메세지"))
                        )
                );
    }

간단하게 한 번 만들어봤는데 테스트 코드를 실행시키니까 아래와 같은 오류가 났다.

 

java.lang.IllegalStateException: REST Docs configuration not found. Did you forget to apply a MockMvcRestDocumentationConfigurer when building the MockMvc instance?

 

MockMvc를 이용해서 테스트 할 때 테스트 코드에 @AutoConfigureRestDocs 어노테이션을 붙여줘야 한다.

테스트 클래스에 적용해서 Spring Rest Docs를 사용하는데 필요한 것들을 자동으로 설정해주는 어노테이션이다.

서블릿 웹 애플리케이션에서 MockMvc, WebTestClient, REST Assured 기반의 환경을 설정해준다.

붙여주지 않으면 위와 같은 오류가 발생한다.

 

(+ Junit 5에서는 저게 안 먹히고 @ExtendWith(RestDocumentationExtension.class) 로 해야된다고 해서 바꿔봤는데 안 붙였을 때와 같은 오류가 발생한다.)

 

붙여줬더니 테스트 코드가 통과했다.

실행시키고 http://localhost:8080/docs/swagger (application.properties의 springdoc.swagger-ui.path에 의해 아래 경로로 접근 가능)로 접속하니까 아래와 같은 화면이 나왔다.

개발자 도구를 확인해보니 /v3/api-docs/swagger-config 리소스를 얻을 수 없다는 것 같다.

 

springSecurity 에 아래 코드를 추가해준다.

.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()

 

떴다 !

 


열심히 테스트 만들던 와중에 궁금한 거

document("/friend",

  ResourceSnippetParameters.builder()

      .tag()

      .description()

 ... 있고 여기에도 . requestHeaders를 builder로 지정해줄 수 있고 아니면 

builder()가 끝난 밖엣 , requestHeaders로 넣어줄 수도 있다. 근데 후자 케이스 밖에 적용이 안 된다 ! 왜지 !

 

굳이 ResouceSnippetParameters.builder()로 해서 넣어주고 싶다면 아래처럼 resource 메소드 안에 넣어주면 된다.

그때는 ResouceSnippetparameters.builder() ... ~~~입력하고  .build()로 닫아줘야한다.

.andDo(MockMvcRestDocumentationWrapper.document("/friend/List",
                                resource(ResourceSnippetParameters.builder()
                                        .tag("friend")
                                        .summary("friend list API")
                                        .description("친구 목록 조회 ")
                                        .requestHeaders(
                                                headerWithName("Authorization").description("Bearer Access Token"))
                                        .build()
                                )
                        )
                );

 

아니면 그냥 이렇게 , requestHeaders()로 해주면 간단하게 적용된다.

.andDo(MockMvcRestDocumentationWrapper.document("/friend/process",
                                ResourceSnippetParameters.builder()
                                        .tag("friend")
                                        .summary("friend process API")
                                        .description("친구 요청 처리")
                                , requestHeaders(
                                        headerWithName("Authorization").description("Bearer Access Token")
                                )
                        )
                );

 

 

 

 

정리

1. RestDocs + Swagger 사용 시 테스트 코드를 통과해야 docs 문서가 생성되므로 프로젝트의 신뢰성이 높아지고 가독성 좋은 문서를 만들 수 있다.

2. RestDocs를 통해 나온 AsciiDocs 결과를 openAPI3스펙으로 변환하고 이 스펙을 SwaggerUI 로 생성한다.
  이때 핵심이 되는 플러그인이 아래 두 개.

com.epages.restdocs-api-spec
- Spring REST Docs의 결과물을 OpenAPI3 스펙으로 변환
org.hidetake.swagger.generator
- OpenAPI3 스펙을 기반으로 SwaggerUI 생성(HTML, CSS, JS)

2. RestDocs 사용시 @AutoConfigureRestDocs 를 붙여준다.
  -> RestDocs Configuration not found 오류남.
    Junit5에서는 @AutoConfigureRestDocs 이게 안 먹히고 @ExtendWith(RestDocumentationExtension.class)를 해줘야 한다고 하는데 나는 Junit 5.8.2 버전인데 잘 된다. @ExtendWith 안 먹힘.

3. SecurityConfig에 .antMatchers("/docs/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() 경로 허용을 해줘야 
Swagger-UI 접속이 가능하다.
 -> 허용 안 해줄 경우 Swagger 접속 시 Failed to remote configuration 오류 
  
4. MockMvcRequestBuilders 보다 RestDocumentationRequestBuilders 를 사용하는 것을 권장
mockMvc.perform(MockMvcRequestBuilders.get("/friend/funding-product/{friendId}", friendId) ~...
권장이라고는 하지만 RestDocumentationRequestBuilders를 쓰지 않으면 테스트가 실패함.

5. MockMvcRestDocumentationWrapper.document()를 통해 문서를 커스텀 할 수 있음.
   5-1. .requestSchema() or .responseSchema()로 요청/응답 객체를 설정할 수 있으며                                                         requestFields()/responseFields()로 필드의 설명을 적어줄 수 있다.
   5-2. resource() 안에 넣지 않으면 .requestHeaders()도, .requestparameters()도 .requestFields도 동작 안 한다 !
     => 아예 Header 관련 .adoc파일이 생성되지 않는 것을 확인. mockMvc로 테스트했던데로 헤더가 자동 세팅됨.
      .resource() 안에 안 넣으려면 ResourceSnippetParameters.builder() 밖에 , requestHeaders()   , requestParameters()    , requestFields() 로 넣어주면 된다.
   5-3. 컨트롤러 테스트에서 파라미터에 Pageable이 있을 경우 page와 size를 .param() or .queryParam()으로 헤더 설정을 해도 상관없다. 그런데 resource() 안에 .requestParameters()로 넣어줘야 문서가 만들어진다.
   5-4. resource()로 문서 커스텀하면서 .responseSchema()로 응답 객체를 설정하고 밖에 , responseFields()로 응답 객체 필드들의 설명을 적어주었는데 적용이 안 됐다. 그래서 resourc() .builder()안에 .responseFields()로 해주니까 적용된다. 왜 ? 되고 안 되고 하는지 아직 잘 모르겠당...

-> 흠 좀 정리해보면 resource()를 쓰지 않을 때는 .requestSchema나 .responseSchema만 정상동작하고 나머지
(requestHeaders, requestPararmeters, requestFields 들은 밖에 , 를 붙여서 해줘야 한다.
 resource()를 쓸 때는 싹다 .으로 builder() ... .build() 사이에 적어줘야 적용이 된다.

 

여러 의문점을 남겨둔채로 ,,,, 성공테스트 RestDocs + Swagger 적용 완료 !

 

 

+ 어떤 건 resource()로 감싸줘야 적용되고 그랬는데 보다 자세하게 정의하기 위해 그렇다고 한다.

 

+ 추가로 해볼 것 : SwaggerUI에서 Try it out을 할 때 AccessToken을 매번 입력해줘야 하고 또 포스트맨으로 발급받은 AccessToken은 안 된다. API문서에서 인증을 통과할 수 있게 해봐야겠다.

 

+ 드디어 preprocessRequest(prettyPrint()) 유무 차이를 찾아냈다 !!!!

나는 SwaggerUI로 계속해서 확인했기 때문에 계속 깔끔하게 json 형태로 나왔다.(prettyPrint()있든 없든)

 

그래서 너무 궁금했는데 이 설정은 RestDocs 관련 설정이라는 것이 갑자기 생각났다 그래서 .adoc 파일을 확인해봤더니 !

preprocessRequest(prettyPrint())를 해서 Request 파일만 json 형태로 예쁘게 정렬된 것을 확인할 수 있었따 ! ☺️☺️

하 이렇게 의문이 해결되면 기분이 너무 좋군 !


참고

https://velog.io/@nefertiri/Spring-REST-Docs-%EC%99%80-Swagger-%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-API-%EB%AA%85%EC%84%B8%EC%84%9C-%EC%9E%91%EC%84%B1-%EC%9E%90%EB%8F%99%ED%99%94%ED%95%98%EA%B8%B0

https://velog.io/@januaryone/Spring-Rest-Docs-%EC%82%AC%EC%9A%A9%EA%B8%B0

https://velog.io/@hwsa1004/Spring-restdocs-swagger-%EA%B0%99%EC%9D%B4-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

https://velog.io/@hwsa1004/Spring-restdocs-swagger-%EA%B0%99%EC%9D%B4-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

https://jwkim96.tistory.com/274

https://velog.io/@zinna_1109/Toy-Project-Swagger-Bearer-Token%EC%84%A4%EC%A0%95