[Spring] JUnit5, Mockito 이용한 테스트 코드

hyewon jeong·2023년 1월 28일
10

Spring

목록 보기
31/65
post-thumbnail

본 글은 공부를 위해 정리한 것으로, 혹시 잘못된 부분이 있으면 댓글 부탁드립니다.

JUnit5, Mockito

JUnit5:

🌿 Mockito

: Java 프로그래밍에서의 단위 테스트를 위한 Mocking 오픈소스 프레임워크입니다.
: Mock 객체를 쉽게 만들고, 관리하고, 검증할 수 있는 방법을 제공하는 프레임워크 입니다. Mock 객체는 진짜 객체와 비슷하게 동작하지만 프로그래머가 직접 행동을 관리하는 객체입니다.

Gradle에 Mockito 라이브러리 Dependency 추가

Intellij IDEA 최신버전에서 Gradle을 이용하여 프로젝트를 구성하면 기본적으로 JUnit5 의존성을 가집니다. JUnit5부터는 JUnit5에서 Mockito를 함께 사용하기 위한 의존성도 함께 작성해주어야 합니다. 또한, 예제에서는 개발 편의를 위해 Lombok을 함께 사용합니다.

다만, Spring Boot (2.2+) 프로젝트를 구성한 경우에는 ‘org.springframework.boot:spring-boot-starter-test’ 라이브러리에 이미 JUnit5, Mockito가 모두 포함되어 있으므로 이 작업이 필요 없습니다.

🌿 Mock Test

Given – When – Then 패턴을 이용하여 Mock Test를 구성합니다.

  • Given: 테스트를 위한 준비 과정입니다. 변수를 선언하고, Mock 객체에 대한 정의도 함께 작성합니다.
  • When: 테스트를 실행하는 과정입니다. 테스트하고자 하는 내용을 작성합니다.
  • Then: 테스트를 검증하는 과정입니다. 예상한 값과 결괏값이 일치하는 지 확인합니다.

📌⚠️ 테스트 전 AssertJ 라이브러리 등 추가하기

  1. 의존성 추가
//maven
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.21.0</version>
    <scope>test</scope>
</dependency>

//gradle
testImplementation group: 'org.assertj', name: 'assertj-core', version: '3.21.0'
  1. 메소드 임폴트
import static org.assertj.core.api.Assertions.*;

⚠️ assertThat의 에러 경우 이 임폴트가 안되어 있어서 그럴 수 있다.
⚠️ JUnit5의 assertThrows 사용경우

import static org.junit.jupiter.api.Assertions.assertThrows;

🌿 Repository Test

Repository는 엔티티를 영속화하기위해 사용된다.엔티티의 영속화 요구는 서비스에서 발생한다.표현 계층은 Client와 맞닿은 영역이므로 Client의 요청/응답을 처리하며, 필요한 기능을 서비스 계층으로 위임하게 된다.서비스 계층은 요구사항을 처리하는 영역으로, 도메인을 통해 비즈니스 로직을 수행한다.(주문을 하거나, 주문 취소를 하거나, 결제를 하거나, 회원 가입을 한다던가, 상품 등록을 한다던가 등등…)비즈니스 로직을 수행하고 난 도메인을 영속화해야하는데 이 기능을 저장소 영역으로 위임한다.따라서 Repository의 기능만 테스트를 하려면 Service와의 결합을 끊어야 한다.

SpringBoot 테스트는 @DataJpaTest Annottation을 제공하는데, 이것을 통해 Repository의 단위 테스트가 가능하다.

@DataJpaTest을 사용할경우 아래와 같은 기능이 수행된다.

  • JPA 관련된 설정만 로드한다. (WebMVC와 관련된 Bean이나 기능은 로드되지 않는다)
  • JPA를 사용해서 생성/조회/수정/삭제 기능의 테스트가 가능하다.
  • @Transactional을 기본적으로 내장하고 있으므로, 매 테스트 코드가 종료되면 자동으로 DB가 롤백된다.
  • 기본적으로 내장 DB를 사용하는데, 설정을 통해 실제 DB로 테스트도 가능하다. (권장하지 않는다)
  • @Entity가 선언된 클래스를 스캔하여 저장소를 구성한다.

회원 가입과 회원 조회에 대한 테스트를 진행했다.테스트할 userRepository는 Bean으로 등록되므로 @Autowiried를 통해 의존성을 주입받았다.Repository 외에 다른 Bean은 필요없으므로 별다른 설정할 게 없다.

