이번에 작업하게 될 TEAMMATES 오픈 소스는 테스트 코드를 작성할 때 Mockito를 사용하고 있었다. 실제로 테스트 코드를 작성하기 전 Mockito를 이용한 테스트 방식이 어떤 식으로 흘러가는지, 어떻게 코드를 작성해야 하는지 먼저 알아보고 작성하기로 결심했다.
Mockito는 자바 단위 테스트에서 “협력 객체” 를 가짜로 만들어 개발자가 직접 행동을 지정(stub)하고, 호출 여부, 횟수, 인자 검증(verify)을 할 수 있게 해주는 테스트 프레임워크다. 여기서 협력 객체란 테스트 대상 클래스가 의존하는 클래스를 지칭한다. Mockito의 기초가 되는 기능들을 하나씩 살펴보자.
Mock은 "모의, 가짜" 라는 뜻을 가진 단어로, 프로그래밍에 대입하면 테스트 시 실제 객체와 동일한 "모의, 가짜" 객체를 생성해서 테스트의 효용성을 높인다.
테스트 코드에서 Mock 객체가 특정 매개 변수를 받았을 때, 특정 값을 반환하거나 예외를 던지도록 설정할 수 있다. 쉽게 말해, 테스트 중에 만들어진 호출에 대해 미리 준비된 답변을 제공한다는 것이다. 큰 틀의 사용법은 when 메서드를 통해 이루어지지만, 상세한 Stubbing 방법에는 2가지가 있다.
when(<Stubbing 할 메서드>).<OngoingStubbing 메서드>;
위의 코드처럼 when에 넣은 메서드의 반환값을 정의해주는 메서드다. 메서드 종류는 아래와 같다.
| 메소드명 | 설명 |
|---|---|
thenReturn | stubbing한 메서드 호출 후 어떤 객체를 반환할 건지 정의 |
thenThrow | stubbing한 메서드 호출 후 어떤 예외를 반환할 건지 정의 |
thenAnswer | stubbing한 메서드 호출 후 어떤 작업을 할지 커스텀하게 정의 (thenReturn, thenThrow 메서드 사용 권장) |
thenCallRealMethod | 실제 메서드 호출 |
아래 코드와 같이 Stubber는 OngoingStubbing과 다르게 when에 클래스를 넣고, 그 후에 메서드를 호출한다.
<Stubber 메서드>.when(<Stubbing 할 클래스>).<Stubbing 할 메서드>;
단지 순서만 바뀐 것처럼 보이지만, 꽤 큰 차이가 있다. Stubber의 경우, 원하는 반환값을 먼저 설정해줘야 한다. 또한, Stubber 메서드 사용 시 반환값이 void인 메서드 테스트가 가능하다. 메서드 종류는 아래와 같다.
| 메소드명 | 설명 |
|---|---|
doReturn | stubbing 메서드 호출 후 어떤 행동을 할 건지 정의 |
doThrow | stubbing 메서드 호출 후 어떤 Exception을 반환할 건지 정의 |
doAnswer | stubbing 메서드 호출 후 작업을 할지 커스텀하여 정의 |
doNothing | stubbing 메서드 호출 후 어떤 행동도 하지 않게 정의 |
doCallRealMethod | 실제 메서드 호출 |
추가적으로, 여러 개의 Stubbing 메서드를 설정해서 연속적으로 반환값을 지정해줄 수도 있다. Stubbing은 아직 구현체가 없는 인터페이스의 메서드나 단위 테스트 작성 시 유용하게 사용할 수 있다.
위에서 수행된 Stubbing 메서드가 정상적으로 수행됐는지 검증하는 기능이다. 정확히 말하자면, Mock 객체의 호출여부와 호출 횟수를 검증하는 기능이다. 값 자체에 대한 검증은 수행하지 않는다.
verify(T mock, VerificationMode mode)
위와 같은 형태로 사용되며, VerificationMode는 검증할 값을 정의하는 메서드다. 메서드 종류는 아래와 같다.
| 메소드명 | 설명 (테스트 내에서) |
|---|---|
times(n) | 몇 번 호출됐는지 검증 |
never | 한 번도 호출되지 않았는지 검증 |
atLeastOne | 최소 한 번은 호출됐는지 검증 |
atLeast(n) | 최소 n 번이 호출됐는지 검증 |
atMostOnce | 최대 한 번이 호출됐는지 검증 |
atMost(n) | 최대 n 번이 호출됐는지 검증 |
calls(n) | n번이 호출됐는지 검증 (InOrder랑 같이 사용해야 함) |
only | 해당 검증 메소드만 실행됐는지 검증 |
timeout(long mills) | n ms 이상 걸리면 Fail 그리고 바로 검증 종료 |
after(long mills) | n ms 이상 걸리는지 확인timeout과 다르게 시간이 지나도 바로 검증 종료가 되지 않는다. |
description | 실패한 경우 나올 문구 |
단위 테스트의 목적은 “내 코드만” 잘 동작하는지 빠르고 안정적으로 확인하는 것이다. 그런데 실제 코드에는 DB 커넥션, 외부 API 같은 느리고 불안정한 의존성이 끼어 있는데, 이런 걸 테스트에서 매번 붙여서 실행하면 테스트가 느려지고, 실패 원인이 뒤섞인다. 그래서 테스트에서는 의존성들을 인터페이스로 분리하고, 실제 구현 대신 가짜 데이터를 주입함으로써 내가 정해놓은 값을 즉시 반환해 주기 때문에, 테스트는 빠르고 예측 가능해진다.
실제 Mockito를 연습해보기 위해 주문을 처리하는 간단한 서비스를 가정했다.
간단하기 때문에 바로 모델과 서비스 로직을 작성하도록 했다. 먼저 주문 모델은 상품 코드, 상품 수량, 결제할 카드 번호만 가지고 있고, 그에 대한 간단한 검증 메서드를 작성해서 생성자를 통해 인스턴스를 생성할 때 검증하도록 했다.
public class Order {
private final String productCode;
private final int quantity;
private final String cardNumber;
public Order(String productCode, int quantity, String cardNumber) {
this.productCode = productCode;
this.quantity = quantity;
this.cardNumber = cardNumber;
validateProductCodeIsEmpty();
validateQuantityIsZeroOrPositive();
validateCardNumberIsEmpty();
}
public String getProductCode() {
return productCode;
}
public int getQuantity() {
return quantity;
}
public String getCardNumber() {
return cardNumber;
}
private void validateProductCodeIsEmpty() {
if (productCode == null || productCode.isBlank()) {
throw new IllegalArgumentException("상품 코드가 비어 있습니다.");
}
}
private void validateQuantityIsZeroOrPositive() {
if (quantity < 0) {
throw new IllegalArgumentException("상품 수량은 음수일 수 없습니다.");
}
}
private void validateCardNumberIsEmpty() {
if (cardNumber == null || cardNumber.isBlank()) {
throw new IllegalArgumentException("카드 번호가 비어 있습니다.");
}
}
}
그리고 서비스 로직이 담겨 있는 OrderService를 설계해야 하는데, 실제 주문이 들어왔을 때 수행될 만한 기능들을 생각해봤다. 먼저 재고를 확인하고, 주문 가능하다면 예약하는 Inventory, 주문된 상품(들)의 가격을 조회하는 Pricing, 요청된 결제를 처리하는 Payment, 고객에게 주문 처리가 완료되었다고 알려주는 Notifier 정도가 있다고 생각했다.
서비스 로직의 결합도를 낮추기 위해 해당 기능들을 인터페이스로 추상화해서 추후 서비스 로직이 구현체를 주입 받도록 설계하는 것이 낫다고 생각했다. 더 나아가 Mockito를 이용해서 진짜 객체 대신에 가짜 객체를 쉽게 끼워 넣기 위해 인터페이스로 설계했다.
먼저 Inventory는 주문을 예약하는 행동과 주문을 취소하는 행동을 가지고 있을 것이다. 주문을 예약하면, 주문한 상품의 상품 수량이 재고에서 빠지고, 주문을 취소하면 주문한 상품의 상품 수량만큼 다시 재고에 돌려놓는 것이다.
public interface Inventory {
boolean reserve(String productCode, int quantity);
void release(String productCode, int quantity);
}
주문이 요청된 상품의 가격을 조회하는 Pricing 인터페이스다. 말 그대로 상품의 가격을 조회하는 행동만 정의했다.
public interface Pricing {
int priceOf(String productCode);
}
요청된 결제를 처리하는 Payment 인터페이스다. 카드 잔액에 따라 결제를 완료할 수 있는지 없는지에 대한 행동만 정의했다.
public interface Payment {
boolean pay(String cardNumber, int amount);
}
마지막으로 처리된 주문에 대하여 주문 정보와 결제 금액을 구매자에게 알려주는 행동이 정의되어 있는 Notifier 인터페이스다.
public interface Notifier {
void sendOrderConfirmed(String orderId, String productCode, int quantity, int amount);
}
위 4개의 의존성을 바탕으로 주문 서비스(OrderService) 코드를 간단하게 구현해봤다. 각각의 기능들은 철저하게 캡슐화 함으로써, 외부에서 서비스 로직에 접근할 때는 오직 placeOrder() 메서드로만 주문 로직을 수행하도록 처리했다.
public class OrderService {
private final Inventory inventory;
private final Pricing pricing;
private final Payment payment;
private final Notifier notifier;
public OrderService(Inventory inventory, Pricing pricing, Payment payment, Notifier notifier) {
this.inventory = inventory;
this.pricing = pricing;
this.payment = payment;
this.notifier = notifier;
}
public boolean placeOrder(Order order) {
if (!tryReserve(order)) {
return false;
}
int totalAmount = calculateAmount(order);
if (!tryPay(order, totalAmount)) {
return false;
}
String orderId = IdGenerator.newId();
notifyConfirmed(orderId, order, totalAmount);
return true;
}
private boolean tryReserve(Order order) {
return inventory.reserve(order.getProductCode(), order.getQuantity());
}
private int calculateAmount(Order order) {
int productPrice = pricing.priceOf(order.getProductCode());
return productPrice * order.getQuantity();
}
private boolean tryPay(Order order, int amount) {
boolean paid = payment.pay(order.getCardNumber(), amount);
if (!paid) {
inventory.release(order.getProductCode(), order.getQuantity());
}
return paid;
}
private void notifyConfirmed(String orderId, Order order, int amount) {
notifier.sendOrderConfirmed(
orderId,
order.getProductCode(),
order.getQuantity(),
amount
);
}
}
먼저 주문이 성공적으로 이루어졌을 때의 상황을 가정해서 테스트 코드를 작성해보자. 현재 테스트 대상은 OrderService이므로 그에 해당하는 테스트 코드를 Mockito를 이용해서 뼈대를 만들어보면 아래와 같다.
import org.junit.jupiter.api.*;
import org.mockito.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.BDDMockito.*;
import static org.mockito.Mockito.*;
class OrderServiceTest {
@Mock Inventory inventory;
@Mock Pricing pricing;
@Mock Payment payment;
@Mock Notifier notifier;
@InjectMocks
OrderService service;
AutoCloseable mocks;
@BeforeEach
void setUp() {
mocks = MockitoAnnotations.openMocks(this);
}
void testOrderService() {
// 성공 시나리오 작성
}
}
먼저 만들어 놓은 4개의 의존성에 @Mock 애노테이션을 붙여주면, Mockito가 프록시 기반의 가짜 객체를 생성해서 필드에 넣어준다. 그리고 @InjectMocks 애노테이션을 테스트 대상인 OrderService에 붙여주면, 그 테스트 대상인 OrderService 객체를 만들고 이름과 타입이 맞는 가짜 객체들을 테스트 대상에 생성자로 주입해준다.
그리고 setUp() 메서드에서 MockitoAnnotations.openMocks(this)를 작성해서 위의 @Mock과 @InjectMocks 애노테이션을 실제로 적용시켜 준다.
여기서 테스트는 메서드의 실행에 따른 상태 변화를 테스트하기 위해 given when, then 3개의 구조로 이루어진 패턴으로 실행한다. 테스트는 대상의 주어진 상태(given)에서 출발해서, 테스트 대상의 행동으로 인해 상태 변화가 일어나면(when), 실행 결과로써 기대하는 상태로 완료되어야(then)한다.
@Test
void shouldPlaceOrder_andNotify_whenInventoryReserved_andPaymentSucceeds() {
// given
Order order = new Order("Rockernun", 5, "1234-5678");
given(inventory.reserve("Rockernun", 5)).willReturn(true);
given(pricing.priceOf("Rockernun")).willReturn(500);
given(payment.pay("1234-5678", 2500)).willReturn(true);
try (var mocked = mockStatic(IdGenerator.class)) {
mocked.when(IdGenerator::newId).thenReturn("OrderId-1");
// when
boolean ok = orderService.placeOrder(order);
// then
assertTrue(ok);
InOrder inOrder = inOrder(inventory, payment, notifier);
inOrder.verify(inventory).reserve("Rockernun", 5);
inOrder.verify(payment).pay("1234-5678", 2500);
inOrder.verify(notifier).sendOrderConfirmed("OrderId-1", "Rockernun", 5, 2500);
then(inventory).should(never()).release(anyString(), anyInt());
}
}
먼저 Order 스텁으로 상품 코드가 “Rockernun” 인 상품 5개를 카드 번호가 “1234-5678” 인 카드로 주문한다고 상황을 정의했다. 그리고 각각의 의존성에 원하는 인자를 설정해서 요청하면 true를 반환시키도록 한 것이다.
given(inventory.reserve("Rockernun", 5)).willReturn(true);
given(pricing.priceOf("Rockernun")).willReturn(500);
given(payment.pay("1234-5678", 2500)).willReturn(true);
위에 해당하는 부분은 Mockito의 BDD 스타일 스텁으로 가짜 의존성(mock)이 어떤 입력을 받으면 무엇을 돌려줄지를 미리 정하는 코드다. 즉, 실제 코드를 실행하는 것이 아니라 해당 입력을 보내면 특정 값을 반환 해달라는 시뮬레이션이라고 생각하면 편하다.
하나만 예를 들어보자면,
given(inventory.reserve("Rockernun", 5)).willReturn(true);
위 코드는 가짜 상품 보관소에 reserve("Rockernun", 5)가 호출되면 true를 반환해 달라고 말하고 있는 것이다. 생각해보면, OrderService에 아래와 같은 코드가 있었다.
...
if (!tryReserve(order)) {
return false;
}
...
private boolean tryReserve(Order order) {
return inventory.reserve(order.getProductCode(), order.getQuantity());
}
여기서 tryReserve가 true를 반환해야 주문 상품의 총액을 계산하고, 결제를 진행하는 등 다음 단계로 넘어가도록 하기 위해 상품 예약이 성공한 상황을 강제로 만들어 버린 것이다.
그리고 나서 정적 메서드를 가짜로 바꿔서 원하는 값을 돌려주도록 아래와 같이 스텁한다.
try (var mocked = mockStatic(IdGenerator.class)) {
mocked.when(IdGenerator::newId).thenReturn("OrderId-1");
...
}
보통 Mockito는 인스턴스 메서드를 모킹하지만, Mockito 3.4 이상부터는 정적 메서드도 모킹이 가능해졌다. 정적 모킹은 전역 상태를 건드리므로, 테스트 간섭이 일어나기 쉽기 때문에 꼭 짧은 범위 안에서만 사용하는 것을 권장한다. mockStatic(IdGenerator.class) 과 같이 정적 호출을 가로채는 프록시를 만들고, mocked.when(IdGenerator::newId).thenReturn("OrderId-1")으로 반환값을 정의한다.
그 후, orderService.placeOrder(order)와 같이 검증하려는 행동을 작성한다.
boolean ok = orderService.placeOrder(order);
이 한 줄이 실제로 무엇을 하는지를 유발하고, 그 결과로 상태 변화와 상호작용이 발생한다.
그리고 추가적으로, 비즈니스 흐름을 테스트에서 안전하게 고정하기 위해서 순서를 검증할 필요할 때가 있다. 그때는 아래와 같이 InOrder로 여러 mock을 넘겨 받아서, 그들 사이의 실제 호출 순서를 기억하고, 기대하는 순서대로 호출되었는지 검증하게 해주면 된다.
InOrder inOrder = inOrder(inventory, payment, notifier);
inventory, payment, notifier 가짜 객체들에 대해 호출 기록을 순서대로 검증하는 객체를 만들었다면, 이제 실제 호출 순서를 따라 아래와 같이 검증해주면 된다.
inOrder.verify(inventory).reserve("Rockernun", 5);
inOrder.verify(payment).pay("1234-5678", 2500);
inOrder.verify(notifier).sendOrderConfirmed("OrderId-1", "Rockernun", 5, 2500);
마지막으로 특정 메서드가 전혀 호출되지 않아야 하는 부정 검증도 존재한다.
then(inventory).should(never()).release(anyString(), anyInt());
위와 같이 성공적인 결제 시나리오에서 inventory.release()와 같이 결제가 실패했을 때 실행되는 메서드의 호출이 되지 않는다는 것을 보장해야 한다. 이와 반대로, 결제가 실패하는 시나리오에서는 notifier.sendOrderConfirmed() 메서드가 호출되지 않도록 보장해야 할 것이다.
여기까지 결제가 성공적으로 진행됐을 때의 테스트 코드를 작성해봤다. 보다시피 테스트는 성공적으로 통과되었다.

