예제 코드
https://github.com/cobiyu/MockitoSample
Test Double이 왜 필요한 지부터 시작하는 기본적인 테스트 코드부터 한 단계씩 발전시켜나가며 Mockito의 어노테이션들의 정확한 쓰임새에 대해 살펴보겠습니다. github에 케이스별로 파일별로 구분 지어 놨으니 참고 부탁드립니다.
Test Double (Mockito)
스프링과 Junit을 이용해서 테스트 코드를 작성하다 보면 테스트 환경(database, api)을 구현하는 코드까지 작성해야 하고 실제 테스트할 코드보다 환경을 구현하는 코드가 훨씬 더 복잡해지게 됩니다. 이런 문제 영역을 해결하기 위해서 테스트 더블 이라는 것이 나왔고 Java진영에서는 대표적으로 Mockito가 있습니다.
Mockito의 어노테이션
Mockito를 사용하다 보면 주로 아래 어노테이션들을 접할 수 있게 됩니다.
@Mock
@MockBean
@Spy
@SpyBean
@InjectMocks
위 어노테이션들은 이름도 비슷한데 쓰임새까지 비슷합니다.
하지만 엄연히 다른 어노테이션이고 저도 쓰다 보니 계속 헷갈리는 부분이 많아서 각각 어노테이션들의 차이점과 정확한 사용법에 대해 정리해보려 합니다.
TEST 대상 : 주문서 생성 로직
예제에서 다룰 TEST 대상은 주문서 service 클래스 중 생성 로직입니다.
OrderService의 createOrder()는 아래 절차를 따릅니다.
1. OrderRepository.findOrderList()로 기존 주문서 조회
2. 주문서가 있다면 중복으로 간주해 OrderDuplicateException 발생
3. OrderRepository.createOrder()로 주문서 생성
4. Argument로 넘어온 isNotify가 true이면 NotificationClient.notifyToMobile() 를 이용해 알림 발생
위 절차를 따른 코드는 아래와 같습니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final NotificationClient notificationClient;
public void createOrder(Boolean isNotify){
List<Order> orderList = orderRepository.findOrderList();
if(orderList.size() > 0){
throw new OrderDuplicateException();
}
orderRepository.createOrder();
if(isNotify){
notificationClient.notifyToMobile();
}
}
}
@Repository
public class OrderRepository {
public List<Order> findOrderList(){
System.out.println("real OrderRepository findOrderList");
return Collections.emptyList();
}
public void createOrder(){
System.out.println("createOrder success");
}
}
@Component
public class NotificationClient {
public void notifyToMobile(){
System.out.println("notify to mobile success");
}
}
1. Test Double을 사용하는 이유
1.1 최초 OrderService의 테스트 작성
Test Double이 없이 위 로직을 테스트하려면 아마 아래와 같은 테스트 코드를 작성하게 될것입니다.
(예제 코드 : https://github.com/cobiyu/MockitoSample/blob/master/src/test/java/com/cobi/testdouble/non_spring/BasicTests.java)
class BasicTests {
private OrderService orderService;
@Test
void createOrderTest() {
OrderRepository orderRepository = new OrderRepository();
NotificationClient notificationClient = new NotificationClient();
orderService = new OrderService(orderRepository, notificationClient);
orderService.createOrder(true);
}
}
그리고 위 TEST를 실행하기 위해서는 아래 조건들이 먼저 선행이 되어야 합니다.
- OrderRepository가 사용할 RDB connection 세팅
- RDB에 로직 테스트 조건에 맞는 데이터 세팅
- NotificationClient가 사용할 Notification Server 연결
- Notification이 성공했을 때의 데이터 롤백 처리
테스트할 간단한 로직을 위해서 세팅할 사항만 해도 너무 많습니다.
사실 로직이 간단해서 이 정도이고 여러 자원을 연결할수록 사전 작업은 많아지게 됩니다.
1.2 OrderService.createOrder() 는 DB나 noti가 어떻게 됐는지는 관심이 없다.
테스트를 실행하기 위해 위 항목들을 세팅하지만 정작 OrderService의 createOrder()에서는
위 세팅 사항들, DB에 데이터가 제대로 들어갔는지, noti가 정상적으로 갔는지는 관심사가 아닙니다.
OrderService의 createOrder()의 실제 관심사는 아래 항목들입니다.
- orderRepository.findOrderList()의 결과가 존재할 때 OrderDuplicateException가 발생하는지
- orderRepository.createOrder() 가 1번 실행됐는지
- isNotify에 따라서 notificationClient.notifyToMobile()가 실행되는지
1.3 실제 관심사들을 테스트하기 위한 상황 설정
위의 실제 관심사들에 대해 테스트하기 위해서는 다음과 같은 상황 설정이 필요합니다.
- orderRepository.findOrderList()의 결과가 존재할 때 OrderDuplicateException가 발생하는지
=> orderRepository.findOrderList가 빈 list를 return하는 상황
- orderRepository.createOrder() 가 1번 실행됐는지
=> orderRepository.createOrder가 실행될 때는 아무 동작 없이 성공했다고 가정하는 상황
- isNotify에 따라서 notificationClient.notifyToMobile()가 실행되는지
=> notificationClient.notifyToMobile()가 실행될 때는 아무 동작 없이 성공했다고 가정하는 상황
위 상황들을 DB에 미리 세팅해놓고 Notification Server에 세팅해놓고 테스트 돌릴 때마다 다시 세팅해주는 것은 생각만 해도 너무 번거로운 일입니다. 이런 문제 영역을 메소드의 실제 내부 동작은 실행되지 않고 상황 설정만 할 수 있도록 해결한 것이 Test Double입니다.
2. Java의 Test Double : Mockito
2.1 첫 번째 Refactoring : Mockito 적용
Mockito를 이용하여 Test Double이 적용되어 첫번째 Refactoring된 테스트코드는 아래와 같습니다.
(예제 코드 :https://github.com/cobiyu/MockitoSample/blob/master/src/test/java/com/cobi/testdouble/non_spring/MockitoTests.java)
- Mockito.mock()을 이용하면 테스트 코드에서 행위 조작이 가능한 형태만 같은 껍데기 mock객체를 생성할 수 있습니다.
- 기존 실제 OrderRepository와 NotificationClient를 생성해서 의존성을 해결했던것과는 달리 mock객체를 생성하여 의존성을 해결합니다.
- mock객체의 orderRepository.findOrderList()와 notificationClient.notifyToMobile()에 대한 기대 행위를 작성하여 테스트에서 원하는 상황을 설정할 수 있게 되었습니다. 이런 기대 행위를 작성하는것을 Stub이라 합니다.
위 결과를 보면 실제 OrderRepository와 NotificationClient의 로직이 아닌 stub로 작성해놓은 로직이 실행된것을 확인 할 수 있습니다.
2.2 mock객체에서 stub되지 않은 메소드
첫번째 refacotring한 코드에서 의문점이 한가지 있습니다.
Mockito.mock()은 껍데기만있는 mock객체를 만들어주고 stub을 해야지만 원하는 상황대로 실행되는데
OrderRepository의 createOrder()에 대해서는 아무 stub을 지정해주지 않았는데도 테스트는 별 무리 없이 성공했습니다.
Answers.RETURNS_DEFAULTS
(https://javadoc.io/static/org.mockito/mockito-core/3.5.10/org/mockito/Mockito.html#RETURNS_DEFAULTS 참고)
이는 Mockito의 기본전략이 Answers.RETURNS_DEFAULTS 이기 때문입니다.
Mockito는 메소드의 type별로 정의된 DEFAULT 메소드가 있습니다.
Answers.RETURNS_DEFAULTS 은 stub 되지 않은 메소드들에 대해서는 Mockito에서 메소드 타입별로 정의된 메소드들을 실행하게 됩니다.
결국 orderRepository.createOrder() 는 따로 stub 되지 않았기 때문에 orderRepository.createOrder() 타입인 void에 맞는 Default 메소드가 실행되어서 아무일 없이 진행된 것입니다.
3. @Mock
3.1 두 번째 Refactoring : @Mock 어노테이션으로 개선
Mockito.mock()을 이용하여 Test Double을 구현 할 수 있지만 Mockito는 어노테이션도 지원합니다.
(예제 코드 : https://github.com/cobiyu/MockitoSample/blob/master/src/test/java/com/cobi/testdouble/non_spring/MockAnnotationTests.java)
위 처럼 @Mock을 이용해서 Mockito.mock() 코드를 대체할 수 있습니다.
한가지 주의 할 점은 @ExtendWith(MockitoExtension.class)를 사용해야지만 테스트 시작전 어노테이션을 감지해서 mock 객체를 주입하기 때문에 꼭 함께 사용해야 합니다.
3.2 세 번째 Refactoring : @Mock, @InjectMocks의 조합
두번째 Refactoring에서는 @Mock 으로 mock 객체를 생성하여 주입했지만
테스트코드에서는 이 mock 객체들을 의존성으로 활용해 직접 OrderService를 생성하고 있습니다.
@InjectMocks
@InjectMocks 라는 어노테이션을 사용한다면 해당 클래스가 필요한 의존성과 맞는 Mock 객체들을 감지하여 해당 클래스의 객체가 만들어질때 사용하여 객체를 만들고 해당 변수에 객체를 주입하게 됩니다.
(예제 코드 : https://github.com/cobiyu/MockitoSample/blob/master/src/test/java/com/cobi/testdouble/non_spring/MockAndInjectMocksTests.java)
3.3 네 번째 Refacotring : @Spy
OrderRepository의 메소드 중 createOrder()는 stub하고 findOrderList()는 실제 기능을 그대로 사용하고 싶은 경우가 생길 수 있고 생각보다 빈번하게 발생합니다.
즉, 하나의 객체를 선택적으로 stub할 수 있도록 하는 기능이 있는데 @Spy (=Mockito.spy) 입니다.
아래 예시는 OrderRepository.createOrder()는 sutb하고 OrderRepository.findOrderList()는 그대로 기존 기능을 사용하도록 구현된 테스트입니다.
(예제 코드 : https://github.com/cobiyu/MockitoSample/blob/master/src/test/java/com/cobi/testdouble/non_spring/SpyTests.java)
- @Spy로 spy객체 주입 (Mockito.spy()로 직접 작성해도 됩니다.)
- spy객체인 orderRepository에서 createOrder()만 stub하고 findOrderList()는 기존 기능 사용하도록 상황 설정
4. SpringBootTest & Mockito
4.1 SpringBootTest 문제 영역 : @Autowired
@SpringBootTest는 SpringBoot 컨텍스트를 이용하여 테스트를 가능하도록 해주는 어노테이션입니다.
즉, @Autowired라는 강력한 어노테이션으로 컨텍스트에서 알아서 생성된 객체를 주입받아 테스트를 진행할 수 있도록 합니다.
@SpringBootTest
class BasicSpringTests {
@Autowired
private OrderService orderService;
@Test
void createOrderTest() {
orderService.createOrder(true);
}
}
하지만 이 방식은 1.1방식과 동일한 문제점을 가지고 있으며 테스트가 실행되기 위해서 해야할 일도 많습니다.
4.2 SpringBootTest 첫 번째 Refactoring : @MockBean
3.1에서 나온 @Mock 과 비슷한 @MockBean이라는 어노테이션이 있습니다.
이름이 비슷한것처럼 실제로도 비슷하게 동작합니다.
하지만 @Mockbean은 경로(org.springframework.boot.test.mock.mockito.MockBean)를 봐도 알 수 있듯이 @Mock과는 다르게 spring 영역에 있는 어노테이션이라는 것을 알 수 있습니다.
@MockBean은 스프링 컨텍스트에 mock객체를 등록하게 되고 스프링 컨텍스트에 의해 @Autowired가 동작할 때 등록된 mock객체를 사용할 수 있도록 동작합니다.
(예제 코드 : https://github.com/cobiyu/MockitoSample/blob/master/src/test/java/com/cobi/testdouble/spring/MockBeanTests.java)
@MockBean으로 @InjectMocks은 동작하지 않나요?
이제부터 @MockBean과 @Mock의 차이와 정확한 사용법에 대해 나오고 더불어 점점 헷갈려가기 시작합니다.
아래 표를 기준으로 구분해서 생각하시면 됩니다.
Mock 종류 | 의존성 주입 Target |
@Mock | @InjectMocks |
@MockBean | @SpringBootTest |
- @Mock은 @InjectMocks에 대해서만 해당 클래스안에서 정의된 객체를 찾아서 의존성을 해결합니다.
- @MockBean은 mock 객체를 스프링 컨텍스트에 등록하는 것이기 때문에 @SpringBootTest를 통해서 Autowired에 의존성이 주입되게 됩니다.
아래 예제는 위 예제에서 @InjectMocks(실패)으로 변경했을때 실패하는 예제를 따로 구현한 형태입니다.
(예제 코드 : https://github.com/cobiyu/MockitoSample/blob/master/src/test/java/com/cobi/testdouble/spring/SpyBeanFailTests.java)
4.3 SpringBootTest 두 번째 Refactoring : @SpyBean
4.2에서 나온 @MockBean에서 3.3의 Spy의 개념만 변경된것이 @SpyBean입니다.
(예제 코드 : https://github.com/cobiyu/MockitoSample/blob/master/src/test/java/com/cobi/testdouble/spring/SpyBeanTests.java)
@SpyBean 사용 시에 유의해야할 점
@MockBean에서 Spy의 개념만 변경된것이기 때문에 이해하기 쉬울 수도 있지만 꼭 유의할 점이 있습니다.
@SpyBean이 Interface일 경우에는 해당 Interface를 구현하는 실제 구현체가 꼭 스프링 컨텍스트에 등록되어 있어야 합니다.
아래 코드가 그 예제인데 실행해보면 바로 테스트 자체에서 error가 발생합니다.
public interface SampleRepository {
public String sampleMethod();
}
@RequiredArgsConstructor
@Service
public class SampleService {
private final SampleRepository sampleRepository;
public void sampleServiceMethod(){
sampleRepository.sampleMethod();
}
}
@SpringBootTest
public class SpyBeanFailTests {
@SpyBean
//@MockBean
private SampleRepository sampleRepository;
@Autowired
private SampleService sampleService;
@Test
public void failTest(){
sampleRepository.sampleMethod();
sampleService.sampleServiceMethod();
}
}
https://github.com/cobiyu/MockitoSample/blob/master/src/main/java/com/cobi/testdouble/repository/SampleRepositoryImpl.java
의 @Repository만 주석처리하면 위 예시와 같은 실패하는 테스트환경이 됩니다.
위 예제는 SampleRepository의 interface만 있는 상태에서 구현체가 없는 상태에서 @SpyBean을 사용하려하는 예시입니다.
@SpyBean은 실제 구현된 객체를 감싸는 프록시 객체 형태이기 때문에 스프링 컨텍스트에 실제 구현체가 등록되어 있어야 합니다.
하지만 위 예제는 스프링 컨텍스트에서 실제 구현체를 일단 꺼내와야하는데 등록된 구현체가 없으므로 SampleService의 의존성 또한 해결되지 않은채로 실패하게 됩니다.
하지만 위 예제를 @MockBean으로 변경하게 되면 기존 스프링 컨텍스트에 등록된 구현체를 사용하는것이 아닌 껍데기만 가진 mock 객체를 스프링 컨텍스트에 등록하는것이기 때문에 SampleService의 의존성도 mock객체로 해결되고 위 테스트는 성공하게 됩니다.
'Java' 카테고리의 다른 글
@SpringBootTest / @DataJpaTest 차이점 과 JPA 영속성 컨텍스트 (5) | 2021.09.12 |
---|---|
람다(Lambda)와 익명 클래스 :: Lambda can be replaced with method reference (0) | 2021.08.01 |
람다 캡처링 :: Variable used in lambda expression should be final or effectively final의 이유 (6) | 2021.07.14 |
@NoargsConstructor(AccessLevel.PROTECTED) 와 @Builder (5) | 2020.07.31 |
[Java8 비동기] CompletableFuture (0) | 2020.07.27 |