@DataJpaTest //@DataJpaTest annotation 을 제공하는데, 이것을 통해 Repository 의 단위 테스트가 가능함
class UserRepositoryTest {
    // 레포지토리 하단은 데이터 베이스이므로 목객체가 필요없음

    //테스트 할 레포지토리는 빈으로 등록되므로, @Autowiried 를 통해 의존성을 주입 받음
    @Autowired
    private UserRepository userRepository;


    @Test
    @DisplayName("사용자 추가")
        //findByUsername 이 실행 되려면 save가 일어나야 함으로 사용자가 추가되는지 먼저 테스트
    void addUser() {
        //given
        User user = new User(
                "pororo",
                "pororo1234",
                "proro@naver.com",
                UserRoleEnum.USER); //null 로 하게 에러 처리 됨 >.<ㅎㅎ 잘 처리됨 굿

        //when
        User savedUser = userRepository.save(user);
        //then
        assertThat(savedUser.getUsername()).isEqualTo(user.getUsername());
    }


    @Test
    @DisplayName("사용자 조회")
    void findByUsername() {
        //given
        User user = new User(
                "pororo",
                "pororo1234",
                "pororo@naver.com",
                UserRoleEnum.USER
        );

        userRepository.save(user);
        //when
        Optional<User> savedUser = userRepository.findByUsername(user.getUsername());
        //then
        assertThat(savedUser).isPresent();
        
    }
}

🌿 Service Test

1. Service는 위로는 Controller, 아래로는 Domain에 의존하고 있다.따라서 결합을 두 군데나 끊어야 한다.

먼저 Controller와의 연결을 끊어야한다.

2. Controller는 Web모듈이므로 Service Test를 진행하려면 Web에 대한 의존성을 받으면 안된다.

따라서 @WebMvcTest, @SpringBootTest와 같은 테스트를 사용하면 Service만을 테스트하기가 어려워진다.**.
Domain을 통해 비즈니스 로직은 수행해야하지만, 실제로 DB에 저장할 건 아니기 때문에 이 부분을 제거할 방법이 필요하다.SpringBoot 테스트는 특정 객체를 가짜로 대체할 Mocking을 제공하고 있고, 아래와 같은 Annotation을 제공한다.@Mock, @MockBean, @Spy, @SpyBean

의존성 끊는 방법

  • UserServiceTest는 Mockito를 사용(@ExtendWith)하고,
  • @Mock으로 선언한 객체는 의존하고 있는 실제 객체 대신에 @Mock으로 선언한 객체로 바꿔치기된다.
  • 따라서 Service 내에 의존하고 있는 Repository를 @Mock으로 선언하면 Repository Bean에 의존하지 않고 테스트가 가능해진다.
  • 그리고 Service 클래스를 @InjectMocks로 선언함으로써, @Mock으로 선언된 가짜 객체들을 의존한 Service 객체가 생성된다.
    (따라서 테스트 런타임 시 UserService의 멤버 변수로 선언된 UserRepository에 Mock 객체가 주입(InjectMocks)됩니다.)