성공 시나리오에서 작성했던대로 결제가 실패할 경우에 대해 테스트 코드를 작성해보자.
@Test
void shouldReleaseReservation_andNotNotify_whenPaymentFails() {
// given
Order order = new Order("Rockernun", 5, "1234-5678");
given(inventory.reserve("Rockernun", 5)).willReturn(true);
given(pricing.priceOf("Rockernun")).willReturn(500);
given(payment.pay("1234-5678", 2500)).willReturn(false);
// when
boolean ok = orderService.placeOrder(order);
// then
assertFalse(ok);
InOrder inOrder = inOrder(inventory, payment);
then(inventory).should(inOrder, times(1)).reserve("Rockernun", 5);
then(payment).should(inOrder, times(1)).pay("1234-5678", 2500);
then(inventory).should(inOrder, times(1)).release("Rockernun", 5);
then(notifier).should(never()).sendOrderConfirmed(anyString(), anyString(), anyInt(), anyInt());
verifyNoMoreInteractions(inventory, payment, notifier);
}
결제가 실패하는 시나리오기 때문에 일단 상품 주문을 예약하는 것까지는 성공적으로 진행되어야 한다. 결제를 수행하기 위해 pay() 메서드를 호출하게 되면 결과값으로 아래와 같이 false를 반환하도록 했다.
given(payment.pay("1234-5678", 2500)).willReturn(false);
그리고 가짜 객체들에 대해 호출 기록을 순서대로 검증하기 위해 아래와 같이 작성했다.
InOrder inOrder = inOrder(inventory, payment);
then(inventory).should(inOrder, times(1)).reserve("Rockernun", 5);
then(payment).should(inOrder, times(1)).pay("1234-5678", 2500);
then(inventory).should(inOrder, times(1)).release("Rockernun", 5);
then(notifier).should(never()).sendOrderConfirmed(anyString(), anyString(), anyInt(), anyInt());
여기서 각 객체들을 호출할 때 정확히 딱 한 번씩만 호출되었는지 엄격히 검증하기 위해 times()를 붙여줘도 좋다. 코드를 보면, 상품 주문을 예약하는 행동이 1번 호출되고, 결제를 시도하는 행동이 뒤이어 1번 호출된다. 하지만 여기서 결제가 실패하기 때문에 주문한 상품의 재고를 그대로 되돌려 놓는 행동이 1번 호출되는지 검증했다. 그리고 마지막으로 결제가 실패했기 때문에 절대 고객에게 주문 정보 알림이 가지 않는 것을 보장하기 위해 검증을 추가했다.
끝으로 위에서 검증한 호출들 이외의 추가 호출은 전혀 없어야 한다는 것을 보장하기 위해 아래와 같이 작성해주면 더욱 단단한 테스트 코드가 될 것이다.
verifyNoMoreInteractions(inventory, payment, notifier);
이렇게 작성한 테스트 코드는 성공적으로 통과된 것을 확인할 수 있었다.

