4번째 필수강의 part1. ch5. 1~4강 요약
Java의 유닛 테스트를 위한 프레임워크로, Spring Boot 2.4부터는 JUnit 5가 디폴트로 적용되어 있다.
윈도우에서는 Ctrl+Shift+T로 테스트 패키지에 동일한 구조로 테스트를 생성할 수 있으며, 테스트에서 가장 많이 사용되는 방법은 Assert 메서드를 사용하는 방법이다.
DMakerService 등을 실제로 동작을 시킬 때는 단순히 new 생성자로 객체를 생성하기엔 너무 복잡해지기 때문에, 서비스나 레포지토리 등을 전부 스프링에 bean으로 등록한 후 주입해주어야 한다. JUnit에서는 스프링 빈 등록 과정이 필요한 서비스를 쉽게 테스트하기 위해 @SpringBootTest 애너테이션을 제공한다. 필요한 모든 빈을 생성해서 실제 실행환경을 재현하고, 그 환경에서 테스트를 돌려본다고 생각하면 된다. 이를 인테그레이션 테스트, 혹은 통합 테스트라고도 부른다.
DMakerServiceTest에 @SpringBootTest를 붙이고, DMakerService에 @Autowired를 붙여주면 의존성이 주입된다.
@SpringBootTest
class DMakerServiceTest {
@Autowired
DMakerService dMakerService;
@Test
public void testExample(){
List<DeveloperDTO> allEmployedDevelopers = dMakerService.getAllEmployedDevelopers();
System.out.println(allEmployedDevelopers);
}
}
이를 실행해보면, H2 DB에 저장된 내용은 없어서 빈 내용이 출력되지만, 어쨌든 실행이 되긴 하는 것을 확인할 수 있다.
테스트용 컨텍스트가 만들어져서 애플리케이션의 모든 빈들이 올라가는 것도 로그에서 확인할 수 있었다.
createDeveloper를 넣어서 내용이 출력되게 만들 수도 있겠으나, 격리성이 떨어지고 DB에 데이터가 있어야만 테스트를 해볼 수 있는 문제로 이어질 수 있다.
격리성이 떨어지는 문제를 해결하기 위한 방법으로는 모킹이 있다. Mockito를 사용하는데, Spring Boot Starter Test에 이미 내장되어 있기 때문에 따로 주입할 필요는 없다.
@ExtendWith(MockitoExtension.class)
class DMakerServiceTest {
@Mock
DeveloperRepository developerRepository;
@Mock
RetiredDeveloperRepository retiredDeveloperRepository;
@InjectMocks
DMakerService dMakerService;
@Test
public void testGetDeveloperDetail(){
given(developerRepository.findByMemberId(anyString()))
.willReturn(Optional.of(Developer.builder()
.developerLevel(DeveloperLevel.SENIOR)
.developerSkillType(DeveloperSkillType.BACK_END)
.experienceYears(12)
.statusCode(StatusCode.EMPLOYED)
.name("name")
.age(40)
.build()));
DeveloperDetailDTO developerDetail = dMakerService.getDeveloperDetail("memberId");
assertEquals(DeveloperLevel.SENIOR, developerDetail.getDeveloperLevel());
assertEquals(BACK_END, developerDetail.getDeveloperSkillType());
assertEquals(12,developerDetail.getExperienceYears());
}
}
Mockito를 사용하려면,
- @SpringBootTest를 삭제하고 @ExtendWith로 대신하며 괄호 안에 MockitoExtension을 사용할 것을 명시해준다.
- 주입이 필요한 클래스에는 @InjectMocks를 붙여주며, 이 클래스가 필요한 의존성에 @Mock 애너테이션을 붙여 등록해준다.
- getDeveloperDetail을 테스트해보려면, 데이터가 입력되지 않은 상태에서는 동작하지 않는다.
- 메서드를 호출하기에 앞서 등록된 Mock들의 동작을 정의해줄 필요가 있다.
- given으로 시작하는 부분이 바로 Mocking으로, findByMemberId에 아무 문자열이나 넣으면 이런 응답을 반환하겠다는 가짜 동작을 정해둔 것이다.
- getDeveloperDetail을 실행하면, given에서 설정한 응답을 받게 된다.
테스트를 실행해보면 DB에 데이터 없이도 테스트가 실행되는 것을 확인할 수 있다.
@WebMvcTest라는 것을 사용해본다. Controller 관련된 빈만 테스트에 올려서 사용한다는 점에서 컨트롤러를 테스트할 때 유용하다.
@WebMvcTest(DMakerController.class)
class DMakerControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private DMakerService dMakerService;
protected MediaType contentType = new MediaType(MediaType.APPLICATION_JSON.getType(),
MediaType.APPLICATION_JSON.getSubtype(),
StandardCharsets.UTF_8);
@Test
void getAllDeveloper() throws Exception{
DeveloperDTO juniorDeveloperDTO = DeveloperDTO.builder()
.developerSkillType(DeveloperSkillType.BACK_END)
.developerLevel(DeveloperLevel.JUNIOR)
.memberId("memberId")
.build();
DeveloperDTO seniorDeveloperDTO = DeveloperDTO.builder()
.developerSkillType(DeveloperSkillType.FRONT_END)
.developerLevel(DeveloperLevel.SENIOR)
.memberId("memberId2")
.build();
given(dMakerService.getAllEmployedDevelopers())
.willReturn(Arrays.asList(juniorDeveloperDTO,seniorDeveloperDTO));
mockMvc.perform(get("/developers").contentType(contentType))
.andExpect(status().isOk())
.andExpect(jsonPath("$.[0].developerSkillType",
CoreMatchers.is(DeveloperSkillType.BACK_END.name()))
).andExpect(jsonPath("$.[0].developerLevel",
CoreMatchers.is(DeveloperLevel.JUNIOR.name()))
).andExpect(jsonPath("$.[1].developerSkillType",
CoreMatchers.is(DeveloperSkillType.FRONT_END.name()))
).andExpect(jsonPath("$.[1].developerLevel",
CoreMatchers.is(DeveloperLevel.SENIOR.name()))
);
}
}
- @WebMvcTest 뒤에 원하는 컨트롤러를 명시하면, 해당 컨트롤러와 컨트롤러의 어드바이스, 필터 등 기본적으로 이 컨트롤러에 접근하기 위한 클래스들도 함께 테스트에 등록되어 편리하다.
- MockMvc: 컨트롤러에 보낼 요청값을 직접 호출하게 되면, 필요한 파라미터들을 검증하고 바인딩하는 과정을 거쳐야 하는데, 그런 것까지 테스트하기는 힘들어서 등장하였다. MockMvc는 그러한 호출을 가상으로 만들어준다.
- DMakerController는 DMakerService에 의존하기 때문에, DMakerService를 가짜 빈으로 등록해 올려준다.
- 많이 사용하게 될 JSON 타입을 생성해주었다.
- 테스트로 출력할 DTO를 만들고, given을 사용하여 getAllEmployedDevelopers에 반환할 응답을 설정해준다.
- MockMvc가 get으로 /developers를 호출하고, 컨텐츠타입을 JSON으로 보내고 JSON타입으로 받도록 설정한다.
- 결과를 andExpect로 비교한다.
테스트를 실행했더니, Entity 생성 시점을 기록하기 위해 붙였던 @EnableJpaAuditing에 문제가 생겨 테스트를 진행할 컨텍스트를 올리지 못한 것으로 나왔다.
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jpaAuditingHandler': Cannot resolve reference to bean 'jpaMappingContext' while setting constructor argument
at org.springframework.beans.factory.support.BeanDefinitionValueResolver.resolveReference
...
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jpaMappingContext': JPA metamodel must not be empty
이 오류에 대한 해결책은 여러 가지가 있는데, 그 중에서 @Configuration를 분리하는 방법으로 해결하였다. 설정값을 별도로 따서 애플리케이션에 영향을 주지 않도록 바꾼다.
// config 패키지 생성
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
@Configuration으로 빈을 등록했기 때문에 DmakerApplication에서는 @EnableJpaAuditing을 지울 수 있다.
이렇게 설정해두면, Configuration 빈이 불려갈 때는 Auditing 기능이 활성화되고, 불려나가지 않을 때는 Auditing 기능이 동작하지 않는 상태가 된다.
이후 다시 테스트를 진행해보면 통과하는 것을 확인할 수 있다.
테스트를 실행하면 아무것도 출력되지 않는데, 첫 andExpect 뒤에 .andDo(print()) 를 추가하면 응답이 함께 출력되어 확인하기 좋다.
Service를 테스트할 때는 다시 Mockito를 사용하되 조금 더 다양한 케이스를 살펴보도록 한다.
@Test
public void testCreateDeveloperSuccess(){
// given
CreateDeveloper.Request request = CreateDeveloper.Request.builder()
.developerLevel(SENIOR)
.developerSkillType(BACK_END)
.experienceYears(12)
.memberId("memberId")
.name("name")
.age(40)
.build();
given(developerRepository.findByMemberId(anyString()))
.willReturn(Optional.empty());
ArgumentCaptor<Developer> captor =
ArgumentCaptor.forClass(Developer.class);
// when
CreateDeveloper.Response developer = dMakerService.createDeveloper(request);
// then
verify(developerRepository, times(1))
.save(captor.capture()); // developerRepository의 save 메서드
Developer savedDeveloper = captor.getValue();
assertEquals(SENIOR, savedDeveloper.getDeveloperLevel());
assertEquals(BACK_END, savedDeveloper.getDeveloperSkillType());
assertEquals(12, savedDeveloper.getExperienceYears());
}
testCreateDeveloper
- 생성할 developer.request 객체를 build한다.
- createDeveloper 메서드 안 비즈니스 검증 과정에서 findByMemberId를 거친다. 이 부분의 mocking이 필요하므로 given으로 가짜 동작을 설정해준다.
- 반환되는 객체가 없어야 중복이 없다는 뜻이므로 빈 Optional을 반환하도록 한다.
- Mockito는 Mock 객체가 동작하여 파라미터로 받은 값도 검증하고 활용할 수 있게 해준다. createDeveloper 동작에서 developerRepository의 save를 통해 객체를 저장하게 되는데, 이 때 save하는 객체를 활용할 수 있게 캡쳐해주는 것이 argument captor이다.
- argument captor로 캡쳐한 값을 검증한다.
다음으로는 testCreateDeveloper이 실패하는 테스트케이스를 작성해본다. 위에서 작성했던 코드를 가져다가 몇가지 부분만 수정을 하는데, 이 경우에는 developerRepository에서 findByMemberId를 실행했을 때 반환되는 developer 객체가 필요하다.
따라서 테스트케이스에서 사용할 developer 객체를 하나 build 해준다.
private final Developer defaultDeveloper = Developer.builder()
.developerLevel(SENIOR)
.developerSkillType(BACK_END)
.experienceYears(12)
.memberId("memberId")
.statusCode(StatusCode.EMPLOYED)
.name("name")
.age(40)
.build();
추가로, createDeveloper 역시 마찬가지로 다른 테스트 케이스에서도 사용할 수 있는 만큼 따로 메서드로 빼낸다.
private final CreateDeveloper.Request defaultCreateRequest = CreateDeveloper.Request.builder()
.developerLevel(SENIOR)
.developerSkillType(BACK_END)
.experienceYears(12)
.memberId("memberId")
.name("name")
.age(40)
.build();
다시 돌아와서, findByMemberId의 결과로 developer 객체가 반환되면 DMakerException이 발생한다. Exception이 발생하는 것을 검증하려면 assertEquals나 verify를 사용하기 보다는, 어차피 반환값을 받지 못하므로 assertThrows를 사용한다.
@Test
public void testCreateDeveloperDuplicateFail(){
// given
given(developerRepository.findByMemberId(anyString()))
.willReturn(Optional.of(defaultDeveloper));
ArgumentCaptor<Developer> captor =
ArgumentCaptor.forClass(Developer.class);
// when
// then
DMakerException dMakerException = assertThrows(DMakerException.class,
() -> dMakerService.createDeveloper(defaultCreateRequest));
DMakerException dMakerException = assertThrows(DMakerException.class,
() -> dMakerService.createDeveloper(defaultCreateRequest));
assertEquals(DMakerErrorCode.DUPLICATED_MEMBER_ID, dMakerException.getDMakerErrorCode());
}
- createDeveloper가 Exception을 던지는 것을 검증하게 되고, 던진 예외 자체를 받음으로서 동작과 검증을 동시에 완료한다.
- 테스트를 통과한 것을 확인할 수 있다.
- 저장하는 로직까지 도달하지 못하기 때문에 캡쳐가 의미가 없다.