Mockito와 JUnit

5tr1ker·2023년 3월 10일
0
post-thumbnail

Mockito란?

Mockito는 개발자가 동작을 직접 제어할수 있는 가짜 ( Mock ) 객체를 지원하는 테스트 프레임워크입니다. 일반적으로 Spring으로 웹 애플리케이션을 개발하면, 여러 객체들 간에 의존성이 생깁니다. 이러한 의존성 때문에 단위 테스트를 작성하기가 어려워지는데, 이를 해결하기 위해 가짜 객체를 주입시켜주는 Mockito 라이브러리를 활용할 수 있습니다. Mockito를 활용하면 가짜 객체에 원하는 결과를 Stub하여 단위 테스트를 진행할 수 있습니다.

예를 들어 컨트롤러 단위 테스트를 진행하기 위해서 서비스 빈을 불러오지 않고, 해당 서비스를 호출했을 때 어떤 값을 반환하게 설계할 수 있습니다.

단위 테스트 및 Stub 에 대한 자세한 설명은 해당 링크를 참고해주세요.

Mockito 기본 사용법

Mock 객체 의존성 주입

Mockito에서 Mock 객체의 의존성 주입을 위해 크게 3가지 어노테이션이 사용됩니다.

  • @Mock : Mock 객체를 만들어 반환합니다.
  • @Spy : Stub 하지 않은 메서드들은 원본 메서드 그대로 사용합니다.
  • @InjectMocks : @Mock 또는 @Spy 로 생성된 가짜 객체를 자동으로 주입시켜 줍니다.

예시로 Controller에 대한 단위 테스트를 할 때 Service 에 의존성이 있다면 @Mock 어노테이션을 통해 가짜 Service를 만들고 , @InjectMocks 를 이용해 Controller 에게 주입합니다.

@Spy 같은 경우는 가짜 객체가 아니라 실제로 동작해야 하는 메서드에 사용합니다. 예를 들면 JWT 토큰 생성하는 메서드는 실제로 동작해야 하기 때문에 @Spy 로 설정합니다.

Stub로 결과 처리

의존성이 있는 객체는 가짜 객체를 주입하여 어떤 결과를 반환하라고 정해진 답변을 준비해야 하는데 Mockito 에서는 다음과 같은 Stub 메서드를 제공합니다.

  • doReturn() : Mock 객체가 특정한 값을 반환해야 하는 경우
  • doNothing() : Mock 객체가 아무 것도 반환하지 않은 경우 ( void와 동일 )
  • doThrow() : Mock 객체가 예외를 발생시키는 경우

만약 Mock 객체를 생성 후 Stub를 설정하지 않았다면 null값이나 0 이 반환됩니다.

Mockito와 JUnit 같이 사용하기

Mockito 와 JUnit를 같이 사용하기 위해 별도의 작업이 필요한데 JUnit4 에서 Mockito를 활용하기 위해서 클래스 어노테이션으로 @RunWith(MockitoJUnitRunner.class) 를 선언해주어야 하는데, SpringBoot 2.2.0 버전 부터는 공식적으로 JUnit5를 지원하기 때문에 @ExtendWith(MockitoExtension.class) 를 선언해 주셔야합니다.

활용 예제

API 코드

@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @PostMapping("/users/signUp")
    public ResponseEntity<UserResponse> signUp(@RequestBody SignUpRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(userService.signUp(request));
    }

    @GetMapping("/users")
    public ResponseEntity<List<UserResponse>> findAll() {
        return ResponseEntity.ok(userService.findAll());
    }
}

테스트 코드

// Mockito를 사용하기 위한 필수 인터페이스
@ExtendWith(MockitoExtension.class) 
class UserControllerTest {

	// UserService의 가짜 객체를 주입
    @InjectMocks 
    private UserController userController;

	// 가짜 객체 생성
    @Mock 
    private UserService userService;
    
    // Spy 사용 예시
    @Spy
    private BCryptPasswordEncoder passwordEncoder;
    
    // 컨트롤러 테스트를 하기위해 HTTP 호출을 위한 객체입니다.
    private MockMvc mockMvc;
    
	@BeforeEach
    public void init() {
        mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
    }
    
    @DisplayName("회원 가입 성공")
	@Test
	void signUpSuccess() throws Exception {
    	// given
    	SignUpRequest request = signUpRequest();
    	UserResponse response = userResponse();
    
    	doReturn(response).when(userService)
        	.signUp(any(SignUpRequest.class));
            
        // when
    	ResultActions resultActions = mockMvc.perform(
        	MockMvcRequestBuilders.post("/users/signUp")
                .contentType(MediaType.APPLICATION_JSON)
                .content(new Gson().toJson(request))
        );
        
        // then
    	MvcResult mvcResult = resultActions.andExpect(status().isOk())
    	    .andExpect(jsonPath("email", response.getEmail()).exists())
    	    .andExpect(jsonPath("pw", response.getPw()).exists())
    	    .andExpect(jsonPath("role", response.getRole()).exists())
		}
	}
    
    private SignUpRequest signUpRequest() {
	    return SignUpRequest.builder()
	       .email("test@test.test")
	       .pw("test")
	       .build();
	}

	private UserResponse userResponse() {
   		return UserResponse.builder()
		  .email("test@test.test")
	      .pw("test")
	   	  .role(UserRole.ROLE_USER)
	   	  .build();
	}
}

