Spring Boot Junit, Mockito

강서진·2024년 1월 9일

4번째 필수강의 part1. ch5. 1~4강 요약

JUnit5

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

격리성이 떨어지는 문제를 해결하기 위한 방법으로는 모킹이 있다. 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에 데이터 없이도 테스트가 실행되는 것을 확인할 수 있다.


Controller Test

@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 Test

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로 캡쳐한 값을 검증한다.
Failed Test Case

다음으로는 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을 던지는 것을 검증하게 되고, 던진 예외 자체를 받음으로서 동작과 검증을 동시에 완료한다.
  • 테스트를 통과한 것을 확인할 수 있다.
  • 저장하는 로직까지 도달하지 못하기 때문에 캡쳐가 의미가 없다.

0개의 댓글