전체 보기

통합 테스트 코드 작성 (with Spring Rest Docs)

작성일자
2023/12/19
태그
SPRING
HIGHLIGHT
프로젝트
FIS
책 종류
1 more property

0. 소개

이 글은 restDocs 적용에 대한 부분은 다루고 있지 않습니다.
restDocs 적용 이후 테스트코드를 어떻게 구성할지 고민이신 분들께 도움이 되리라 생각합니다.

1. 네이밍

채택한 테스트 코드 메서드 네이밍 : should_기대결과_When_테스트상태
ex) Should_ThrowException_WhenDuplicateNickname
이 방식은 'Behavior-Driven Development' (BDD)에 영감을 받은 Given-When-Then 패턴을 반영한 것으로, 테스트의 의도를 명확하게 전달하기에 채택했다.

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 이렇게 네 가지 케이스가 전부 예시로 나와있기 때문입니다.
글을 읽는 분들께 도움이 되셨으면 좋겠습니다