MockMVC에 대한 설명은 해당 링크를 참조해 주세요.

코드 리뷰

@Spy
    private BCryptPasswordEncoder passwordEncoder;

Spy는 Mock하지 않은 메서드는 실제 메서드로 동작해야 하는데, 위의 코드는 실제로 사용자 비밀번호를 암호화해야 하기 때문에 @Spy로 사용합니다.

// 예제 1번
doReturn(response).when(userService)
        	.signUp(any(SignUpRequest.class));

userService의 signUp 메서드를 호출했을 경우 , 미리 생성해 놓은 response를 반환합니다.

HTTP 요청을 보내면 Spring은 내부에서 MessageConverter를 이용해 객체를 Json String으로 변환합니다. 하지만 우리는 API로 전달되는 파라미터인 request 객체 ( SignUpRequest ) 를 조작할 수 없습니다. 그래서 전달되는 인자가 SignUpRequest 클래스 타입이라면 어떤 객체라도 처리할 수 있게 any() 를 사용합니다. any()를 사용할 때 인자로 특정 클래스의 타입을 지정해 주는 것이 좋습니다. 자세한 내용은 하단의 Argument matchers 에서 다룹니다.

doReturn(response).when(userService)
        	.signUp(1L);

만약 userService의 signUp 메서드에 인자 1 을 주었을 때 테스트를 하고 싶다면 다음과 같이 작성할 수 있습니다.

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

when 단계에서 mockMVC에 데이터와 함께 POST 요청을 전송하고 , 그 결과값인 ResultActions 타입의 변수로 받습니다. 다만 위의 설명처럼 객체를 직접 Json String으로 변환해야 하기에 Gson 을 이용해 변환해줍니다.

// then
MvcResult mvcResult = resultActions.andExpect(status().isOk())
	.andExpect(jsonPath("email", response.getEmail()).exists())
	.andExpect(jsonPath("pw", response.getPw()).exists())
	.andExpect(jsonPath("role", response.getRole()).exists())

then 단계에서 API 호출 결과가 200 Response 인지 검증을 하며 또한 jsonPath 을 이용해 해당 json 값이 존재하는지 확인할 수 있습니다.

@WebMvcTest

위와 같이 MockMvc를 생성하는 작업은 번거롭습니다. 때문에 Spring Boot는 컨트롤러 테스트를 위해 @WebMvcTest 어노테이션을 제공하고 있으며 , 이를 사용하면 MockMvc 객체가 자동으로 생성되며 ControllerAdvice나 Filter , Intercaptor등 웹 계층 테스트에 필요한 모든 빈을 등록해 스프링 컨텍스트 환경을 구성합니다. 또한 @WebMvcTest를 사용하기 위해선 @Mock , @Spy 대신에 @MockBean 과 @SpyBean을 사용해 주셔야합니다.

@WebMVcTest(UserController.class)
class UserControllerTest {

    @MockBean
    private UserService userService;

    @Autowired
    private MockMvc mockMvc;

    // 테스트 작성

}

다만 스프링은 내부적으로 스프링 컨텍스트를 캐싱하고 동일한 테스트 환경이라면 재사용합니다. 하지만 특정 컨트롤러만을 빈으로 만들고 @MockBean 과 @SpyBean으로 빈을 모킹하는 @WebMvcTest는 캐싱 효과를 거의 얻지 못하고 새로운 컨텍스트를 생성합니다. 따라서 빠른 테스트를 원한다면 처음 사용했던 MockMvc를 사용하는 방법이 좋습니다.

Argument matchers

Mocking할 메서드가 내부에서 어떤 인수로 실행될 지 모를 때 예시로 DB의 AutoIncrement 값이 몇인지 모르지만 Stub를 해야할 때에 any를 사용할 수 있습니다.

when(studyRepository.findById(any())).thenReturn(Optional.of(study));

doReturn(response).when(studyRepository)
        	.findById(any(Integer.class));

any()를 사용하면 어떤 인수로 메서드에 전달되어도 동일한 결과를 반환하며 이 외 다양한 인터페이스가 있습니다.

  • anyInt(), anyBoolean(), anyFloat(), anyString() ...
  • anySet(), anyMap(), anyIterable() ...
  • isNull(), isNotNull() ...
  • contains(), startWith(), artThat() ...

verify

@Mock
private UserRepository userRepository;

@Spy
private BCryptPasswordEncoder passwordEncoder;

//verify
verify(userRepository, times(1)).save(any(User.class));
verify(passwordEncoder, times(1)).encode(any(String.class));

테스트 코드 내에서 given - when - then 단계 외에 verify 단계를 활용할 수 있는데, verify는 Mock된 객체의 특정 메서드가 호출된 횟수를 검증할 수 있습니다. 위의 코드에서 userRepository 객체의 save 메서드와 passwordEncoder 객체의 encode 메서드가 각각 1번만 호출되었는지를 검증하기 위해 사용합니다.