회원가입과 회원조회 기능에 대해 Service 테스트 코드를 아래와 같이 작성해보았다.

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;
    
    @InjectMocks
    private UserService userService;

    @Spy
    private JwtUtil jwtUtil;

    @Spy
    private BCryptPasswordEncoder passwordEncoder;

    @Test
    @DisplayName("회원가입")
    void signup() {
        //given 입력값 리턴값
        SignupRequest request = SignupRequest.builder()
                .username("pororo")
                .password("pororo1234")
                .email("pororo@naver.com")
                .admin(false)
                .adminToken(null)
                .build();

        when(userRepository.findByUsername(any(String.class)))
                .thenReturn(Optional.empty());
        //when
        ResponseStatusDto response = userService.signup(request);

        //then
        assertThat(response.getStatusCode()).isEqualTo(StatusEnum.SIGNUP_SUCCESS.getStatusCode());
        assertThat(response.getMsg()).isEqualTo(StatusEnum.SIGNUP_SUCCESS.getMsg());

        verify(userRepository, times(1)).save(any(User.class));
    }
  • @ExtendWith(MockitoExtension.class)
    테스트 클래스가 Mockito를 사용함을 의미합니다.
  • @Mock: 실제 구현된 객체 대신에 Mock 객체를 사용하게 될 클래스를 의미합니다. 테스트 런타임 시 해당 객체 대신 Mock 객체가 주입되어 Unit Test가 처리됩니다.
  • @InjectMocks: Mock 객체가 주입된 클래스를 사용하게 될 클래스를 의미합니다. 테스트 런타임 시 클래스 내부에 선언된 멤버 변수들 중에서 @Mock으로 등록된 클래스의 변수에 실제 객체 대신 Mock 객체가 주입되어 Unit Test가 처리됩니다.
  • assertThrows
  1. assertThrows(Class<> classType, Executable executable)
  2. assertThrows 메소드는 첫번째 인자로 발생할 예외 클래스의 Class 타입을 받습니다.
  3. Assertions.assertThrows의 두 번째 인자인 입력된 함수? 를 실행하여 첫 번째 인자인 예외 타입과 같은지(혹은 캐스팅이 가능한 상속 관계의 예외인지) 검사합니다.
  • assertThat(결과 검증)
    : 리턴이 있는 결과값 검증

  • verify(행위 검증)
    : 목객체를 인자로 받아, 해당 Mock 객체의 원하는 상호작용이 있었는가 검증
    : 만약에 리턴값이 없으면 중요한 함수들이 몇번 호출 됐는지 등을 검사

  • Optional.of()
    : 반드시 값이 있어야 하는 객체인 경우
    : 해당 메서드 null 인 경우 -> NullPointException

  • userRepository.save()
    -> 테스트하지 않음 why? 리턴값도 없고, 행위지정이 없기 때문에 실행 한척 만 함

🌿 Controller Test

  • MockMvc 객체 필요하다.

MockMvc 객체란?

  • 서블릿 컨테이너의 구동 없이, 시뮬레이션된 MVC 환경에 모의 HTTP 서블릿 요청을 전송하는 기능을 제공하는 유틸리티 클래스다.

  • MockMVC란 Spring MVC 테스트 유틸리티 클래스. 테스트 코드를 작성하지 않는다면 Postman 등의 request를 발생시킬 수 있는 도구를 사용해 직접 호출해 서버를 디버깅해야 하는데 MockMVC를 사용하면 이 과정을 건너뛸 수 있다. 쉽게 말해 Controller를 호출해 주는 도구이다.

@ExtendWith(MockitoExtension.class)
class UserControllerTest {

    @Mock
    private UserService userService;
    @InjectMocks
    private UserController userController;
    private MockMvc mockMvc;


    @BeforeEach
    public void init() { // mockMvc 초기화, 각메서드가 실행되기전에 초기화 되게 함
        mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
        //  standaloneMockMvcBuilder() 호출하고 스프링 테스트의 설정을 커스텀하여 mockMvc 객체 생성
    }

    @Test
    @DisplayName("회원가입 (성공)")
    void signup1() throws Exception {
        //given
        // request 입력값, 목객체 리턴값
        SignupRequest request = SignupRequest.builder()
                .username("pororo")
                .password("pororo1234")
                .email("pororo@naver.com")
                .admin(false)
                .build();
        ResponseStatusDto response = new ResponseStatusDto(StatusEnum.SIGNUP_SUCCESS);

        when(userService.signup(any(SignupRequest.class)))//when(userService.signup(request)) 이건 에러남 아래에서 Gson으로 뉴한 객체와 주소값이 다르기 때문임
                .thenReturn(response);

        //when
        ResultActions resultActions = mockMvc.perform(
                MockMvcRequestBuilders.post("/api/users/signup")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new Gson().toJson(request))); //json으로 받아야 하기 때문에 Gson을 build gradle에 추가 하기


