우선, 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 객체를 추가적으로 생성하며 내가 원하는대로 문서화할 수 있었다 :)