티스토리 뷰

728x90
반응형

지난 글에선 Spring REST Docs와 그 기본 설정에 대해 알아보았다.

 

2022.09.14 - [개발/Spring] - [Spring]API Documentation - Spring REST Docs

 

[Spring]API Documentation - Spring REST Docs

지난 글까지 단위 테스트와 Mock 객체를 이용한 슬라이스 테스트를 코드에 적용했다. 2022.09.07 - [개발/Spring] - [Spring]단위 테스트(Unit Test) 2022.09.07 - [개발/Spring] - [Spring]JUnit을 이용한 비즈니..

gnidinger.tistory.com

잠깐 복습하자면 Spring REST Docs를 이용한 API 문서화는 아래와 같은 과정을 거쳐 진행된다.

 

계속해서 실제로 Controller 테스트 케이스에 Spring REST Docs를 적용해 HTML 문서를 만들어보자.

 

Basic Structure

 

API문서 생성을 위한 테스트 코드의 기본 구조는 아래와 같이 생겼다.

import com.gnidinger.member.controller.MemberController;
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.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;

@WebMvcTest(MemberController.class) // Controller Test를 위한 전용 애너테이션. 괄호 안에는 대상 클래스 지정.
@MockBean(JpaMetamodelMappingContext.class) // JPA에서 사용하는 Bean을 Mock 객체로 주입
@AutoConfigureRestDocs // Spring REST Docs 자동 구성
public class MemberControllerRestDocsTest {
    @Autowired
    private MockMvc mockMvc; // Mock Object 주입
    
    @MockBean // Controller 클래스의 의존성(Service, Mapper)제거를 위해 Mock Object 주입
    
    @Test
    public void postMemberTest() throws Exception {
        // given - HTTP Request에 필요한 Request Body, Query Parameter, Path Variable 등의 데이터 추가
        
        // 주입받은 Mock Object가 동작하도록 Mocjkito를 이용해 Stubbing
        
        // when
        ResultActions actions = mockMvc .perform(); // Request 전송
        
        // then
        actions.andExpect() // Response 검증
                // 테스트 수행 후 API 문서 자동 생성을 위해 핸들러 메서드의 API 스펙 정보 추가
                // .andDo()는 일반적인 동작을 정의할 때 사용되며 document()는 API 문서 생성을 위한 Spring Rest Docs 메서드
                .andDo(document());  
    }
}

추가로 메인 메서드가 있는 클래스에도 애너테이션을 추가한다.

@EnableJpaAuditing // 추가
@SpringBootApplication
public class ApiDocumentationApplication {
    public static void main(String[] args) {
        SpringApplication.run(ApiDocumentationApplication.class, args);
	}
}

위와 같이 메인 메서드가 존재하는 클래스에 @EnableJpaAuditing을 추가하면

 

JPA와 관련된 빈을 필요로 하게 된다. 따라서 WebMvcTest를 이용해 테스트를 진행할 경우에는

 

반드시 JpaMetamodelMappingContext를 Mock 객체로 주입해주어야 한다.

 

@SpringBootTest vs. @WebMvcTest

 

이전까지의 글에선 @SpringBootTest + @AutoConfigureMockMvc를 이용해 Controller의 테스트를 진행했다.

 

하지만 이번 글에선 @WebMvcTest 하나만 사용하고 있는데, 둘의 차이는 무엇일까?

 

정답부터 말하자면 @WebMvcTest는 Controller를 위한 Slice Test에 특화된 애너테이션이다.

 

애너테이션 문서를 뜯어보면 그 차이가 명확하게 보이는데,

 

@SpringBootTest가 SpringBootTestContextBootstrapper.class로 부트스트랩을 하는 대신

 

@WebMvcTest는 그것을 상속받은 WebMvcTestContextBootstrapper.class를 사용해 부트스트랩을 하고 있다.

 

@BootstrapWith(SpringBootTestContextBootstrapper.class)는 전체 빈을 스프링 컨테이너에 등록하기 때문에

 

테스트 환경을 구성하는 것은 편하지만 실행 속도가 느려지게 된다.

 