        //then
        resultActions.andExpect(status().isOk())
                .andExpect(jsonPath("statusCode", response.getStatusCode()).exists())
                .andExpect(jsonPath("msg", response.getMsg()).exists());
    }

    @Test
    @DisplayName("회원가입 (실패) - 아이디")
    void signup_failed_id() throws Exception {
        // given
        SignupRequest request = SignupRequest.builder()
                .admin(false)
                .username("nat")
                .password("1234qwer")
                .email("nathan@gmail.com")
                .build();

        // when
        ResultActions resultActions = mockMvc.perform(
                MockMvcRequestBuilders.post("/api/users/signup")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new Gson().toJson(request)));

        // then
        resultActions.andExpect(status().isBadRequest());
    }


    @Test
    @DisplayName("회원가입 (실패) - 패스워드")
    void signup_failed_pw() throws Exception {
        //given
        //request, response
        SignupRequest request = SignupRequest.builder()
                .admin(false)
                .username("pororo")
                .password("123")
                .email("pororo@naver.com")
                .build();

        ResponseStatusDto response = new ResponseStatusDto(StatusEnum.SIGNUP_SUCCESS);
        lenient().when(userService.signup(request))
                .thenReturn(response);

        //when
        ResultActions resultActions = mockMvc.perform(
                MockMvcRequestBuilders.post("/api/users/signup")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(new Gson().toJson(request)));

        //then
        resultActions.andExpect(status().isBadRequest());


    }

}
  • JUnit의 생명 주기
💡 생명주기와 관련되어 테스트 순서에 관여하게 되는 어노테이션은 다음과 같습니다.

- @BeforeAll : 테스트를 시작하기 전에 호출되는 메서드
- @BeforeEach : 각 테스트 메서드가 실행되기 전에 동작하는 메서드
- @AfterAll : 테스트를 종료하면서 호출되는 메서드
- @AfterEach : 각 테스트 메서드가 종료되면서 호출되는 메서드

mockMvc 객체 생성 및 초기화 메서드 생성

   @BeforeEach // 각 메서드 실행 전에 먼저 실행하여 mockMvc 초기화 시킴
    public void init() { //초기화 
        mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
    }
  • 테스트 대상의 컨트롤러를 지정해 MockMvc 생성
    : 필요에 따라 standaloneMockMvcBuilder() 호출하고 스프링 테스트의 설정을 커스텀

perform()

: MockMvc가 제공하는 메서드로, 브라우저에서 서버에 URL 요청을 하듯 컨트롤러를 실행시킬 수 있다.
: perform() 메소드는 RequestBuilder 객체를 인자로 받고, 이는 MockMvcRequestBuilders의 정적 메소드를 이용해서 생성한다.

MvckMvcRequestBuilders

· MvckMvcRequestBuilders의 메소드들은 GET, POST, PUT, DELETE 요청 방식과 매핑되는 get(), post(), put(), delete() 메소드를 제공한다.

  • 이 메소드들은 MockHttpServletRequestBuilder 객체를 리턴하고, 이를 통해 HTTP 요청 관련 정보(파라미터, 헤더, 쿠키 등)를 설정할 수 있다.
  • MockHttpServletRequestBuilder의 메소드는 MockHttpServletRequestBuilder 객체를 다시 리턴하여 메시지 체인을 구성하여 복잡한 요청을 설정할 수도 있다.

  • contentType()
    : 요청 헤더의 content type 설정

  • content()
    : 요청 바디의 content 설정

  • andExpect()
    : perform() 메소드를 이용하여 요청을 전송하면, 그 결과로 ResultActions 객체를 리턴하는데 이 객체는 응답 결과를 검증할 수 있는 andExpect()isBadRequest() 메소드를 제공한다.
    : andExpect()가 요구하는 ResultMatcher는 MockMvcResultMatchers에 정의된 정적 메소드를 통해 생성할 수 있다.
    :MockMvcResultMatcher 객체는 컨트롤러가 어떤 결과를 전송했는지, 서버의 응답 결과를 검증한다.

MockMvcResultMatcher 객체가 제공하는 메소드

응답 상태 코드 검증

  • andExpect()
    perform()으로 발생한 요청에 대한 응답 어떤 기대값을 가져야하는지 지정한다.

var response = mockMvc.perform(get("/address") .param("parma_1", "0")) .andExpect(status().isOk()) .andExpect(content().string("Need reponse text")) .andDo(print()) .andReturn();

위 샘플 코드는 Http Response Code가 200인지, 응답 content에 “Need reponse text” 문자열이 있는지 검증한다. 또한 andExpect(jsonPath(“$.field”).value(“success”))처럼 응답 JSON의 특정 경로에 원하는 값이 있는지 확인 가능하다.

  • andDo(), andReturn()
    MockMVC.perform()으로 리턴되는 ResultAction 인터페이스에 대한 처리를 지정한다. 보통 andDo(print())를 많이 쓰는데, 많이 쓰는 만큼 @Before와 setUp() 함수에서 미리 지정해놓으면 편하다. andReturn()은 응답 객체를 그대로 재사용할 수 있도록 해준다.

