1. 개요
프로젝트를 진행하면서, 코드 변경이나 기능 업데이트가 될 때마다 불안한 마음이 들었다. 매번 여러 시나리오를 테스트하기 위해 스프링부트를 실행해서 API를 호출하고, 정상동작 여부를 확인하는 것은 번거롭고 시간도 많이 걸린다.
잘 짜여진 테스트코드만 있으면 기능 변경되어도 테스트 코드에서 검증이 가능하기 때문에 안정성을 확보하면서 개발이 가능하다. 그리고 테스트 코드의 또 다른 장점은 테스트 코드만 봐도 어떤 의도로 개발되었는지 쉽게 이해할 수 있다.
테스트 프레임워크로는 JUnit5과 Mockito를 사용했다.
2. Mockito란?
Mockito는 JAVA 오픈소스 프레임워크로 단위테스트를 위해 모의 객체(Mock Object)를 생성하고 관리하는데 사용된다.
Mockito의 핵심 개념은 테스트를 할 때, 모의 객체를 생성하여 모의 객체의 행위를 정의함으로써 의존 객체로부터 독립적으로 테스트를 수행할 수 있다.
예를 들어 회원 조회 로직을 테스트 한다고 가정하자.
모의 객체를 사용하지 않는 경우
실제 DB에서 데이터를 조회하고, DB 데이터에 의존하여 테스트를 작성해야 한다.
그럼 DB 데이터가 변경될 때마다 테스트를 수정해야할 것이다.
모의 객체를 사용하는 경우
userRepository라는 모의 객체를 생성하고, getUser 메서드의 예상동작을 정의함으로써 DB 데이터에 의존하지 않고 테스트를 작성할 수 있다. 이렇게 하면, DB 데이터의 변경이 발생하더라도 독립적인 테스트가 가능하다.
3. 주요 용어
이해를 돕기 위한 코드로 User와 userService가 있다고 가정하자. getUser()는 항상 동일한 user를 반환한다.
@AllArgsConstructor
public class User {
private String name;
private int age;
private boolean active;
}
public class UserService {
public User getUser() {
return new User("realUser", 27, true);
}
}
a. Mock
- mock 객체를 생성하는 것으로 생성된 모의 객체의 예상동작을 정의할 수 있다.
@Test
void mockTest() {
UserService mockUserService = Mockito.mock(UserService.class) // mock
}
b. Stub
- 생성된 mock 객체의 예상 동작을 정의할 수 있다.
@Test
void mockTest1() {
UserService mockUserService = Mockito.mock(UserService.class);
User user = new User("mockUser", 30, false);
when(mockUserService.getUser()).thenReturn(user); // stup
User mockUser = mockUserService.getUser();
assertThat(mockUser.getName()).isEqualTo("mockUser");
}
c. Spy
- Spy로 만든 mock 객체는 원본 동작을 유지할 수도 있고, 정의할 수도 있다.
- 실행 결과를 보면 둘다 성공한다.
- Stup을 해주면 정의한 동작대로 실행.
- Stup 하지 않으면 원본 동작대로 실행.
@Test
void mockTest1() {
UserService mockUserService = Mockito.spy(UserService.class); // spy
User user = new User("mockUser", 30, false);
when(mockUserService.getUser()).thenReturn(user);
User mockUser = mockUserService.getUser();
assertThat(mockUser.getName()).isEqualTo("mockUser");
}
@Test
void mockTest2() {
UserService mockUserService = Mockito.spy(UserService.class); // spy
User realUser = mockUserService.getUser();
assertThat(realUser.getName()).isEqualTo("realUser");
}
d. Verification
- mock 객체의 메서드 호출을 확인하는 것을 의미한다.
- test1은 mock 객체의 getUser() 메서드를 한 번 호출할 때 검증한 예시이다.
- test2은 mock 객체의 getUser() 메서드를 두 번 호출할 때 검증한 예시이다.
@Test
void mockTest1() {
UserService mockUserService = Mockito.mock(UserService.class);
mockUserService.getUser();
verify(mockUserService).getUser(); // verification (1회 호출 검증)
}
@Test
void mockTest2() {
UserService mockUserService = Mockito.mock(UserService.class);
mockUserService.getUser();
mockUserService.getUser();
verify(mockUserService, times(2)).getUser(); // Verification (2회 호출 검증)
}
4. Mockito, Spring Test 관련 어노테이션
@Mock
- mock 객체를 생성할 때 사용한다.
@MockBean
- 스프링 ApplicationContext에 mock 빈을 주입할 때 사용한다.
@InjectMocks
- mock 객체를 주입받을 대상에 사용하며, 주입받을 필드에 mock 객체가 자동으로 주입된다.
@ExtendWith(MockitoExtension.class)
- JUnit5 와 mockito를 통합하기 위해 사용된다.
@WebMvcTest(Class<?>[])
- 컨트롤러 레이어를 테스트할 때 사용한다.
- 해당 컨트롤러와 관련된 빈들을 로드한다.
5. 테스트 예제
a. 의존성 추가
스프링부트를 사용한다면, spring-boot-stater-test에 mockito 관련 라이브러리가 포함되어 있다.
스프링 부트를 사용하지 않는다면 따로 추가해줘야 한다.
dependencies {
// ..
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
b. 서비스(Service) 테스트
UserService
- User 저장, UserId로 user를 조회하기 위한 비즈니스 로직에 해당하는 코드이다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public User searchUser(String userId) {
return userRepository.findById(userId);
}
public User signup(User user) {
return userRepository.save(user);
}
}
UserRepository
- 실제 db와 연결하지 않고, user 정보를 map에 저장하도록 구현했다.
- userId가 이미 있을 경우에는 RuntimeExceeption을 발생시킨다.
@Repository
public class UserRepository {
private static final ConcurrentHashMap<String, User> USER_STORE = new ConcurrentHashMap<>();
public User findById(String userId) {
return USER_STORE.get(userId);
}
public User save(User user) {
User storedUser = USER_STORE.get(user.getUserId());
if(storedUser != null) throw new RuntimeException("존재 하는 ID 입니다.");
USER_STORE.put(user.getUserId(), user);
return user;
}
}
지금부터 서비스 테스트를 작성해보자.
mock 객체 생성과 주입
- UserRepository mock 객체를 생성하고, UserService에 @InjectMocks를 붙혀줌으로서 mock객체를 주입받는다.
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@InjectMocks private UserService sut;
@Mock private UserRepository userRepository;
// ..
}
회원가입 로직 테스트
- 테스트는 given-when-then(BDD 스타일) 패턴으로 작성한다.
- willReturn(),willThrow() 등 메서드를 통해 mock 객체(userRepositoy)의 예상 동작을 미리 정의해준다.
- exception을 검사하고 싶을 때는 assertThrows와 함께 사용하면 된다.
- should() 메서드로 해당 메서드가 제대로 호출되었는지 검증한다.
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@InjectMocks private UserService sut;
@Mock private UserRepository userRepository;
@DisplayName("user 정보를 입력 하면, user 정보를 저장 한다.")
@Test
void givenUserInfo_whenSignup_thenReturnsUserInfo() {
// Given
User user = createUser();
BDDMockito.given(userRepository.save(user)).willReturn(user);
// When
sut.signup(user);
// Then
BDDMockito.then(userRepository).should().save(user);
}
@DisplayName("이미 가입된 userId 입력 하면, 예외를 발생 시킨다.")
@Test
void givenUserInfo_whenSignup_thenThrowsException() {
// Given
User user = createUser();
BDDMockito.given(userRepository.save(user)).willThrow(RuntimeException.class);
// When
RuntimeException exception = assertThrows(RuntimeException.class,
() -> sut.signup(user)
);
// Then
assertThat(exception).isInstanceOf(RuntimeException.class);
BDDMockito.then(userRepository).should().save(user);
}
@DisplayName("유저 id를 입력하면, userId에 해당 하는 user 정보를 반환 한다.")
@Test
void given_when_then() {
// Given
String userId = "test1";
User user = createUser();
BDDMockito.given(userRepository.findById(userId)).willReturn(user);
// When
sut.searchUser(userId);
// Then
BDDMockito.then(userRepository).should().findById(userId);
}
private User createUser() {
return new User(
"test1",
"홍길동",
27,
true
);
}
}
c. 컨트롤러(Controller) 테스트
UserControler
- User 저장, userId로 user를 조회하는 api이다.
@RequiredArgsConstructor
@RequestMapping("/users")
@RestController
public class UserController {
private final UserService userService;
@GetMapping("{userId}")
public ResponseEntity<ResponseDto<User>> getUser(@PathVariable("userId") String userId) {
return ResponseEntity.ok(
ResponseDto.okWithData(userService.searchUser(userId))
);
}
@PostMapping
public ResponseEntity<ResponseDto<User>> saveUser(@RequestBody User user) {
return ResponseEntity.ok(
ResponseDto.okWithData(userService.signup(user))
);
}
}
GlobalExceptionHandler
- 요청 처리과정에서 예외가 발생하면 예외를 처리하는 핸들러이다.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ResponseDto<Void>> handleGlobalException(Exception e) {
HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.body(ResponseDto.errorWithMessage(
status.value(),
e.getMessage()
));
}
}
ReponseDto
- 공통 응답 DTO이다.
@Getter
@Setter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ResponseDto<T> {
private int codee;
private T data;
private String message;
public static <T> ResponseDto<T> ok() {
return new ResponseDto<>(HttpStatus.OK.value(), null, null);
}
public static <T> ResponseDto<T> okWithData(T data) {
return new ResponseDto<>(HttpStatus.OK.value(), data, null);
}
public static <T> ResponseDto<T> errorWithMessage(int code, String message) {
return new ResponseDto<>(code, null, message);
}
}
응답 예시
해당 컨트롤러에 대한 테스트를 작성해보자.
스프링 MVC 테스트와 mock 빈 주입
- MockMvc는 컨트롤러 테스트에 사용되며, HTTP 요청을 모의할 수 있고, 응답을 검증할 수 있다.
- @MockBean을 사용하여 UserController에 mock 빈을 주입한다.
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired private MockMvc mvc;
@MockBean private UserService userService;
// ..
}
mvc 테스트
- MockMvc.perform으로 모의 요청 테스트를 정의했다.
- andExpect와 jsonPath("$.path")를 사용하면 JSON 형식의 응답 데이터에 대해 검증이 가능하다.
- jsonEncode() 메서드는 POST 요청의 바디에 JSON 형식의 객체를 문자열로 포함시키기 위해 Jackson 라이브러리의 ObjectMapper를 사용하여 객체를 문자열로 변환하는 메서드이다.
@WebMvcTest(UserController.class)
class UserControllerTest {
public static final String EXCEPTION_MSG = "존재 하는 ID 입니다.";
@Autowired private MockMvc mvc;
@MockBean private UserService userService;
@DisplayName("회원가입 요청 - 성공")
@Test
void givenUserInfo_whenSignup_thenReturns200() throws Exception {
// Given
User user = createUser();
BDDMockito.given(userService.signup(any(User.class))).willReturn(user);
// When & Then
mvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(jsonEncode(user))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").exists())
.andExpect(jsonPath("$.data.userId").value(user.getUserId()))
.andExpect(jsonPath("$.message").isEmpty());
BDDMockito.then(userService).should().signup(any(User.class));
}
@DisplayName("회원가입 요청 - 실패")
@Test
void givenUserInfo_whenSignup_thenReturns500() throws Exception {
// Given
User user = createUser();
BDDMockito.given(userService.signup(any(User.class))).willThrow(new RuntimeException(EXCEPTION_MSG));
// When & Then
mvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(jsonEncode(user))
)
.andExpect(status().is5xxServerError())
.andExpect(jsonPath("$.code").value(500))
.andExpect(jsonPath("$.data").isEmpty())
.andExpect(jsonPath("$.message").value(EXCEPTION_MSG));
BDDMockito.then(userService).should().signup(any(User.class));
}
@DisplayName("회원 정보 조회 요청")
@Test
void givenUserId_whenGetUser_thenReturns200() throws Exception {
// Given
User user = createUser();
BDDMockito.given(userService.searchUser(anyString())).willReturn(user);
// When & Then
mvc.perform(get("/users/" + user.getUserId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").exists())
.andExpect(jsonPath("$.data.userId").value(user.getUserId()))
.andExpect(jsonPath("$.message").isEmpty());
BDDMockito.then(userService).should().searchUser(anyString());
}
private User createUser() {
return new User(
"test1",
"홍길동",
27,
true
);
}
private String jsonEncode(Object object) throws JsonProcessingException {
return new ObjectMapper().writeValueAsString(object);
}
}
6. 마무리
지금까지 스프링부트에서 JUnit5 + mockito를 사용하여 단위 테스트를 작성하는 방법에 대해 알아보았다.
간단한 예제로 설명 했지만, 실제 프로젝트를 진행하면서 테스트를 작성할 때는 사용자 인증이 추가되거나, 로직적으로 더욱 복잡한 케이스들도 많을 것이다.
기본적으로 mock 객체를 사용하여 다른 레이어로부터 독립적인 테스트를 작성하는 것이 단위테스트에서 중요한 부분인 것 같다. 이것만 잘이해하고 활용할 수 있다면 큰 어려움없이 단위테스트를 작성할 수 있을 것이다.
참고자료
https://www.baeldung.com/mockito-series
'Programming > Spring' 카테고리의 다른 글
[Spring] Spring에서 비동기 @Async 이해하기 (0) | 2024.08.23 |
---|---|
[Spring] 스프링 MVC 1편(스프링 MVC 기본기) (0) | 2024.04.09 |
[Spring] 스프링 MVC 1편(MVC 구조 이해) (0) | 2024.04.02 |
[Spring] 스프링 MVC 1편(MVC 프레임워크 만들기) (4) | 2024.03.14 |
[Spring] 스프링 MVC 1편(서블릿, JSP MVC 패턴) (1) | 2024.02.27 |