단위 테스트는 소프트웨어 개발 과정에서 필수적인 부분입니다. 그러나, 외부 시스템이나 클래스 의존성 때문에 단위 테스트를 작성하고 실행하는 것이 어려울 때가 있습니다. 이런 문제를 해결하기 위해 Mockito 라는 강력한 모킹 프레임워크가 자주 사용됩니다. 이 글에서는 Mockito 기초부터 시작하여, 실제 예시를 통한 활용 방법을 알아보겠습니다.
Mockito 란?
Mockito는 Java 기반의 모킹 프레임워크로, 단위 테스트에서 외부 의존성을 모의(Mock) 객체로 대체하여 테스트의 견고성과 독립성을 높이는데 사용됩니다. Mockito를 사용하면 실제 객체를 모의 객체로 대체하여, 외부 시스템과의 상호작용을 시뮬레이션할 수 있습니다.
Mockito 기본 사용법
- Mock 객체 생성 : "mock()" 메소드를 사용하여 클래스의 모의 객체를 생성합니다 ( 가짜 객체 라고도 합니다. )
- 메소드의 동작 정의 : "when()" 과 "thenReturn()" 을 사용하여 모의 객체의 특정 메소드가 호출될 때의 동작을 정의합니다.
- any() : 어떠한 객체도 받아들일 수 있습니다. 주로 객체 타입의 인자에 대해 넓은 범위를 적용할 때 사용됩니다.
- anyString() : 어떠한 문자도 받아들일 수 있습니다. 문자열 타입의 인자에 대해 넓은 범위를 적용할 때 사용됩니다.
- 타입별 매처들 : anyInt(), anyDouble(), anyBoolean() 등의 매처들은 특정 타입의 어떤한 값도 받아 들일수 있습니다.
- Mock 객체의 사용 : 테스트 중에 모의 객체를 실제 객체처럼 사용합니다.
- 결과검증 : "verify()" 메소드를 사용하여 모의 객체의 특정 메소드가 특정 조건으로 호출되었는지 검증합니다.
- 예외던지기 : 특정 조건에 대해서 테스트 대상 객체가 예외를 던지도록 설정할 수 있습니다. 이는 메소드가 예외 상화을 적절히 처리하는지 검증하는데 유용합니다.
- when(myclass.someMethod(anyString()).thenThrow(new RuntimeException("exception"));
any() 등 매처의 예시
List 인터페이스의 특정 메소드를 모킹하고자 할 때, 다음과 같이 만들 수 있습니다.
List<String> mockedList = mock(List.class);
when(mockedList.get(anyInt())).thenReturn("element");
위 코드에서 mockList.get(anyInt()) 는 get 메소드가 어떤 정수 인자와 함께 호출 될때 마다 element를 반환 하도록 설정합니다.
하지만, 더 구체적으로 타입을 명시할 수도 있습니다. 예를 들어 List 타입의 인자에 대해서만 모킹 하고 싶다면 any(List.class) 또는 any(ArrayList.class) 같이 작성할 수 있습니다.
when(myMock.someMethod(any(List.class))).thenReturn("response");
public class MyClassTest {
@Test
void 기본_사용법() {
// given (준비)
MyClass myClass = mock(MyClass.class);
// when을 사용하여 someMethod(10)이 호출될 때 반환값을 20으로 설정
when(myClass.someMethod(10)).thenReturn(20);
// when (실행)
// 실제로 someMethod를 10의 인자로 호출
myClass.someMethod(10);
// then (단언 assert)
// verify를 사용하여 someMethod가 10으로 호출되었는지 검증
verify(myClass).someMethod(10);
assertThat(myClass.someMethod(10)).isEqualTo(20);
}
}
public class MyClass {
public int someMethod(int value) {
return value + 10;
}
}
Spy 생성 및 사용 예시
Mockito의 스파이(spy) 기능은 실제 객체의 일부 동작을 모의 하면서 나머지는 실제 객체의 동작을 그대로 유지하는데 사용됩니다.
이를 통해 실제 동작을 부분적으로 변경하거나 추적이 용이 합니다.
예를들어, 우리는 ArrayList 클래스의 일부 동작을 모의 하고 싶다고 가정하고, ArrayList의 add 메소드를 호출할 때마다 메시지를 출력하고 싶다고 합시다. 여기서 spy를 사용할 수 있습니다.
class MathApplicationTest {
@Test
public void arrayListSpyExample() {
List<String> list = new ArrayList<>();
List<String> spyList = spy(list);
// add 메소드를 호출할 때마다 메시지 출력
doAnswer(invocation -> {
System.out.println("Element " + invocation.getArgument(0) + " added");
return invocation.callRealMethod();
}).when(spyList).add(anyString());
spyList.add("Hello");
spyList.add("World");
// 실제 메소드 동작 확인
assertEquals(2, spyList.size());
assertEquals("Hello", spyList.get(0));
assertEquals("World", spyList.get(1));
}
}
실제 예시 : 계산기 서비스 테스트
아래는 CalculatorService 인터페이스를 사용하는 MathApplication 클래스를 테스트 하는 예시입니다.
public interface CalculatorService {
double add(double input1, double input2);
double subtract(double input1, double input2);
double multiply(double input1, double input2);
}
@RequiredArgsConstructor
public class MathApplication {
private final CalculatorService calcService;
public double add(double input1, double input2){
return calcService.add(input1, input2);
}
public double subtract(double number1, double number2) {
return calcService.subtract(number1, number2);
}
public double multiply(double number1, double number2) {
return calcService.multiply(number1, number2);
}
}
class MathApplicationTest {
@Test
void 테스트() {
// given (준비)
CalculatorService calculatorService = mock(CalculatorService.class);
when(calculatorService.add(10, 20)).thenReturn(30.0);
// when (실행)
MathApplication mathApplication = new MathApplication(calculatorService);
double result = mathApplication.add(10, 20);
// then (단언 assert)
verify(calculatorService).add(10, 20);
assertThat(result).isEqualTo(30.0);
}
@Test
void 테스트_any() {
// given (준비)
CalculatorService calculatorService = mock(CalculatorService.class);
when(calculatorService.add(anyDouble(),anyDouble())).thenReturn(30.0);
// when (실행)
MathApplication mathApplication = new MathApplication(calculatorService);
double result = mathApplication.add(10, 20);
// then (단언 assert)
assertThat(result).isEqualTo(30.0);
}
@Test
void 테스트_예외_던지기() {
// given (준비)
CalculatorService calculatorService = mock(CalculatorService.class);
when(calculatorService.add(anyDouble(),anyDouble())).thenThrow(new RuntimeException("예외 발생"));
MathApplication mathApplication = new MathApplication(calculatorService);
// when (실행) // then (단언 assert)
assertThatThrownBy(() -> {
mathApplication.add(10, 20);})
.isInstanceOf(RuntimeException.class).hasMessage("예외 발생");
}
}
실제 예시 : 사용자 조회
Springboot + jpa 를 사용하여 유저의 정보를 조회하고 싶을 경우를 예시로 구성해 보겠습니다.
아래는 유저를 조회하고 등록하는 코드 입니다. 해당 클래스의 getById를 테스트 하고 싶을때 UserRepository를 모의 해야 합니다.
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public User getById(long id) {
return userRepository.findById(id).orElseThrow(() -> new UserNotFoundException(id));
}
@Override
public User getUser(String email) {
return userRepository.findByUser(email).orElseThrow(() -> new UserNotFoundException(email));
}
@Override
public List<User> getUsers() {
return userRepository.findUsers().orElseThrow(() -> new UserDataEmptyException());
}
@Override
public User save(UserCreate userCreate) {
User user = User.create(userCreate, passwordEncoder);
return userRepository.save(user);
}
@Override
public User update(Long id, UserUpdate userUpdate) {
User user = getById(id);
user.update(userUpdate);
return userRepository.save(user);
}
}
모의 하는 방법은 Mockito를 사용하여 mock 하는 방법과, 어노테이션으로 사용하는 방법이 있습니다.
먼저 mock 하는 방법을 알아보겠습니다.
class UserServiceImplTest {
@Test
void 유저의_아이디로_유저_정보를_가져온다() {
// given (준비)
UserRepository userRepository = mock(UserRepository.class);
User thenReturnUser = User.builder()
.id(1l)
.email("test@kakao.com")
.nickname("test")
.build();
when(userRepository.findById(1L)).thenReturn(Optional.ofNullable(thenReturnUser));
// when (실행)
UserServiceImpl userService = new UserServiceImpl(userRepository, new BCryptPasswordEncoder());
User result = userService.getById(1l);
// then (단언 assert)
verify(userRepository).findById(1L);
Assertions.assertThat(result.getNickname()).isEqualTo(thenReturnUser.getNickname());
}
}
두번째로 @Mock 어노테이션으로 모의 해보겠습니다.
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@Mock
private UserRepository userRepository;
@Test
void 유저의_아이디로_유저_정보를_가져온다_Mock_어노테이션_사용() {
// given (준비)
User thenReturnUser = User.builder()
.id(1l)
.email("test@kakao.com")
.nickname("test")
.build();
when(userRepository.findById(1L)).thenReturn(Optional.ofNullable(thenReturnUser));
UserServiceImpl userService = new UserServiceImpl(userRepository, new BCryptPasswordEncoder());
// when (실행)
User result = userService.getById(1l);
// then (단언 assert)
verify(userRepository).findById(1L);
Assertions.assertThat(result.getNickname()).isEqualTo(thenReturnUser.getNickname());
}
}
이때 UserServiceImpl의 생성자에 주입하여 사용했는데요, 이 부분 또한 어노테이션으로 주입을 대체할 수 있습니다.
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@InjectMocks
private UserServiceImpl userService;
@Mock
private UserRepository userRepository;
@Mock
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Test
void 유저의_아이디로_유저_정보를_가져온다_InjectionMock_어노테이션_사용() {
// given (준비)
User thenReturnUser = User.builder()
.id(1l)
.email("test@kakao.com")
.nickname("test")
.build();
when(userRepository.findById(1L)).thenReturn(Optional.ofNullable(thenReturnUser));
// when (실행)
User result = userService.getById(1l);
// then (단언 assert)
verify(userRepository).findById(1L);
Assertions.assertThat(result.getNickname()).isEqualTo(thenReturnUser.getNickname());
}
}
위 예시에서 만약 PasswordEncoder 부분을 @Spy로 사용해도 되는데요. @Spy 어노테이션은 실제 객체를 감싸는 스파이 객체를 생성하게 됩니다.
스파이 객체는 기본적으로 실제 객체의 모든 동작을 수행합니다. 또한, 필요한 경우 해당 객체안에 특정 메서드의 동작을 변경하거나 추적할 수 있습니다.
- 실제 동작 수행 : bCryptPasswordEncoder.encode() 나 다른 메서드를 호출하면 실제 암호화 로직이 실행 됩니다. 이로 인해 실제 암호화 로직의 영향을 받습니다.
- 성능 저하 가능성 : 실제 암호화 로직은 상대적으로 느릴 수 있으며 테스트 성능에 영향을 줄 수 있습니다.
- 테스트의 명확성 감소 : @Mock 을 사용할 때는 모의 객체의 동작을 명확하게 정의할 수 있지만 @Spy를 사용하면 실제 로직이 수행되므로 테스트가 더 복잡해지고, 테스트의 목적이 모호해질 수 있습니다.
- 동작변경 및 추적 : 필요한 경우 @Spy 객체의 특정 메서드 동작을 변경하여 테스트를 더 세밀하게 제어할 수 있습니다.
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@InjectMocks
private UserServiceImpl userService;
@Mock
private UserRepository userRepository;
@Spy
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Test
void 유저의_아이디로_유저_정보를_가져온다_spy_어노테이션_사용() {
// given
User thenReturnUser = User.builder()
.id(1L)
.email("test@kakao.com")
.nickname("test")
.build();
when(userRepository.findById(1L)).thenReturn(Optional.ofNullable(thenReturnUser));
// 스파이 객체의 특정 메서드 동작을 변경
String fakeEncodedPassword = "fakeEncodedPassword";
/*
doReturn(fakeEncodedPassword)
이 부분은 bCryptPasswordEncoder.encode 메서드가 호출될 때 반환할 값을 지정합니다.
여기서는 "fakeEncodedPassword"라는 문자열을 반환하도록 설정
.when(bCryptPasswordEncoder): 이 부분은 동작을 변경할 객체를 지정합니다.
여기서는 bCryptPasswordEncoder 객체의 메서드 호출에 대한 동작을 변경하고 있습니다.
.encode(anyString()): 이 부분은 변경할 메서드와 그 메서드가 받는 인자의 타입을 지정합니다.
anyString()은 Mockito의 인자 매처로, 어떤 문자열이든지 받을 수 있음을 의미합니다.
즉, encode 메서드가 어떤 문자열로 호출되든지 간에, 지정된 반환 값("fakeEncodedPassword")을 반환
*/
doReturn(fakeEncodedPassword).when(bCryptPasswordEncoder).encode(anyString());
// when
User result = userService.getById(1L);
// then
verify(userRepository).findById(1L);
Assertions.assertThat(result.getNickname()).isEqualTo(thenReturnUser.getNickname());
// 추가적인 검증
String testPassword = "password";
String encodedPassword = bCryptPasswordEncoder.encode(testPassword);
Assertions.assertThat(encodedPassword).isEqualTo(fakeEncodedPassword);
}
}
'테스트' 카테고리의 다른 글
테스트 대역 5가지 (0) | 2024.07.17 |
---|---|
테스트 (0) | 2024.07.16 |
번거로운 동작을 스텁(stub)으로 대체 (0) | 2024.01.24 |
번거로운 동작을 스텁(stub)으로 대체 (0) | 2024.01.19 |
JUnit 기본 (2) | 2024.01.03 |