Mockito에서 인자를 검증하고 스텁할 때는 anyXxx(), eq(), matches()와 같은 매처를 사용할 수 있다. 몇 가지만 주의하도록 하자.
첫 번째로, 한 메서드 호출의 파라미터 안에서 리터럴과 매처를 혼용하지 말도록 하자. 기존에 작성했던 테스트 코드처럼 모두 리터럴로 통일하든지, 모두 매처로 통일하도록 해야 한다.
// 리터럴로 통일
given(payment.pay("1234-5678", 2500)).willReturn(true);
// 매처로 통일
given(payment.pay(eq("1234-5678"), eq(2500))).willReturn(true);
두 번째로, 매칭 범위에 유념하자. 각 매처들은 범위가 각기 다르다. 아래는 자주 사용되는 매처의 매칭 범위다.
anyString(), anyInt(), any(): 타입만 맞으면 어떤 값이든 매칭된다.eq(value): 정확히 그 값만 매칭된다.isNull(), notNull(): null 있는지, 없는지에 따라 매칭된다.matches(regex): 정규식 패턴에 해당하는 경우에 매칭된다.argThat(predicate): 임의의 조건식에 해당하는 경우에 매칭된다.
예를 들어, 결제 금액이 0원보다 큰 금액이어야 결제 성공으로 취급하는 스텁을 작성하고 싶다면 아래와 같이 작성하면 된다.
given(payment.pay(eq("1234-5678"), argThat(amount -> amount > 0))).willReturn(true);
위와 같은 매처의 규칙은 검증에서도 동일하게 적용된다. 다만 매칭 범위에서 anyString()과 같이 범위가 너무 광범위하면, 원치 않는 호출까지 통과시키기 때문에 가능하면 범위를 줄일 수 있을 만큼 줄이는 것이 좋다.
<참고 자료>
[Test] Mockito 사용법
Mockito란, Stubbing/Verification, Mock 객체 생성 방법과 동작 원리
Mockito 프레임 워크
Mockito를 활용하여 테스트 코드 작성하기