CI/CD 파이프라인 중 테스트와 정적 코드 분석은 코드의 품질을 높일 수 있는 좋은 방법입니다. 이번 실습에서는 먼저 테스트 코드 작성에 대해 알아보겠습니다.
테스트 코드에 대한 중요성은 인지하고 있었지만, 사실 실제로 프로젝트를 진행하면서는 SI사업의 특성상 시간과 리소스의 문제로 실제로 테스트 코드를 작성하진 않았었습니다. 그러다보니 이번에 테스트 코드 작성도 처음하고, 정적 코드 분석이란 개념도 처음 접하게 되었습니다. 이에 대해 처음에는 이해를 잘 못했었는데 어떤 오해를 했었고, 어떻게 이해를 했었는지까지 한번 자세하게 적어볼까 합니다.(아직도 잘못 이해하고 있는 것이 있을 수 있으니 댓글로 정정해주시면 더 좋을 것 같습니다.)
테스트는 Mockito를 이용한 슬라이스 테스트로 진행하였습니다. 슬라이스 테스트란 레이어별로 잘라서 단위테스트를 진행하는 것으로 레이어에서 Bean을 최소한으로 등록시켜 테스트하는 것입니다.
Mockito란 결과를 넣은 가짜 객체를 주입하여 테스트를 진행하게 도와주는 프레임워크입니다.
단위테스트를 진행할 때 @SpringBootTest로 테스트하지 않는 이유는 다음과 같습니다.
- 실제 어플리케이션의 모든 Bean을 올리기 때문에 무겁다.
- 테스트 단위가 크기때문에 디버깅이 어렵다.
레이어는 Controller와 Service로 나누었으며, Controller에서는 @WebMvcTest를 이용하였습니다.
@WebMvcTest는 Mvc를 테스트할 때에 웹을 직접 실행시키지 않고 테스트 요청과 응답을 테스트 하기 위한 어노테이션입니다.
테스트 레이아웃은 아래와 같이 잡았습니다.
여기서 Sample은 테스트 할 Entity의 예시를 Constant로 정의해 둔 곳입니다. 이를 활용해서 코드를 테스트할 것입니다.
아래는 예시를 위해 코드의 일부분을 발췌한 것입니다.
QuestionController.java
public class QuestionController {
private final QuestionService questionService;
@PostMapping("/question")
public ResponseEntity registerQuestion(@RequestHeader("userId") String userId,
@RequestBody QuestionRegisterRequest questionRegisterRequest){
questionService.registerQuestion(userId, questionRegisterRequest); // a
return ResponseEntity.noContent().build();
}
@GetMapping("/question-show")
public ResponseEntity<QuestionResponse> findShowQuestionById(@RequestParam Long id){
return ResponseEntity.ok(questionService.findShowQuestionResponse(id));
}
@GetMapping("/question-show-by-userId")
public ResponseEntity<List<QuestionListResponse>> findByUserId(@RequestHeader("userId") String userId){
return ResponseEntity.ok(questionService.findByUserId(userId));
}
}
QuestionControllerTest.java
@MockBean(JpaMetamodelMappingContext.class) // 1
@WebMvcTest(QuestionController.class) //2
public class QuestionControllerTest {
@Autowired
private MockMvc mockMvc; //3
@MockBean
private QuestionService questionService; //4
private Question question;
@BeforeEach //5
void setUp(){
question = Question.builder()
.id(QUESTION_ID)
.userId(QUESTION_USER_ID)
.question(
QuestionContents.builder()
.title(QUESTION_TITLE)
.body(QUESTION_BODY)
.build()
)
.response(
Response.builder()
.response(
ResponseContents.builder()
.title(RESPONSE_TITLE)
.body(RESPONSE_BODY)
.build()
)
.build()
)
.displayStatus(QUESTION_DISPLAY_STATUS)
.build();
}
@DisplayName("[회원] 문의사항 등록 API")
@Test
void registerQuestion() throws Exception{
doNothing()
.when(questionService).registerQuestion(anyString(), any()); //6
ResultActions result = mockMvc.perform(
post("/api/question/question")
.header("userId", QUESTION_USER_ID)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(QUESTION_REGISTER_REQUEST))
); // 7
result.andExpect(status().isNoContent()); //8
}
@DisplayName("[회원] 특정 문의사항 조회 API")
@Test
void findShowQuestionById() throws Exception {
doReturn(
question.convertQuestionResponse()
).when(questionService).findShowQuestionResponse(QUESTION_ID); //9
ResultActions resultActions = mockMvc.perform(
get("/api/question/question-show")
.accept(MediaType.APPLICATION_JSON)
.param("id",QUESTION_ID.toString())
);
resultActions.andExpect(status().isOk())
.andExpect(jsonPath("$.question.title").value(QUESTION_TITLE))
.andExpect(jsonPath("$.question.body").value(QUESTION_BODY))
.andExpect(jsonPath("$.response.title").value(RESPONSE_TITLE))
.andExpect(jsonPath("$.response.body").value(RESPONSE_BODY)); // 10
}
@DisplayName("[회원] 특정 유저 문의사항 조회 API")
@Test
void findByUserId() throws Exception {
doReturn(List.of(QuestionListResponse.builder()
.id(QUESTION_ID)
.title(QUESTION_TITLE)
.existResponse(true)
.build()
))
.when(questionService).findByUserId(QUESTION_USER_ID);
ResultActions resultActions = mockMvc.perform(
get("/api/question/question-show-by-userId")
.accept(MediaType.APPLICATION_JSON)
.header("userId", QUESTION_USER_ID)
);
resultActions.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].id").value(QUESTION_ID))
.andExpect(jsonPath("$[0].title").value(QUESTION_TITLE))
.andExpect(jsonPath("$[0].existResponse").value(true)); // 11
}
https://giron.tistory.com/127 를 참고해보면 Application위에 @EnableJpaAuditing를 선언하면 JPA가 자동으로 넣어주는 데이터가 null로 들어오는 문제가 생깁니다. 실제로 저도 CreatedDate와 ModifiedDate가 null로 오는 것 때문에 해당 데이터들은 검수를 하지 않았었습니다. 하지만 별도의 config를 빼서 선언하면 해결된다고 합니다.
QuestionService.java
@Service
@RequiredArgsConstructor
public class QuestionService {
private final QuestionRepository questionRepository;
@Transactional
public void registerQuestion(String userId, QuestionRegisterRequest questionRegisterRequest){
questionRepository.save(Question.of(questionRegisterRequest.getQuestion(),userId));
}
public Question findShowQuestionById(Long id){
return questionRepository.findByIdAndDisplayStatus(id, DisplayStatus.SHOW)
.orElseThrow(()-> new CustomException(ApiMessage.NOT_EXIST_QUESTION));
}
public QuestionResponse findShowQuestionResponse(Long id){
return findShowQuestionById(id).convertQuestionResponse();
}
public List<QuestionListResponse> findByUserId(String userId){
return questionRepository.findAllByUserIdAndDisplayStatusOrderByCreatedDateDesc(userId,DisplayStatus.SHOW)
.stream().map(Question::convertListResponse)
.collect(Collectors.toList());
}
}
QuestionServiceTest.java
@ExtendWith(value = MockitoExtension.class) // 1
public class QuestionServiceTest {
@InjectMocks
@Spy
private QuestionService questionService; // 2
@Mock
private QuestionRepository questionRepository; // 3
private Question question;
@BeforeEach
void setUp(){
question = Question.builder()
.id(QUESTION_ID)
.userId(QUESTION_USER_ID)
.question(
QuestionContents.builder()
.title(QUESTION_TITLE)
.body(QUESTION_BODY)
.build()
)
.response(
Response.builder()
.response(
ResponseContents.builder()
.title(RESPONSE_TITLE)
.body(RESPONSE_BODY)
.build()
)
.build()
)
.displayStatus(QUESTION_DISPLAY_STATUS)
.build();
}
@DisplayName("문의 등록")
@Test
void registerQuestion() {
questionService.registerQuestion(QUESTION_USER_ID, QUESTION_REGISTER_REQUEST); // 4
verify(questionRepository).save(any()); // 5
}
@DisplayName("문의 ID 조회")
@Test
void findShowQuestionById() {
given(questionRepository.findByIdAndDisplayStatus(QUESTION_ID, DisplayStatus.SHOW))
.willReturn(Optional.ofNullable(question)); // 6
Question question1 = questionService.findShowQuestionById(QUESTION_ID); // 7
assertAll(
()->assertThat(question1.getId()).isEqualTo(QUESTION_ID),
()->assertThat(question1.getUserId()).isEqualTo(QUESTION_USER_ID),
()->assertThat(question1.getQuestion().getTitle()).isEqualTo(QUESTION_TITLE),
()->assertThat(question1.getQuestion().getBody()).isEqualTo(QUESTION_BODY),
()->assertThat(question1.getResponse().getResponse().getTitle()).isEqualTo(RESPONSE_TITLE),
()->assertThat(question1.getResponse().getResponse().getBody()).isEqualTo(RESPONSE_BODY),
()->assertThat(question1.getDisplayStatus()).isEqualTo(QUESTION_DISPLAY_STATUS)
); // 8
}
@DisplayName("문의 ID 조회했는데 등록된 ID가 없는 경우 Exception 발생")
@Test
void findShowQuestionById_not_exist_question() {
given(questionRepository.findByIdAndDisplayStatus(2L,DisplayStatus.SHOW))
.willThrow(new CustomException(ApiMessage.NOT_EXIST_QUESTION)); // 9
assertThatThrownBy(()->questionService.findShowQuestionById(2L))
.isInstanceOf(CustomException.class)
.hasMessage("문의사항이 존재하지 않습니다."); // 10
}
@DisplayName("question response로 convert")
@Test
void findShowQuestionResponse() {
given(questionRepository.findByIdAndDisplayStatus(QUESTION_ID, DisplayStatus.SHOW))
.willReturn(Optional.ofNullable(question));
QuestionResponse questionResponse = questionService.findShowQuestionResponse(QUESTION_ID);
assertAll(
()->assertThat(questionResponse.getQuestion().getTitle()).isEqualTo(QUESTION_TITLE),
()->assertThat(questionResponse.getQuestion().getBody()).isEqualTo(QUESTION_BODY),
()->assertThat(questionResponse.getResponse().getTitle()).isEqualTo(RESPONSE_TITLE),
()->assertThat(questionResponse.getResponse().getBody()).isEqualTo(RESPONSE_BODY)
);
}
@DisplayName("유저 문의 조회")
@Test
void findByUserId() {
given(questionRepository.findAllByUserIdAndDisplayStatusOrderByCreatedDateDesc(QUESTION_USER_ID, DisplayStatus.SHOW))
.willReturn(List.of(question));
List<QuestionListResponse> response = questionService.findByUserId(QUESTION_USER_ID);
assertAll(
()->assertThat(response.size()).isEqualTo(1),
()->assertThat(response.get(0).getId()).isEqualTo(QUESTION_ID),
()->assertThat(response.get(0).getTitle()).isEqualTo(QUESTION_TITLE),
()->assertThat(response.get(0).getExistResponse()).isEqualTo(true)
); // 11
}
}
메서드 Stub이란 기존 코드를 대리하거나 흉내내는 것을 의미합니다. 간단하게 메서드의 값을 정해주는 행위를 Stub이라고 생각하면 될 듯 합니다.
위와 같은 방식으로 테스트 코드를 작성하면 다음 실습에서 진행할 jacoco와 SonarQube에서 코드 커버리지를 측정할 수 있습니다. 코드 커버리지란 테스트 코드가 애플리케이션의 어느 정도까지 검증했는지를 측정하는 수치입니다. 테스트코드를 꼼꼼하게 작성할수록 코드 커버리지가 올라가므로 테스트 코드는 꼼꼼하게 작성하는 것이 좋습니다. 다음 실습에서는 jacoco와 sonarQube를 활용하여 코드 커버리지 측정 및 정적 분석하는 방법에 대해 알아보겠습니다.