반면 @BootstrapWith(WebMvcTestContextBootstrapper.class)는 Controller 테스트에 필요한 빈만 등록,

 

속도가 상대적으로 빠르게 된다.

 

또한 @AutoConfigureMockMvc 역시 @WebMvcTest에는 이미 포함되어 있는 것도 확인할 수 있다.

 

단점은 Controller가 의존하고 있는 객체가 있는 경우 Mock 객체를 이용해 일일이 의존성을 제거해주어야 한다는 것이 있다.

 

Add API Specs

 

계속해서 테스트 케이스에 문서화를 위한 API 스펙 정보를 추가하자.

 

postMember()

 

@WebMvcTest(MemberController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
public class MemberControllerRestDocsTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean // MemberController와 MemberService의 단절을 위해 Mock Object 주입
    private MemberService memberService;

    @MockBean // MemberController와 MemberMapper의 단절을 위해 Mock Object 주입
    private MemberMapper mapper;

    @Autowired
    private Gson gson;

    @Test
    public void postMemberTest() throws Exception {
        // given // postMember()에 전달하는 Request Body
        MemberDto.Post post = new MemberDto.Post("gni@gmail.com", "gni", "010-1234-5678");
        String content = gson.toJson(post);
        // postMember()가 전송하는 Response Body
        MemberDto.response responseDto = new MemberDto.response(1L,
                "gni@gmail.com",
                "gni",
                "010-1234-5678",
                Member.MemberStatus.MEMBER_ACTIVE,
                new Stamp());

        // postMember()가 의존하는 객체의 메서드 호출을 Mock Object를 사용해 Stubbing
        given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class))).willReturn(new Member());

        given(memberService.createMember(Mockito.any(Member.class))).willReturn(new Member());

        given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);

        // when // MockMvc의 perform() 메서드로 POST 요청 전송
        ResultActions actions = mockMvc
                .perform(post("/v11/members")
                        .accept(MediaType.APPLICATION_JSON)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(content));

        // then
        actions.andExpect(status().isCreated())
                .andExpect(jsonPath("$.data.email").value(post.getEmail()))
                .andExpect(jsonPath("$.data.name").value(post.getName()))
                .andExpect(jsonPath("$.data.phone").value(post.getPhone()))
                .andDo(document( // API 스펙을 전달받아 문서화 작업을 하는 RestDocumentationResultHandler의 핵심 메서드
                        "post-member", // 첫 번째 파라미터는 API 문서 Snippet의 식별자 역할. Snippet이 지정 디렉토리 하위에 생성.
                        getDocumentRequest(), // Snippet 생성 전 문서 영역 전처리
                        getDocumentResponse(), // Snippet 생성 전 문서 영역 전처리
                        requestFields( // 문서로 표현될 Request Body. 파라미터의 원소인 FieldDescriptor가 데이터 표현
                                List.of( // Request Body를 JSON으로 표현했을 때 하나의 성질인 FieldDescriptor
                                        fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
                                        fieldWithPath("name").type(JsonFieldType.STRING).description("이름"),
                                        fieldWithPath("phone").type(JsonFieldType.STRING).description("전화번호")
                                )
                        ),
                        responseFields( // 문서로 표현될 Response Body
                                List.of(
                                        fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
                                        // "data.memberId"의 data.memberId는 data 의 하위 프로퍼티 의미
                                        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("스탬프 개수")
                                )
                        )
                ));

    }
}

코드가 길지만 위에서 잡은 기본 구조에 구체적인 로직을 더한 것을 확인할 수 있다.

 

가장 중요한 목적은 Controller 클래스의 Slice Test가 다른 계층의 코드를 호출하지 않는 것인데,

 

서비스 계층의 createMember()와 Mapper 클래스 호출 방지를 위해 코드의 가장 윗부분에 Mock 빈을 주입하고 있다.

 

그리고 아래로 내려오면 문서화 작업을 위한 코드가 길게 붙어있는 걸 확인할 수 있는데, 하나씩 살펴보자.

 

먼저 document() 메서드는 위에도 적었듯이 API 문서 생성을 위한 Spring REST Docs의 자체 메서드이다.

 

조금 정확하게는 RestDocumentationResultHandler타입을 리턴하는 MockMvcRestDocumentation의 메서드가 된다.

 

