91DAYS) [Pre-Project] API 문서화

nacSeo (낙서)·2023년 2월 28일
0

우선, build.gradle에 asciidoctor를 위한 설정들을 이것 저것 추가해줬다.
<추가한 build.gradle 파일 내용>

plugins {
	id "org.asciidoctor.jvm.convert" version "3.3.2"	// .adoc 파일 확장자를 가지는 AsciiDoc 문서를 생성해주는 Asciidoctor 사용 플러그인 추가
}

configurations {
	asciidoctorExtensions				// AsciiDoctor에서 사용되는 의존 그룹을 지정
}

ext {
	set('snippetsDir', file("build/generated-snippets"))	// API 문서 스니핏 생성 경로 지정
}

dependencies {
	// API 문서화
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
	asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
}

// :test task 실행 시, Asciidoctor 기능 사용 위해 asciidoctorExtensions 설정
tasks.named('asciidoctor') {
	configurations "asciidoctorExtensions"
	inputs.dir snippetsDir
	dependsOn test
}

// :build task 실행 전에 실행
// copy되는 index.html은 외부에 제공하기 위한 용도
task copyDocument(type: Copy) {
	dependsOn asciidoctor							// :asciidoctor task 실행 후 task가 실행되도록 의존성 설정
	from file("${asciidoctor.outputDir}")			// "build/docs/asciidoc/"경로에 생성되는 index.html copy
	into file("src/main/resources/static/docs")		// 해당 경로로 index.html 추가
}

// :build task 실행 전 :copyDocument task가 먼저 수행
build {
	dependsOn copyDocument
}

// 애플리케이션 실행 파일이 생성하는 :bootJar task 설정
// copy되는 index.html은 애플리케이션 실행 파일인 jar파일 포함, 웹 브라우저에서 API 문서를 확인하기 위한 용도
bootJar {
	dependsOn copyDocument					// :bootJar task 실행 전에 :copyDocument task가 실행되도록 의존성 설정
	from ("${asciidoctor.outputDir}") {		// Asciidoctor 실행으로 생성되는 index.html 파일을 jar 파일 안에 추가
		into 'static/docs'
	}
}

다시 테스트 코드로 돌아와 API 문서화를 위한 코드를 추가하면서 테스트 코드의 부족한 부분들을 메꿨다.
Mockito도 제대로 활용을 안했고, ResponseDto를 활용하여 API 문서에 response부분이 예시로 잘 나오도록 수정에 수정을 거듭했다.... TDD 개발은 대체 어떻게 하는 걸까...