요청 데이터 설정

  • MockHttpServletRequestBuilder
  • MockMultipartHttpServletRequestBuilderd의 팩토리 메서드

MockHttpServletRequestBuilder 주요 메서드

  • param / params
    • 요청 파라미터 설정
  • header / headers
    • 요청 해더 설정
    • contentType & accept와 같은 특정 해더를 설정하는 메서드도 제공
  • cookie
    • 쿠키 설정
  • content
    • 요청 본문 설정
  • requestAttr
    • 요청 스코프에 객체를 설정
  • flashAttr
    • 플래시 스코프에 객체를 설정
  • sessionAttr
    • 세션 스코프에 객체를 설정

MockMultipartHttpServletRequestBuilderd 주요 메서드

  • file
    • 업로드할 파일 지정
<@Test
public void testBooks() throws Exception {
  mockMvc.perform(get("books"))
    .param("name", "Spring")
    .accept(MediaType.APPLICATION_JSON)
    .header("X-Track-Id", UUID.randomUUID().toString())
    .andExpect(status().isOk());
}

실행 결과 검증

  • ResultActions의 andExpect()
    • 인수에 실행결과를 검증하는 ResultMatcher 지정 (MockMvcResultMatchers에서 제공)

MockMvcResultMatchers 주요 메서드

  • status
    • HTTP 상태 코드 검증
  • header
    • 응답 해더의 상태 검증
  • cookie
    • 쿠키 상태 검증
  • content
    • 응답 본문 내용 검증
    • jsonPath나 xpath와 같은 특정 콘텐츠를 위한 메서드도 제공
  • view
    • 컨트롤러가 반환한 뷰 이름 검증
  • forwardedUrl
    • 이동대상의 경로를 검증
    • 패턴으로 검증할떄는 forwardedUrlPattern 메서드를 사용
  • redirectedUrl
    • 리다이렉트 대상의 경로 또는 URL 검증
    • 패턴으로 검증할때는 redirectedUrlPattern 메서드를 사용
  • model
    • 스프링 MVC 모델 상태 검증
  • flash
    • 플래시 스코프의 상태 검증
  • request
    • 서블릿 3.0부터 지원되는 비동기 처리의 상태나 요청 스코프의 상태, 세션 스코프의 상태 검증

주의

  • MockMvc에서 뷰나 HttpMessageConverter가 생성한 응답 본문을 검증할 수 있음
  • JSP를 뷰로 사용할 때는 응답 분문이 언제나 비어 있어 그 결과를 검증할 수 없음

실행결과검증 구현

@Test
public void testBooks() throws Exception {
  mockMvc.perform(get("books"))
          .andExpect(status().isOk())
          .andExpect(content().string("[{\"bookId\":\"1234\",\"name\":\"슬랙으로 협업하기\"}]"));
}

실행 결과 출력

  • ResultActions의 andDo()
    • 인수에 실행 결과를 처리할 수 있는 ResultHandler 지정
    • 스프링 테스트는 MockMvc ResultHandler의 팩토리 클래스를 통해 다양한 ResultHandler 제공

MockMvcResultHandlers

  • log
    • 실행결과를 디버딩 레벨에서 로그로 출력
    • org.springframework.test.web.servlet.result
  • print
    • 실행결과를 임의의 출력대상에 출력
    • 출력대상을 지정하지 않으면 기본으로 System.out 출력
@Test
public void testBooks() throws Exception {
   mockMvc.perform(get("books"))
           .andExpect(status().isOk())
           .andDo(log());
}
ResultActions resultActions= mockMvc.perform(
                 MockMvcRequestBuilders.post("/api/users/signup")
                         .contentType(MediaType.APPLICATION_JSON) // MediaType을 APPLICATION_JSON으로 요청 받으므로 설정
                         .content(String.valueOf(request)));// .content(new Gson().toJson(request)));

toJson() – Java 객체를 JSON 형식으로 변환
fromJson() – JSON을 Java 객체로 변환

💡mockito를 이용한 테스트 중 에러 들

mockito를 이용한 테스트 중 에러 들

테스트 코드 작성 순서

  1. when -> 테스트할 로직 작성
  2. given -> 테스트에 필요한 입력값 , 리턴값 등을 작성
  3. then -> 검증

참고

profile
개발자꿈나무

0개의 댓글