그 외에 사용할 수 있는 코드는 다음이 있습니다.

메서드 호출 순서 검증 InOrder

InOrder 는 메서드 호출 순서를 검증하기 위해 사용됩니다. 처음 InOrder 객체를 생성할 때 inOrder("Mock객체명") 으로 생성한 후에 검증하고 싶은 순서에 맞게 verify를 작성하면 됩니다. 또한 위의 표에 calls() 를 사용하여 테스트할 수 있습니다.

verifyNoMoreInteractions(T mock) -> 선언한 verify 후 해당 mock를 실행하면 실패
verifyNoInteractions(T mock) -> 테스트 내에 mock를 실행하면 실패


@Test
    void testInOrderWithCalls() {
    	// 선언한 순서대로 실행되면 성공합니다.
        userService.getUser();
        userService.getUser();
        userService.getLoginErrNum();
 
        InOrder inOrder = inOrder(userService); // 이 코드 이후로 순서를 정합니다.
 
        inOrder.verify(userService, calls(2)).getUser(); // 먼저 getUser() 가 두번 실행되고
        inOrder.verify(userService).getLoginErrNum(); // getLoginErrNum() 이 실행되면 성공합니다.
    }
    
    @Test
    void testInOrderWithVerifyNoMoreInteractions() {
        userService.getUser();
        // userService.getLoginErrNum(); - 실행하면 fail
 
        InOrder inOrder = inOrder(userService);
 
        inOrder.verify(userService).getUser(); // getUser()가 먼저 실행되고
 
        verifyNoMoreInteractions(userService); //위에 verify 이후 userService를 호출하면 fail
    }
    
@Test
    void testInOrderWithVerifyNoInteractions() {
        userService.getUser();
        userService.getLoginErrNum();
        // productService.getProduct(); - 해당 코드를 주석해제하면 해당 테스트는 실패합니다.
        
        InOrder inOrder = inOrder(userService);
 
        inOrder.verify(userService).getUser(); // getUser()가 먼저 실행되고
        inOrder.verify(userService).getLoginErrNum(); // getLoginErrNum() 이 실행되면 성공!
 
        verifyNoInteractions(productService); //만약 productService를 호출하면 실패
    }

@DataJpaTest

스프링 부트는 JPA 레파지토리를 손쉽게 테스트할 수 있는 @DataJpaTest 를 제공합니다. @DataJpaTest를 사용하면 기본적으로 인메모리 데이터베이스인 H2를 기반으로 테스트용 데이터베이스를 구축하며, 테스트가 끝나면 트랜잭션 롤백을 합니다. 레파지토리 계층은 실제 DB와 통신없이 단순 모킹하는 것은 의미가 없으므로 데이터베이스와 통신하는 @DataJpaTest를 사용합니다.

@DataJpaTest
class UserRepositoryTest {
	@Autowired
    private UserRepository userRepository;
    
    @DisplayName("사용자 추가")
    @Test
    void addUser() {
        // given
        User user = user();
        
        // when
        User savedUser = userRepository.save(user);

        // then
        assertThat(savedUser.getEmail()).isEqualTo(user.getEmail());
        assertThat(savedUser.getPw()).isEqualTo(user.getPw());
        assertThat(savedUser.getRole()).isEqualTo(user.getRole());
    }
    
    @DisplayName("사용자 목록 조회")
    @Test
    void addUser() {
        // given
        userRepository.save(user());
        
        // when
        List<User> userList = userRepository.findAll();

        // then        
        assertThat(userList.size()).isEqualTo(1);
    }

    private User user() {
        return User.builder()
                .email("email")
                .pw("pw")
                .role(UserRole.ROLE_USER).build();
    }
}

BDD 스타일로 테스트하기

TDD는 테스트를 기준으로 하는 개발 방법론이라면 , BDD는 행동을 기준으로 하는 개발 방법론입니다. 크게 Given과 When , Then 3가지로 나눠서 테스트를 진행하면 되는데 BDD에 대한 자세한 설명은 해당 링크 를 참조해주세요.

Mockito는 BDD 스타일로 테스트 코드를 짤 수 있게 BDDMockito 클래스를 제공합니다. 간단하게 기존 메서드를 다음과 같이 변경해주면 됩니다.

@Test
void testVerifyTimes() {
	//given
	//when 메서드를 given으로 변경해주시면 됩니다!
    // 기존 코드 : when(userService.getUser()).thenReturn(null); 
	given(userService.getUser()).thenReturn(null);
        
	//when
	userService.getUser();
	userService.getUser();
	
	//then
	//verify 를 then으로 바꿔주세요.
    // 기존 코드 : verify(userService, times(2)).getUser();
	then(userService, times(2)).getUser();
}

참고

참고 블로그 1 : https://mangkyu.tistory.com/145
참고 블로그 2 : https://effortguy.tistory.com/144
참고 블로그 3 : 링크

profile
https://github.com/5tr1ker

0개의 댓글