코드를 보완하고 API 문서화가 추가된 최종적인 나의 테스트 코드다 :)

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
public class QuestionControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private Gson gson;

    @MockBean
    private QuestionService questionService;

    @MockBean
    private QuestionMapper mapper;

    @Test
    void postQuestionTest() throws Exception {
        // given
        QuestionDto.Post post = new QuestionDto.Post("질문 제목", "질문 내용", 1L);
        String content = gson.toJson(post);

        given(mapper.questionPostDtoToQuestion(Mockito.any(QuestionDto.Post.class))).willReturn(new Question());

        Question mockResultQuestion = new Question();
        mockResultQuestion.setId(1L);
        given(questionService.createQuestion(Mockito.any(Question.class), eq(1L))).willReturn(new Question());

        // when
        ResultActions actions =
                mockMvc.perform(
                        post("/questions")
                                .accept(MediaType.APPLICATION_JSON)
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(content)
                );

        // then
        actions
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", is(startsWith("/questions/"))))
                .andDo(document(
                        "post-question",
                        getRequestPreProcessor(),
                        getResponsePreProcessor(),
                        requestFields(
                                List.of(
                                        fieldWithPath("title").type(JsonFieldType.STRING).description("질문 제목"),
                                        fieldWithPath("contents").type(JsonFieldType.STRING).description("질문 내용"),
                                        fieldWithPath("memberId").type(JsonFieldType.NUMBER).description("회원 식별자")
                                )
                        ),
                        responseHeaders(
                                headerWithName(HttpHeaders.LOCATION).description("Location header. 등록된 리소스의 URI")
                        ),
                        responseFields(
                                List.of(
                                        fieldWithPath("code").type(JsonFieldType.STRING).description("질문 식별자"),
                                        fieldWithPath("message").type(JsonFieldType.STRING).description("상태 코드 메시지"),
                                        fieldWithPath("data").type(JsonFieldType.STRING).description("결과 데이터").optional()
                                )
                        )
                ));
    }

    @Test
    void patchQuestionTest() throws Exception {
        // given
        Long questionId = 1L;
        QuestionDto.Patch patch = new QuestionDto.Patch(1L, "질문 제목 수정", "질문 내용 수정", 1L);
        String content = gson.toJson(patch);

        QuestionDto.Response responseDto = new QuestionDto.Response(1L, "질문 제목", "질문 내용",
                LocalDateTime.now(), LocalDateTime.now(), new WriterResponse(1L, "회원 이름", "회원 이미지"), null);

        given(mapper.questionPatchDtoToQuestion(Mockito.any(QuestionDto.Patch.class))).willReturn(new Question());
        given(questionService.updateQuestion(Mockito.any(Question.class), Mockito.anyLong())).willReturn(new Question());
        given(mapper.questionToQuestionResponseDto(
                Mockito.any(Question.class), Mockito.any(MemberMapper.class), Mockito.any(AnswerMapper.class))).willReturn(responseDto);

        // when
        ResultActions actions =
                mockMvc.perform(
                        RestDocumentationRequestBuilders
                                .patch("/questions/{question-id}", questionId)
                                .accept(MediaType.APPLICATION_JSON)
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(content)
                );

        // then
        actions
                .andExpect(status().isOk())
                .andDo(document(
                        "patch-question",
                        getRequestPreProcessor(),
                        getResponsePreProcessor(),
                        pathParameters(
                                parameterWithName("question-id").description("질문 식별자 ID")
                        ),
                        requestFields(
                                List.of(
                                        fieldWithPath("id").type(JsonFieldType.NUMBER).description("질문 식별자").ignored(),
                                        fieldWithPath("title").type(JsonFieldType.STRING).description("질문 제목").optional(),
                                        fieldWithPath("contents").type(JsonFieldType.STRING).description("질문 내용").optional(),
                                        fieldWithPath("memberId").type(JsonFieldType.NUMBER).description("회원 식별자")
                                )
                        ),
                        responseFields(
                                List.of(
                                        fieldWithPath("code").type(JsonFieldType.STRING).description("질문 식별자"),
                                        fieldWithPath("message").type(JsonFieldType.STRING).description("상태 코드 메시지"),
                                        fieldWithPath("data").type(JsonFieldType.STRING).description("결과 데이터").optional()
                                )
                        )
                ));
    }

    @Test
    void getQuestionTest() throws Exception {
        // given
        Long questionId = 1L;

        List<AnswerResponseDto> answers = List.of(
                new AnswerResponseDto(1L, "답변1", LocalDateTime.now(), LocalDateTime.now(),
                        new WriterResponse(1L, "회원 이름1", "회원 이미지1")),
                new AnswerResponseDto(2L, "답변2", LocalDateTime.now(), LocalDateTime.now(),
                        new WriterResponse(2L, "회원 이름2", "회원 이미지2"))
        );

        QuestionDto.Response response = new QuestionDto.Response(1L, "질문 제목", "질문 내용",
                LocalDateTime.now(), LocalDateTime.now(), new WriterResponse(1L, "회원 이름1", "회원 이미지1"),
                answers);

        given(questionService.findQuestion(Mockito.anyLong())).willReturn(new Question());
        given(mapper.questionToQuestionResponseDto(
                Mockito.any(Question.class), Mockito.any(MemberMapper.class), Mockito.any(AnswerMapper.class)))
                .willReturn(response);

        // when
        ResultActions actions = mockMvc.perform(
                RestDocumentationRequestBuilders
                        .get("/questions/{question-id}", questionId)
                        .accept(MediaType.APPLICATION_JSON));

        // then
        actions
                .andExpect(status().isOk())
                .andDo(document(
                        "get-question",
                        getRequestPreProcessor(),
                        getResponsePreProcessor(),
                        pathParameters(
                                        parameterWithName("question-id").description("질문 식별자 ID")
                        ),
                        responseFields(
                                List.of(fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드"),
                                        fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지"),
                                        fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터").optional(),
                                        fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("질문 식별자"),
                                        fieldWithPath("data.title").type(JsonFieldType.STRING).description("질문 제목"),
                                        fieldWithPath("data.contents").type(JsonFieldType.STRING).description("질문 내용"),
                                        fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("질문 생성 날짜"),
                                        fieldWithPath("data.modifiedAt").type(JsonFieldType.STRING).description("질문 수정 날짜"),
                                        fieldWithPath("data.member").type(JsonFieldType.OBJECT).description("질문회원 데이터").optional(),
                                        fieldWithPath("data.member.id").type(JsonFieldType.NUMBER).description("질문회원 식별자"),
                                        fieldWithPath("data.member.name").type(JsonFieldType.STRING).description("질문회원 이름"),
                                        fieldWithPath("data.member.profileImage").type(JsonFieldType.STRING).description("질문회원 이미지"),
                                        fieldWithPath("data.answers").type(JsonFieldType.ARRAY).description("답변 데이터").optional(),
                                        fieldWithPath("data.answers[].id").type(JsonFieldType.NUMBER).description("답변 식별자"),
                                        fieldWithPath("data.answers[].contents").type(JsonFieldType.STRING).description("답변 내용"),
                                        fieldWithPath("data.answers[].createdAt").type(JsonFieldType.STRING).description("답변 생성 날짜"),
                                        fieldWithPath("data.answers[].modifiedAt").type(JsonFieldType.STRING).description("답변 수정 날짜"),
                                        fieldWithPath("data.answers[].member").type(JsonFieldType.OBJECT).description("답변회원 데이터").optional(),
                                        fieldWithPath("data.answers[].member.id").type(JsonFieldType.NUMBER).description("답변회원 식별자"),
                                        fieldWithPath("data.answers[].member.name").type(JsonFieldType.STRING).description("답변회원 이름"),
                                        fieldWithPath("data.answers[].member.profileImage").type(JsonFieldType.STRING).description("답변회원 이미지")
                                )
                        )
                ));
    }

    @Test
    void getQuestionsTest() throws Exception {
        // given
        String page = "1";
        String size = "10";
        MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
        queryParams.add("page", page);
        queryParams.add("size", size);

        Question question1 = new Question("제목1", "내용1");

        Question question2 = new Question("제목2", "내용2");

        Page<Question> pageQuestions =
                new PageImpl<>(List.of(question1, question2),
                        PageRequest.of(0, 10, Sort.by("id").descending()), 2);

        List<QuestionDto.listResponse> responses = List.of(
                new QuestionDto.listResponse(1L, "질문 제목1", "질문 내용2",
                        LocalDateTime.now(), LocalDateTime.now(), new WriterResponse(1L, "회원 이름1", "회원 이미지2")),
                new QuestionDto.listResponse(2L, "질문 제목2", "질문 내용2",
                        LocalDateTime.now(), LocalDateTime.now(), new WriterResponse(2L, "회원 이름2", "회원 이미지2"))
        );

        given(questionService.findQuestions(Mockito.anyInt(), Mockito.anyInt())).willReturn(pageQuestions);
        given(mapper.questionsToQuestionResponseDto(Mockito.anyList())).willReturn(responses);

        // when
        ResultActions actions =
                mockMvc.perform(
                        get("/questions")
                                .params(queryParams)
                                .accept(MediaType.APPLICATION_JSON)
                );

        // then
        MvcResult result = actions
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data").isArray())
                .andDo(
                        document(
                                "get-questions",
                                getRequestPreProcessor(),
                                getResponsePreProcessor(),
                                requestParameters(
                                        List.of(
                                                parameterWithName("page").description("Page 번호"),
                                                parameterWithName("size").description("Page Size")
                                        )
                                ),
                                responseFields(
                                        List.of(
                                                fieldWithPath("code").type(JsonFieldType.STRING).description("응답 코드"),
                                                fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지"),
                                                fieldWithPath("data").type(JsonFieldType.ARRAY).description("결과 데이터").optional(),
                                                fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("질문 식별자"),
                                                fieldWithPath("data[].title").type(JsonFieldType.STRING).description("질문 제목"),
                                                fieldWithPath("data[].contents").type(JsonFieldType.STRING).description("질문 내용"),
                                                fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("질문 생성 날짜"),
                                                fieldWithPath("data[].modifiedAt").type(JsonFieldType.STRING).description("질문 수정 날짜"),
                                                fieldWithPath("data[].member").type(JsonFieldType.OBJECT).description("질문회원 데이터").optional(),
                                                fieldWithPath("data[].member.id").type(JsonFieldType.NUMBER).description("질문회원 식별자"),
                                                fieldWithPath("data[].member.name").type(JsonFieldType.STRING).description("질문회원 이름"),
                                                fieldWithPath("data[].member.profileImage").type(JsonFieldType.STRING).description("질문회원 이미지"),
                                                fieldWithPath("pageInfo").type(JsonFieldType.OBJECT).description("페이지 정보"),
                                                fieldWithPath("pageInfo.page").type(JsonFieldType.NUMBER).description("페이지 번호"),
                                                fieldWithPath("pageInfo.size").type(JsonFieldType.NUMBER).description("페이지 사이즈"),
                                                fieldWithPath("pageInfo.totalElements").type(JsonFieldType.NUMBER).description("전체 건 수"),
                                                fieldWithPath("pageInfo.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수")
                                        )
                                )
                        )
                )
                .andReturn();

        List list = JsonPath.parse(result.getResponse().getContentAsString()).read("$.data");

        assertThat(list.size(), is(2));
    }

    @Test
    void deleteQuestionTest() throws Exception {
        // given
        Long questionId = 1L;
        Long memberId = 1L;

        MultiValueMap<String, String> param = new LinkedMultiValueMap<>();
        param.add("memberId", String.valueOf(memberId));

        doNothing().when(questionService).deleteQuestion(questionId, memberId);

        // when
        ResultActions actions = mockMvc.perform(
                RestDocumentationRequestBuilders
                        .delete("/questions/{question-id}", questionId)
                        .param("memberId", String.valueOf(memberId))
        );

         // when
        actions
                .andExpect(status().isNoContent())
                .andDo(
                        document(
                                "delete-question",
                                getRequestPreProcessor(),
                                getResponsePreProcessor(),
                                pathParameters(
                                        Arrays.asList(parameterWithName("question-id").description("질문 식별자 ID"))
                                )
                        )
                );
    }
}