document()의 첫 번째 파라미터는 Snippet의 식별자 역할을 하며, "post-member"로 지정했기 때문에

 

Snippet은 post-member 디렉터리에 생성된다.

 

계속해서 나오는 파라미터는 스니핏 생성 전에 Request와 Response 문서 영역을 전 처리하는 기능을 하는데,

 

아래와 같이 공통화해서 재사용할 수 있게 만들어져 있다.

public interface ApiDocumentUtils {
    static OperationRequestPreprocessor getDocumentRequest() {
        return preprocessRequest(prettyPrint());
    }

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

매개변수로 주어진 prettyPrint는 JSON 포맷의 Body들을 보기 좋게 표현해 준다.

 

이어서 requestFields()와 responseFields()는 각각 문서로 표현될 Body를 의미하며

 

파라미터로 전달되는 List<FieldDescriptor>의 원소들이 각각 포함될 데이터를 표현한다.

 

여기서 FieldDescriptor 객체는 Body를 JSON으로 변환했을 때 하나의 성질을 의미하게 된다.

 

마지막으로 responseFields() 안의 data.memberId는 data의 하위 정보임을 의미한다.

    {
        "data": {
            "memberId": 1,             // data.memberId
            "email": "gni@gmail.com",
            "name": "gni",
            "phone": "010-1234-5678",
            "memberStatus": "활동중",
            "stamp": 0
        }
    }

 

patchMember()

 

같은 논리로 patchMember에 대한 테스트 케이스와 문서화 코드를 작성하면 아래와 같은 모양이 된다.

@WebMvcTest(MemberController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
public class MemberControllerRestDocsTest {
    ...
    
    @Test
    public void patchMemberTest() throws Exception {
        //given
        long memberId = 1L;
        MemberDto.Patch patch = new MemberDto.Patch(
                memberId,
                "gni",
                "010-1234-5678",
                Member.MemberStatus.MEMBER_ACTIVE
        );

        String content = gson.toJson(patch);

        MemberDto.response responseDto = new MemberDto.response(
                1L,
                "gni@gmail.com",
                "gni",
                "010-1234-5678",
                Member.MemberStatus.MEMBER_ACTIVE,
                new Stamp()
        );

        // willReturn()은 null이 아니어야 한다.
        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",
                        getDocumentRequest(),
                        getDocumentResponse(),
                        pathParameters( // Path Variable 정보 추가
                                parameterWithName("member-id").description("회원 식별자")
                        ),
                        requestFields(
                                List.of(
                                        // memberId는 Request Body에 속하지 않는 정보이므로 ignored()
                                        fieldWithPath("memberId").type(JsonFieldType.NUMBER).description("회원 식별자").ignored(),
                                        // 회원 정보는 선택적으로 가능해야 하므로 optional()
                                        fieldWithPath("name").type(JsonFieldType.STRING).description("이름").optional(),
                                        fieldWithPath("phone").type(JsonFieldType.STRING).description("전화번호").optional(),
                                        fieldWithPath("memberStatus").type(JsonFieldType.STRING).description("회원 상태: MEMBER_ACTIVE").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("스탬프 개수")
                                )
                        )
                ));

    }
}

patchMember의 경우 회원 정보를 수정하는 로직이기 때문에 몇 가지를 추가했다.

 

  • 회원 정보는 선택적으로 수정 가능 → optional() 추가로 필수가 아닌 선택정보로 설정
  • API Spec 중 path variable 정보 추가
  • memberId는 Request Body에 속한 정보가 아니기 때문에 제외(ignored())

 

Created Snippets

 

코드를 모두 작성한 후 테스트를 진행해 통과하면

 

위에 작성한 정보를 기반으로 스니핏 문서가 자동으로 생성된다.

 

스니핏 파일을 열어보면 아래와 같은 문서가 작성된 것을 확인할 수 있다.

 

이렇게 해서 API 문서화 중 가장 까다로운 부분이 끝났다.

 

다음 글에서는 계속해서 스니핏 문서와 템플릿을 가지고 HTML 문서를 만들어보자.

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/06   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30
글 보관함