JUnit과 Mockito 기반의 Spring 단위 테스트 코드 작성법

·2024년 5월 23일
0

spring

목록 보기
10/18

1. Mockito

1.1 Mockito란?

Mockito는 개발자가 동작을 직접 제어할 수 있는 가짜 객체를 지원하는 테스트 프레임워크이다. 일반적으로 Spring으로 웹 애플리케이션을 개발하면 여러 객체들 간의 의존성이 생긴다. 이러한 의존성은 단위 테스트를 작성을 어렵게 하는데, 이를 해결하기 위해 가짜 객체를 주입시켜주는 Mockito 라이브러리를 활용할 수 있다.

1.2 Mockito 사용법

Mock 객체 의존성 주입

Mockito에서 가짜 객체의 의존성을 주입을 위해서는 크게 3가지의 애노테이션이 사용된다.

  • @Mock : 가짜 객체를 만들어 반환해주는 애노테이션
  • @Spy : Stub하지 않은 메소드들은 원본 메서드 그대로 사용하는 애노테이션
  • @InjectMocks : @Mock 또는 @Spy로 생성된 가짜 객체를 자동으로 주입시켜주는 애노테이션

예를 들어 UserCotroller에 대한 단위 테스트를 작성하고자 할 때, UserService를 사용하고 있다면 @Mock를 통해 가짜 UserService를 만들고, @InjectMocks를 통해 UserCotroller에 이를 주입시킬 수 있다.

Stub로 결과 처리

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

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

Mockito와 JUnit의 결합

Mockito도 테스트 프레임워크이기 때문에 JUnit과 결합되기 위해서는 별도의 작업이 필요하다. Mockito를 활용하기 위해 클래스 애노테이션으로 JUnit4에서는 @RunWith(MockitoJUnitRunner.class), JUnit5부터는 @ExtendWith(MockitoExtension.class)를 사용해야 결합이 가능하다.

2. Spring 컨트롤러 단위 테스트 작성

테스트할 컨트롤러

@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());
    }
}

2.1 단위 테스트 작성 준비

@ExtendWith(MockitoExtension.class) // JUnit5와 Mockito 연동
class UserControllerTest {

    @InjectMocks // 가짜 객체를 주입하는 테스트 대상
    private UserController userController;

    @Mock // 가짜 객체 생성
    private UserService userService;

    // HTTP 호출을 위해 스프링에서 제공
    private MockMvc mockMvc;

    @BeforeEach // MockMvc 생성
    public void init() {
        mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
    }

}
  • JUnit5와 Mockito를 연동하기 위해 @ExtendWith(MockitoExtension.class) 애노테이션 지정
  • 가짜 객체 의존성 주입을 위한 @InjectMocks 애노테이션 지정
  • @Mock를 지정하여 가짜 객체 생성
  • HTTP 호출을 위한 MockMvc 생성

2.2 단위 테스트

회원가입 성공 테스트

@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("/api/schedules")
//                        .contentType(MediaType.MULTIPART_FORM_DATA)
//                        .content(new Gson().toJson(requestDto))
//        );
		// multipart/formdata로 받는 경우
    ResultActions resultActions = mockMvc.perform(
            MockMvcRequestBuilders.multipart("/api/schedules")
                    .part(new MockPart("title", requestDto.getTitle().getBytes(StandardCharsets.UTF_8)))
                    .part(new MockPart("content", requestDto.getContent().getBytes(StandardCharsets.UTF_8)))
                    .part(new MockPart("writer", requestDto.getWriter().getBytes(StandardCharsets.UTF_8)))
                    .part(new MockPart("password", requestDto.getPassword().getBytes(StandardCharsets.UTF_8)))
    );

    // 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() { // SignUpRequest 객체 생성
    return SignUpRequest.builder()
        .email("test@test.test")
        .pw("test")
        .build();
}

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

given

  • doReturn() : 반환할 값
  • when() : 사용할 가짜 객체
  • any() : HTTP 요청을 보내면 스프링은 내부에서 MessageConverter를 사용해 Json String을 객체로 변환한다. 그런데 이것은 스프링 내부에서 진행되므로, API로 전달되는 파라미터인 SignUpRequest를 조작할 수 없다. 그래서 SignUpRequest 타입이라면 어떠한 객체도 처리할 수 있도록 any()가 사용되었다. any()를 사용할 때는 특정 클래스의 타입을 지정해주는 것이 좋다.

when

  • mockMvc.perform() : 요청 정보 작성
  • MockMvcRequestBuilders : 요청 메서드 종류, 내용, 파라미터 등을 설정할 수 있다.
  • Gson().toJson() : 보내는 데이터는 객체가 아닌 문자열이여야 하므로 Gson을 사용해 변환한다. (google JSON 외부 라이브러리)

then

  • status().isOk() : API 호출 결과로 200 Response인지 확인한다.
  • jsonPath() : 해당 json 값이 존재하는지 검증

사용자 목록 조회 테스트