어제는 코드를 작성해도 코드 전체 이해도가 낮은 느낌이었다면, 오늘은 정말 코드 하나하나 왜 짰는지, 어떻게 동작하는지 다 생각하면서 짰어야 했다.


이 후, 내가 짠 위의 코드를 기반으로 Member와 Answer의 테스트 코드를 추가해주고, index.adoc 문서에는 아래와 같이 추가했다.

= stackoverflow
:sectnums:
:toc: left
:toclevels: 4
:toc-title: Table of Contents
:source-highlighter: prettify

 코코와 아이들 🐢

v1.0.1, 2023.02.24



***
== MemberController
=== 회원 가입

.http-request
include::{snippets}/post-member/http-request.adoc[]

.request-fields
include::{snippets}/post-member/request-fields.adoc[]

.http-response
include::{snippets}/post-member/http-response.adoc[]

.response-fields
include::{snippets}/post-member/response-fields.adoc[]


=== 회원 정보 수정

.http-request
include::{snippets}/patch-member/http-request.adoc[]

.path-parameters
include::{snippets}/patch-member/path-parameters.adoc[]

.request-fields
include::{snippets}/patch-member/request-fields.adoc[]

.http-response
include::{snippets}/patch-member/http-response.adoc[]

.response-fields
include::{snippets}/patch-member/response-fields.adoc[]

