TDD를 만족하기 위해서 단위 테스트 코드 작성하는 법을 공부했었다. 이 글에서 잘 정리하고자 한당. 기본적인 방식은 given-when-then을 따르고자 한다. 이러한 패턴의 장단점도 있다고 하지만, 아직 그러한 깊이는 부족한 것 같다. 나중에 많이 하다 보면 보이겠지?
0. Mock 객체 작성
@MockBean
private RecordService recordService;
단위 테스트를 만들기 위해 의존관계 주입 받는 외부 객체는 Mock객체로 만들어 낸다. @Mock과 @MockBean 어노테이션으로 Mock객체를 생성할 수 있다. Spring Boot Container가 테스트 시에 필요하고, Bean이 Container에 존재한다면 @MockBean을 사용하고 아닌 경우에는 @Mock을 사용한다.
1. Controller 테스트 코드
@Slf4j
@MockBean(JpaMetamodelMappingContext.class)
@WebMvcTest(RecordController.class)
@Import({ElementValidator.class, CollectionValidator.class, GlobalExceptionHandler.class, EmptySectionException.class})
public class RecordControllerTestV2 {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private RecordService recordService;
private static MockHttpSession session;
@AfterAll
static void logout() {
session.clearAttributes();
}
@Test
@DisplayName("달력 정보 가져오기")
void getCalenderInfo() throws Exception {
List<GetCalenderRes> response = getCalenderResList();
Integer year = 2022;
Integer month = 9;
given(recordService.myCalenderPage(loginId, year, month)).willReturn(
response);
mockMvc.perform(get("/record/calender")
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding("utf-8")
.session(session)
.param("year", year.toString())
.param("month", month.toString())
)
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.[0].day").value(1l))
.andDo(print());
verify(recordService).myCalenderPage(loginId, year, month);
}
}
Controller 테스트 코드 작성이 가장 까다롭다. 하나씩 봅시다.
1.1 클래스 외부
@MockBean(JpaMetamodelMappingContext.class)
위 어노테이션이 없으면 이러한 에러 메시지가 나타난다.
No qualifying bean of type 'umc.healthper.service.MemberService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
이 이유는 테스트를 돌릴때는 기본적으로 XApplication이 돌면서 작동한다. 따라서 @EnableJpaAuditing을 Application위에 올리면 Jpa관련된 빈들이 올라오기를 요구한다. 이때, mockMvc를 사용한 테스트는 mvc와 관련한 빈들만 찾아서 올리므로 JPA관련 빈을 찾지 못해서 JPA metamodel must not be empty에러가 발생한다.
@WebMvcTest(RecordController.class)
테스트 목적인 Controller 클래스는 위 어노테이션으로 등록해준다.
@Import({ElementValidator.class, CollectionValidator.class,
GlobalExceptionHandler.class, EmptySectionException.class})
mock 테스트를 하면 위 같이 Import는 없는게 좋을 수 도 있지만, 내가 정의한 Exception이 잘 되는지, validator가 잘 동작 하는지와 같은 것을 보는 것도 Controller에서 하는 것이라 생각해서 가져왔다.
1.2 클래스 내부
@Autowired
private MockMvc mockMvc;
MockMvc의 경우 컨트롤러의 역할(url mapping, http method 등등)을 한다.
private static MockHttpSession session;
웹 통신에서 세션을 빼놓을 수 없다. 로그인 구현을 세션 방식으로 했어서 위 객체가 필요했다.
1.3 메서드 내부
given(recordService.myCalenderPage(loginId, year, month)).willReturn(
response);
given-when-then에서 given에 해당한다. 위 코드는 Mock 객체인 recordService에 대해 myCalenderPage메서드에 위 세가지 인자가 들어올 경우 response를 리턴하도록 했다.
mockMvc.perform(get("/record/calender")
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding("utf-8")
.session(session)
.param("year", year.toString())
.param("month", month.toString())
)
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.[0].day").value(1l))
.andDo(print());
이번엔 when 부분이다. mockMvc.perform 메서드를 활용한다. get(url), post(url)로 http 메서드와 url명시한다. 이후 contetyType과 같은 헤더 정보들을 등록하고 param 메서드로 requestParameter를 등록한다. session으로 session 값을 넣을 수도 있다.
위 코드에는 없지만 만약 body 부분에 내용을 등록하고 싶다면 content() 메서드를 통해 등록한다. 메서드 파라미터는 String 타입으로 넘겨 받는데, ObjectMapper를 통해 DTO를 변환하여 만들기도 한다. 아래 코드 처럼.
PostRecordReq recordReq = postReq();
ResultActions perform = mockMvc.perform(post("/record")
.contentType(MediaType.APPLICATION_JSON)
.characterEncoding("utf-8").content(req)
.session(session));
마지막으로 then 부분인 검증은 andExpect와 verify 메서드를 사용했다. andExpect는 $같은 표현식으로 json 타입을 검증하고 verify는 mock 객체의 메서드가 올바르게 사용 되었는지를 확인한다.
2. Service 테스트 코드
@Slf4j
@ExtendWith(MockitoExtension.class)
class RecordServiceTest{
@InjectMocks
RecordService service;
@Mock
RecordRepository repository;
@Mock
MemberService memberService;
static Member testUser;
static LocalDate testDate;
@BeforeAll
static void before(){
testDate = LocalDate.now();
testUser = Member.createMember(2022l, "test");
}
/**
* 없는 운동 조회시
*/
@Test
@DisplayName("없는 기록 id 조회")
void incorrectGet(){
Long wrongRecordId = 500l;
RecordNotFoundByIdException exception = assertThrows(RecordNotFoundByIdException.class, () -> {
service.findById(wrongRecordId, testUser.getId());
});
}
@Test
@DisplayName("id 정상 조회")
void getRecord(){
Long recordId = 1l;
String comment = "good-day";
String sections = "0100101001";
RecordJPA response = new RecordJPA(Member.createMember(2022l, "test"), comment, sections, testDate);
Mockito.when(repository.findById(recordId)).thenReturn(response);
RecordJPA findRecord = service.findById(recordId, testUser.getId());
assertThat(findRecord.getComment()).isEqualTo(comment);
assertThat(findRecord.getSections()).isEqualTo(sections);
assertThat(findRecord.getCreatedDay()).isEqualTo(testDate);
}
}
Service 테스트 코드 부터는 간단하다. 앞에서 많이 다루어서. 이번에는 mock 객체를 주입받는 RecordService를 만들었다.
이번에는 given 단계에서 Mockito.when을 사용해 보았다.
3. Repository 테스트 코드
repositoory 테스트 코드는 위 service 테스트 코드와 유사하게 진행하면 되어서 생략하겠다. 하지만 중요한 건 repository인 것 만큼 db와의 연결이 중요하다.
테스트를 하는 만큼 기존 db 보다는 in-memory로 만들고 싶다면 아래와 같은 두 방식 중 하나를 클래스 레벨에 선언하면 된다.
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@DataJpaTest
'백앤드(스프링)' 카테고리의 다른 글
제너릭과 DTO, connection pool을 활용하여 Spring RestTemplate 이용하기 (0) | 2022.12.22 |
---|---|
스프링 포인트 거래 시스템 (1) | 2022.12.21 |
AOP 2편[내부 호출과 프록시 기술의 한계, 마무리] (0) | 2022.10.30 |
AOP 2편 [포인트 컷 분리, 어드바이스 활용, 포인트컷 지시자] (0) | 2022.10.30 |
AOP 1편 [@Aspect, AOP 1편 정리] (0) | 2022.10.30 |