@DisplayName("사용자 목록 조회")
@Test
void getUserList() throws Exception {
    // given
    doReturn(userList()).when(userService)
        .findAll();

    // when
    ResultActions resultActions = mockMvc.perform(
        MockMvcRequestBuilders.get("/user/list") // GET 요청
    );

    // then
    MvcResult mvcResult = resultActions.andExpect(status().isOk()).andReturn();
    
    UserListResponseDTO response = new Gson()
						    .fromJson(mvcResult
											    .getResponse()
											    .getContentAsString(),
											     UserListResponseDTO.class);
    assertThat(response.getUserList().size()).isEqualTo(5);
}

private List<UserResponse> userList() {
    List<UserResponse> userList = new ArrayList<>();
    for (int i = 0; i < 5; i++) {
        userList.add(new UserResponse("test@test.test", "test", UserRole.ROLE_USER));
    }
    return userList;
}

2.3 @WebMvcTest

스프링 부트에서는 컨트롤러 테스트를 위한 @WebMvcTest 애노테이션을 제공한다. 이를 이용하면 MockMvc 객체가 자동 생성될 뿐만 아니라 ControllerAdvice나 Filter, Interceptor 등 웹 계층 테스트에 필요한 요소들을 모두 빈으로 등록해 스프링 컨텍스트 환경을 구성한다. @WebMvcTest는 스프링 부트가 제공하는 테스트 환경이므로 @MockBean과 @SpyBean을 사용해 주어야 한다.

@WebMVcTest(UserController.class)
class UserControllerTest {

    @MockBean
    private UserService userService;

    @Autowired
    private MockMvc mockMvc;

    // 테스트 작성
}

하지만 여기서 주의할 점이 있다. 스프링은 내부적으로 스프링 컨텍스트를 캐싱해두고 동일한 테스트 환경이라면 재사용한다. 그런데 특정 컨트롤러만을 빈으로 만들고 @MockBean과 @SpyBean으로 빈을 모킹하는 @WebMvcTest는 캐싱의 효과를 거의 얻지 못하고 새로운 컨텍스트의 생성을 필요로 한다. 그러므로 빠른 테스트를 원한다면 직접 MockMvc를 생성했던 처음의 방법을 사용하는 것이 좋을 수 있다.

3. Spring 서비스 계층 단위 테스트 작성

테스트할 서비스

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserServiceImpl {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder passwordEncoder;

    @Transactional
    public UserResponse signUp(final SignUpRequest request) { // 회원가입
        final User user = User.builder()
                .email(request.getEmail())
                .pw(passwordEncoder.encode(request.getPw()))
                .role(UserRole.ROLE_USER)
                .build();

        return UserResponse.of(userRepository.save(user));
    }

    public List<User> findAll() { // 사용자 목록 조회
        return userRepository.findAll().stream()
            .map(UserResponse::of)
            .collect(Collectors.toList()));
    }
}

3.1 단위 테스트 작성 준비

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @InjectMocks
    private UserService userService;

    @Mock
    private UserRepository userRepository;

    @Spy//Stub하지 않은 메서드를 실제 메서드로 동작하게 한다. 사용자 비밀번호를 암호화해야 하므로 사용
    private BCryptPasswordEncoder passwordEncoder;

}

3.2 단위 테스트

회원가입 성공 테스트

@DisplayName("회원 가입")
@Test
void signUp() {
    // given
    BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
    SignUpRequest request = signUpRequest();
    String encryptedPw = encoder.encode(request.getPw());

    doReturn(new User(request.getEmail(), encryptedPw, UserRole.ROLE_USER)).when(userRepository)
        .save(any(User.class));
        
    // when
    UserResponse user = userService.signUp(request);

    // then
    assertThat(user.getEmail()).isEqualTo(request.getEmail());
    assertThat(encoder.matches(request.getPw(), user.getPw())).isTrue();

    // verify
    verify(userRepository, times(1)).save(any(User.class));
    verify(passwordEncoder, times(1)).encode(any(String.class));
}
  • verify는 Mockito 라이브러리를 통해 만들어진 가짜 객체의 특정 메서드가 호출된 횟수를 검증할 수 있다.

사용자 목록 조회 테스트

@DisplayName("사용자 목록 조회")
@Test
void findAll() {
    // given
    doReturn(userList()).when(userRepository)
        .findAll();

    // when
    final List<UserResponse> userList = userService.findAll();

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

private List<User> userList() {
    List<User> userList = new ArrayList<>();
    for (int i = 0; i < 5; i++) {
        userList.add(new User("test@test.test", "test", UserRole.ROLE_USER));
    }
    return userList;
}

4. Spring 레포지토리 계층 단위 테스트 작성

테스트할 레포지토리

public interface UserRepository extends JpaRepository <User, Long> {}

4.1 @DataJpaTest

스프링 부트는 JPA 레포지토리를 손쉽게 테스트할 수 있는 @DataJpaTest 애노테이션을 제공한다. @DataJpaTest를 사용하면 기본적으로 인메모리 데이터베이스인 H2를 기반으로 테스트용 데이터베이스를 구축하며, 테스트가 끝나면 트랜잭션 롤백을 해준다.

4.2 단위 테스트

@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();
    }
}
  • h2 데이터베이스 의존성 추가해줘야 동작

0개의 댓글