=== 회원 페이지 조회

.http-request
include::{snippets}/get-member/http-request.adoc[]

.path-parameters
include::{snippets}/get-member/path-parameters.adoc[]

.http-response
include::{snippets}/get-member/http-response.adoc[]

.response-fields
include::{snippets}/get-member/response-fields.adoc[]


=== 회원 탈퇴
.curl-request
include::{snippets}/delete-member/curl-request.adoc[]

.http-request
include::{snippets}/delete-member/http-request.adoc[]

.path-parameters
include::{snippets}/delete-member/path-parameters.adoc[]

.http-response
include::{snippets}/delete-member/http-response.adoc[]



***
== QuestionController
=== 질문 등록
.curl-request
include::{snippets}/post-question/curl-request.adoc[]

.http-request
include::{snippets}/post-question/http-request.adoc[]

.request-fields
include::{snippets}/post-question/request-fields.adoc[]

.http-response
include::{snippets}/post-question/http-response.adoc[]

.response-headers
include::{snippets}/post-question/response-headers.adoc[]

.response-fields
include::{snippets}/patch-question/response-fields.adoc[]

=== 질문 수정
.curl-request
include::{snippets}/patch-question/curl-request.adoc[]

.http-request
include::{snippets}/patch-question/http-request.adoc[]

