0. 소개
이 글은 restDocs 적용에 대한 부분은 다루고 있지 않습니다.
restDocs 적용 이후 테스트코드를 어떻게 구성할지 고민이신 분들께 도움이 되리라 생각합니다.
1. 네이밍
2. 자동화
restDocs 위한 테스트코드 자동으로 뽑아주는 라이브러리 없나,,,, → 라이브러리는 없지만, 몇 개 예시를 직접 만들어둔 후 코파일럿을 활용하기로 했다.
3. 통합 테스트 코드 예제
IntegrationTest.java
IntegrationTest 라는 추상 클래스를 만들어 세팅 관련한 코드들을 넣어주었다.
중복코드를 줄이기 위해 모든 테스트 클래스가 상속하는 추상 클래스를 만들어 둔 것이다.
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@SpringBootTest
@Transactional
@ActiveProfiles("police-test") // 테스트용 db 사용 위해 프로필 분리
@WithMockUser(username = "MockKim", roles = {"USER"}) // security 검증 간단히 통과하기 위해 사용. 더 정밀하겐 다음 포스팅에서 다룸
public abstract class IntegrationTest {
static {
System.setProperty("com.amazonaws.sdk.disableEc2Metadata", "true");
}
@Autowired
protected MockMvc mockMvc;
@Autowired
protected EntityManager em;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
protected DatabaseInitializer databaseInitializer;
// 매 테스트 실행 전마다 더미 데이터 추가
@BeforeEach
public void setUp() {
databaseInitializer.execute();
}
// 에러 메시지 관련해선 공통 descriptor로 처리
protected List<FieldDescriptor> errorResultDescriptors = List.of(
fieldWithPath("status_code").type(STRING).description("에러 코드"),
fieldWithPath("message").type(STRING).description("에러 메시지")
);
// descrptor에 enum 설명 적을 때 편리하게 적기 위한 메서드
protected <E extends Enum<E>> String getEnumValuesAsString(Class<E> enumClass) {
String enumValues = Arrays.stream(enumClass.getEnumConstants())
.map(Enum::name)
.collect(Collectors.joining(", "));
return " (종류: " + enumValues + ")";
}
// response body의 경우 모든 필드에 optional을 걸어주어 테스트 오류 최소화해주는 함수
protected List<FieldDescriptor> applyOptionalToResponseFields(List<FieldDescriptor> originalDescriptors) {
return originalDescriptors.stream()
.map(FieldDescriptor::optional)
.collect(Collectors.toList());
}
}
Java
복사
DatabaseInitializer
테스트 시 데이터베이스에 직접 데이터를 저장하거나, 조회하는 등의 api 테스트 정도의 테스트를 진행할 일이 생길 수 있다.
이 경우, 주의할 점은 각 테스트들은 독립적이어야 하기에 테스트를 돌 때마다 데이터베이스를 clear 해주는 작업이 필요하단 점이 있다. 이는 ddl-auto를 테스트에 대해선 create-drop 방식을 택함으로써 해결해주었다.
더 나아가, 매 테스트마다 공통으로 필요한 데이터가 있다면 더미 데이터 개념으로 매 테스트 시작 시 미리 넣어줄 수도 있다. 이는 아래 코드와 같이 구현해주었다.
예시가 좀 많긴 한데, 연관관계가 필요한 친구들은 메서드 인자로 주고 받게 해줬다. id가 필요한 경우가 꽤 많아서, 생성된 각 도메인의 getId 메서드는 넣어줬다.
@Component
public class DatabaseInitializer {
private final EntityManager em;
private Center center;
private User user;
private Agent agent;
private Call call;
private Application application;
private Schedule schedule;
private MessageTemplate messageTemplate;
private MessageHistory messageHistory;
@Autowired
public DatabaseInitializer(EntityManager em) {
this.em = em;
}
public Long getCenterId() {
return center.getId();
}
public Long getUserId() {
return user.getId();
}
public Long getAgentId() {
return agent.getId();
}
public Long getApplicationId() {
return application.getId();
}
public Long getCallId() {
return call.getId();
}
public Long getScheduleId() {
return schedule.getId();
}
public Long getMessageTemplateId() {
return messageTemplate.getId();
}
public Long getMessageHistoryId() {
return messageHistory.getId();
}
public void execute() {
center = createInitCenter();
user = createInitUser();
agent = createInitAgent();
call = createInitCall(center, user);
application = createInitApplication();
schedule = createInitSchedule(center, user, agent);
messageTemplate = createInitMessageTemplate(user);
messageHistory = createInitMessageHistory(center, messageTemplate);
}
private Center createInitCenter() {
Center center = Center.createCenter(
"서울특별시",
"동작구"
);
em.persist(center);
return center;
}
private User createInitUser() {
User user = User.createUser(
"Kim",
"김전화",
"1234",
"01011111111",
LocalDate.of(2000, 1, 1),
UserAuthority.USER
);
em.persist(user);
return user;
}
private Application createInitApplication() {
Application application = Application.createApplication(
center,
new Profile("김원장", "wonjang@naver.com", "010-2222-2222"),
new HopePeriod(LocalDate.of(2023, 12, 15), LocalDate.of(2023, 12, 17)),
new HopeTimeSlot(LocalTime.of(11, 0), LocalTime.of(12, 0))
);
em.persist(application);
return application;
}
private Agent createInitAgent() {
Agent agent = Agent.createAgent(
"김현장",
"01033333333"
);
em.persist(agent);
return agent;
}
private Call createInitCall(Center center, User user) {
Call call = Call.createCall(
center,
user,
"2000-01-01",
"09:00:00",
Participation.EMPTY
);
em.persist(call);
return call;
}
private Schedule createInitSchedule(Center center, User user, Agent agent) {
Schedule schedule = Schedule.createSchedule(
center,
user,
agent,
LocalDate.of(2000, 1, 1),
LocalDate.of(2000, 1, 1),
LocalTime.of(10, 0),
25
);
em.persist(schedule);
return schedule;
}
private MessageTemplate createInitMessageTemplate(User staff) {
MessageTemplate messageTemplate = MessageTemplate.createMessageTemplate(
staff,
"제목",
"문자내용"
);
em.persist(messageTemplate);
return messageTemplate;
}
private MessageHistory createInitMessageHistory(Center center, MessageTemplate messageTemplate) {
MessageHistory messageHistory = MessageHistory.createMessageHistory(
center,
messageTemplate,
"김전화",
"wonjang@naver.com"
);
em.persist(messageHistory);
return messageHistory;
}
}
Java
복사
Security 관련
해당 부분은 내용이 많아 다음 포스트로 끊어 넣었습니다.
예제를 실제로 사용해보고 싶으신 분들은 다음 포스트를 보고 오심 좋을 거 같습니다.
전체적인 흐름을 보고 싶으신 분들은 바로 아래로 넘어 가셔도 좋습니다.
CallIntegrationTest.java → requestBody 사용 예시
requestBody 가 존재하는 예시 코드 입니다. 원래 코드를 간략화 해서 핵심 내용만 담아 재구성했습니다.
public class CallIntegrationTest extends IntegrationTest {
@Nested
@DisplayName("콜기록 추가하기")
class SaveCall {
// 문서에 추가할 requestBody 설명
List<FieldDescriptor> callSaveRequestDescriptors = List.of(
fieldWithPath("centerId").type(JsonFieldType.NUMBER).description("센터 id"),
fieldWithPath("inOut").type(JsonFieldType.STRING)
.description("접수 방법" + getEnumValuesAsString(InOut.class)) // enum 설명
fieldWithPath("centerEtc").type(JsonFieldType.STRING).description("센터 비고").optional() // 센터 비고 필드에만 optional 추가
);
// 콜기록 추가하기에 대한 201 테스트
@Test
@DisplayName("콜기록을 추가할 수 있다.")
void Should_SaveCall_Success() throws Exception {
// given
CallSaveRequest request = new CallSaveRequest( // requestDto (requestBody)
1L,
InOut.IN,
"센터 비고"
);
// when
ResultActions resultActions = mockMvc.perform( // api 실행
RestDocumentationRequestBuilders
.post("/call")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)) // requestDto To json
);
// then
resultActions.andExpect(status().isCreated()); // 상태 코드 201인지 체크
resultActions.andDo( // 문서 작성
document(
"call-save-success", // api의 id
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
resource(
ResourceSnippetParameters.builder()
.tag("call") // 문서에서 api들이 태그로 분류됨
.summary("콜기록 추가하기") // api 이름
.description("콜기록을 추가합니다.") // api 설명
.requestSchema(schema("CallSaveRequest")) // requestBody 이름 (필수x)
.requestFields(callSaveRequestDescriptors) // 아까 만들어둔 requestBody 설명
.build()
)
)
);
}
}
}
Java
복사
AgentIntegrationTest.java → query/pathParameter, responseBody 사용 예시
queryParameter와 pathParameter가 존재하며, resoponseBody가 존재하는 예시 코드입니다.
원래 코드를 간략화 해서 핵심 내용만 담아 재구성했습니다.
public class AgentIntegrationTest extends IntegrationTest {
@Nested
@DisplayName("월과 키워드로 현장요원 검색하기")
@WithMockUser(username = "MockLee", roles = {"ADMIN"}) // 권한으로 IntegrationTest에 걸려있는 USER 말고 ADMIN을 따로 적용
class SearchAgent {
// 문서에 추가할 pathParameter 설명
List<ParameterDescriptorWithType> agentSearchPathParameterDescriptors = List.of(
parameterWithName("month").description("월")
);
// 문서에 추가할 queryParameter 설명
List<ParameterDescriptorWithType> agentSearchQueryParameterDescriptors = List.of(
parameterWithName("keyword").description("키워드")
);
// 문서에 추가할 responseBody 설명
List<FieldDescriptor> agentSearchResponseDescriptors = applyOptionalToResponseFields(List.of( // 모든 필드에 optional 적용
fieldWithPath("agentId").type(NUMBER).description("현장요원 ID"),
fieldWithPath("name").type(STRING).description("현장요원 이름"),
fieldWithPath("hasCar").type(STRING)
.description("현장요원 자차 여부" + getEnumValuesAsString(HasCar.class)),
fieldWithPath("scheduleList[].date").type(STRING).description("스케쥴 날짜"),
fieldWithPath("scheduleList[].scheduleId").type(NUMBER).description("스케쥴 ID"),
fieldWithPath("scheduleList[].receiptDate").type(STRING).description("접수일"),
fieldWithPath("scheduleList[].visitDate").type(STRING).description("방문날짜"),
fieldWithPath("scheduleList[].visitTime").type(STRING).description("방문시간"),
fieldWithPath("scheduleList[].center.centerId").type(NUMBER).description("센터 ID"),
fieldWithPath("scheduleList[].center.name").type(STRING).description("센터 이름"),
fieldWithPath("scheduleList[].center.address").type(STRING).description("센터 주소"),
));
// 현장요원 검색하기에 대한 200 테스트
@Test
@DisplayName("월과 키워드로 현장요원을 검색할 수 있다.")
void Should_SearchAgent_Success() throws Exception {
// given
Map<String, String> queryParams = Map.of( // queryParameter
"keyword", "김"
);
MultiValueMap<String, String> queryParameters = new LinkedMultiValueMap<>();
queryParameters.setAll(queryParams);
// when
ResultActions resultActions = mockMvc.perform( // api 실행
RestDocumentationRequestBuilders
.get("/agent/{month}", "2000-01") // pathParametr는 여기서 바로 지정해줬음
.params(queryParameters) // 만들어둔 queryParameter 추가
.accept(MediaType.APPLICATION_JSON)
);
// then
resultActions.andExpect(status().isOk()); // 상태 코드 200인지 확인
resultActions.andDo( // 문서 작성
document(
"agent-search-success", // api의 id
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
resource(
ResourceSnippetParameters.builder()
.tag("agent") // 문서에서 api들이 태그로 분류됨
.summary("월과 키워드로 현장요원 검색하기") // api 이름
.description("월과 키워드로 현장요원을 검색합니다. 어드민만 사용할 수 있습니다.") // api 설명
.pathParameters(agentSearchPathParameterDescriptors) // 아까 만들어둔 pathParameter 설명
.queryParameters(agentSearchQueryParameterDescriptors) // 아까 만들어둔 queryParameter 설명
.responseSchema(schema("AgentSearchResponse")) // responseBody 이름 (필수x)
.responseFields(agentSearchResponseDescriptors) // 아까 만들어둔 responseBody 설명
.build()
)
)
);
}
}
}
Java
복사
이 두 가지 예시면, multipart/form-data 형식이 아닌 json을 주고받는 api들은 전부 커버가 될 것입니다.
requset/respose body, path/query parameter 이렇게 네 가지 케이스가 전부 예시로 나와있기 때문입니다.
글을 읽는 분들께 도움이 되셨으면 좋겠습니다