.path-parameters
include::{snippets}/patch-question/path-parameters.adoc[]

.request-fields
include::{snippets}/patch-question/request-fields.adoc[]

.http-response
include::{snippets}/patch-question/http-response.adoc[]

.response-fields
include::{snippets}/patch-question/response-fields.adoc[]

=== 특정 질문 조회
.curl-request
include::{snippets}/get-question/curl-request.adoc[]

.http-request
include::{snippets}/get-question/http-request.adoc[]

.path-parameters
include::{snippets}/get-question/path-parameters.adoc[]

.http-response
include::{snippets}/get-question/http-response.adoc[]

.response-fields
include::{snippets}/get-question/response-fields.adoc[]


=== 질문 목록 조회
.curl-request
include::{snippets}/get-questions/curl-request.adoc[]

.http-request
include::{snippets}/get-questions/http-request.adoc[]

.request-parameters
include::{snippets}/get-questions/request-parameters.adoc[]

.http-response
include::{snippets}/get-questions/http-response.adoc[]

.response-fields
include::{snippets}/get-questions/response-fields.adoc[]


=== 질문 삭제
.curl-request
include::{snippets}/delete-question/curl-request.adoc[]

.http-request
include::{snippets}/delete-question/http-request.adoc[]

.path-parameters
include::{snippets}/delete-question/path-parameters.adoc[]

.http-response
include::{snippets}/delete-question/http-response.adoc[]


=== 답변 등록
.http-request
include::{snippets}/post-answer/http-request.adoc[]

.request-fields
include::{snippets}/post-answer/request-fields.adoc[]

.path-parameters
include::{snippets}/post-answer/path-parameters.adoc[]

.http-response
include::{snippets}/post-answer/http-response.adoc[]

.response-fields
include::{snippets}/post-answer/response-fields.adoc[]
***


***
== AnswerController
=== 답변 수정
.http-request
include::{snippets}/patch-answer/http-request.adoc[]

.request-fields
include::{snippets}/patch-answer/request-fields.adoc[]

.path-parameters
include::{snippets}/patch-answer/path-parameters.adoc[]

.http-response
include::{snippets}/patch-answer/http-response.adoc[]

.response-fields
include::{snippets}/patch-answer/response-fields.adoc[]


=== 답변 삭제
.http-request
include::{snippets}/delete-answer/http-request.adoc[]

.path-parameters
include::{snippets}/delete-answer/path-parameters.adoc[]

.http-response
include::{snippets}/delete-answer/http-response.adoc[]

.response-body
include::{snippets}/delete-answer/response-body.adoc[]
***

gradle-build 후 생성된 index.html을 기반한 최종적인 API 문서












너무 많아서 Question 부분만 가져왔다^^;; http-response쪽 때문에 고생을 많이 했는데 코드를 분석하고 Mockito 객체를 추가적으로 생성하며 내가 원하는대로 문서화할 수 있었다 :)

profile
백엔드 개발자 김창하입니다 🙇‍♂️

0개의 댓글