Mock을 마주하는 자세
Test Double
용어 정리
마틴 파울러의 MocksArentStubs 아티클과 함께 알아보는 Mock과 Stub 의 차이
Mock 과 Stub 의 차이
Mock 실제 사용하는 코드의 행동이 아닌 그 행동을 구사하는 척 하는 것 -> 행동 검증 중점
Stub 객체가 특정 행위를 수행한 이후 속성 값으로 갖고 있는 상태의 대한 변화를 검증 하는 것 -> 상태 검증 중점
예시로 살펴보기
"In both cases I'm using a test double instead of the real mail service. There is a difference in that the stub uses state verification while the mock uses behavior verification."
"Using mocks this test would look quite different."
class OrderInteractionTester...
public void testOrderSendsMailIfUnfilled() {
Order order = new Order(TALISKER, 51);
Mock warehouse = mock(Warehouse.class);
Mock mailer = mock(MailService.class);
order.setMailer((MailService) mailer.proxy());
mailer.expects(once()).method("send");
warehouse.expects(once()).method("hasInventory")
.withAnyArguments()
.will(returnValue(false));
order.fill((Warehouse) warehouse.proxy());
}
}
Mock 은 행동에 대한 결과가 어떠한 값을 반환해야 하는지를 검증하게 되며, 위 Mock 예시의 테스트 코드에서 실제 모킹된 MailService가 send() 라는 메서드를 호출이 되었는지 메서드 호출 여부에 대해 검증을 한다.
⇒ 호출 여부에 대한 검증을 한다는 것은 객체가 특정 행동을 정말 수행 했는가? 를 중점적으로 둔다는 것이다.
"사용자가 회원가입을 하면 회원가입 축하 메일을 보내는 상황을 테스트 한다면?"
AuthService 의 signUp() 라는 메서드를 호출하면 EventListner를 통해 메일을 발송하고 있다고 가정한다.
그렇다면 실제 MailService 에 createAccountUser() 라는 메서드가 이벤트에 의해 정말 호출이 되었는지 테스트를 해볼 수 있는데, 이 때 Mock 객체를 이용한 테스트를 활용하는 것이다.
Stub 은 Stub 객체의 메서드를 수행한 후, 객체가 갖고 있는 상태값이 메서드에 의해 제대로 변경 되었는지 검증하는 것이다.
"사용자가 게시글을 읽으면 조회수가 1 증가 해야 한다고 하는 상황을 테스트 한다면?"
ArticleService 의 read() 라는 메서드를 호출한 후 viewCount 가 1이 되었다 라는 코드를 작성하면, 이 테스트는 Stub 객체를 이용한 테스트인 것이다.
결론적으로 모킹해오는 객체들은 stubbing을 해주지 않는다면 기본 값들을 반환하는 정책을 따르며, 테스트하려는 대상에만 집중하고 내가 제어할 수 없는 외부 세계의 영역을 stubbing(mocking) 한다.
이토록 어렵게 느껴지는 Mock 과 Stub 의 차이는 정의의 내용이 우리를 혼란스럽게 할 뿐, 실질적으로 용어를 사용할 땐 대부분 Mock 이라는 단어로 포괄하여 사용한다.
굳이 굳이, mock 과 stub 을 엄밀히 구분해가며 소통하지 않으니 개념적으로만 이해할 요소라고 생각이 든다.
Mockito 어노테이션 사용해보기
@ExtendWith(MockitoExtension.class) // NOTE: 해당 어노테이션이 있으므로 테스트가 시작될 때 Mockito 를 활용하여 Mock 객체를 생성한다..
class MailServiceTest {
@Mock
private MailSendClient mailSendClient;
@Mock
private MailSendHistoryRepository mailSendHistoryRepository;
@DisplayName("메일 전송 테스트")
@Test
void sendMail() {
// given
MailService mailService = new MailService(mailSendClient, mailSendHistoryRepository)
// @Mock Stubbing
when(mailSendClient.sendMail(anyString(), anyString(), anyString(), anyString()))
.thenReturn(true);
// when
boolean result = mailService.sendMail("fromEmail", "toEmail", "subject", "content");
// then
assertThat(result).isTrue();
// save 행위가 1번 호출 됐는지 검증 하는 메서드
verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
}
}
@Mock
MockitoBean 과 달리, 순수 Mock 객체를 이용하여 스프링 컨테이너 환경에서 동작하는 통합 테스트가 아닌, 단위 테스트 위주의 테스트 케이스를 작성할 수 있다.
Mock 어노테이션을 클래스 변수로 작성한 뒤 ExtendWith(MockitoExtension.class) 를 클래스 레벨에 사용하면 Mock 객체가 기본으로 생성된다.
@Mock 어노테이션은 아래 코드와 같은 기본 값 생성 코드를 생략할 수 있는 편리함을 제공한다.
MailSendClient mailSendClient1 = mock(MailSendClient.class);
"Mock 객체의 특정 메서드를 Stubbing 하였지만, 메서드 내부에서 다른 메서드를 호출 하는 경우 어떻게 될까?"
public class MailService {
private final MailSendClient mailSendClient;
private final MailSendHistoryRepository mailSendHistoryRepository;
public boolean sendMail(String fromEmail, String toEmail, String subject, String content) {
boolean result = mailSendClient.sendMail(fromEmail, toEmail, subject, content);
if (result) {
mailSendHistoryRepository.save(
MailSendHistory.builder()
.fromEmail(fromEmail)
.toEmail(toEmail)
.subject(subject)
.content(content)
.build()
);
mailSendClient.a();
mailSendClient.b();
mailSendClient.c();
return true;
}
return false;
}
...
MailServiceTest
// @Mock Stubbing
when(mailSendClient.sendMail(anyString(), anyString(), anyString(), anyString()))
.thenReturn(true);
위 코드를 보면, MailServiceTest 에서 MailSendClient 의 sendMail() 을 Stubbing 하여 실제 사용자에게 보내는 메일을 보내지 않고 테스트 하기 위한 코드를 작성했다.
하지만, 내부적으로 호출 되고 있는 mailSendHistoryRepository 의 save() 메서드는 어떻게 될까?
메일 발송 기록을 저장하는 코드인데, 이 내용은 테스트 할 때 마다 저장되면 안되기 때문에 똑같이 Stubbing 을 해줘야하는걸까?
Stubbing 메서드 내부에서 실행되는 다른 메서드 디버깅하기

디버깅을 해보면 save() 메서드의 결과값이 null 인 것을 알 수 있고, 실제 저장이 되지 않았다.
Mock 객체 내부 코드를 살펴보면, 생성할 때 기본값을 사용할 수 있도록 유도하며 기본 값을 반환하게 된다.

@InjectMocks
어노테이션 명칭에서도 알 수 있듯이 Mock 객체를 주입하는 어노테이션으로, MailService 같이 Mock 객체에 대한 의존관계를 갖고 있을 때 테스트 케이스에서 Mock 객체가 생성 되어 있다면 의존 관계를 주입해주는 방식이다.
스프링의 DI 방식과 동일하다.
@Spy
Mock과 유사하지만 Spy는 실제 객체의 일부 메서드만 Stubbing 하여 사용할 수 있다.
Mock 은 특정 행동에 대해 정의하지 않으면 기본 값을 반환 하지만, Spy는 정의 되지 않은 행동은 실제 메서드의 반환 값을 그대로 사용한다.
그 중 정의된 행동만 의도한 값이 사용된다.
public boolean sendMail(String fromEmail, String toEmail, String subject, String content) {
boolean result = mailSendClient.sendMail(fromEmail, toEmail, subject, content);
if (result) {
mailSendHistoryRepository.save(
MailSendHistory.builder()
.fromEmail(fromEmail)
.toEmail(toEmail)
.subject(subject)
.content(content)
.build()
);
mailSendClient.a();
mailSendClient.b();
mailSendClient.c();
sendMail() 에 a, b, c 메서드를 호출하고 각 메서드는 각자의 메서드명을 로그로 출력하는 행동을 한다.
그 다음, 테스트 코드에서 sendMail 만 Stubbing 하면 아래와 같은 출력 결과를 얻는다.
12:53:45.919 [main] INFO sample.cafekiosk.spring.client.mail.MailSendClient -- a
12:53:45.921 [main] INFO sample.cafekiosk.spring.client.mail.MailSendClient -- b
12:53:45.921 [main] INFO sample.cafekiosk.spring.client.mail.MailSendClient -- c
BDDMockito
package sample.cafekiosk.spring.api.service.mail;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.BDDMockito;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import sample.cafekiosk.spring.client.mail.MailSendClient;
import sample.cafekiosk.spring.domain.history.mail.MailSendHistory;
import sample.cafekiosk.spring.domain.history.mail.MailSendHistoryRepository;
@ExtendWith(MockitoExtension.class) // NOTE: 해당 어노테이션이 있으므로 테스트가 시작될 때 Mockito 를 활용하여 Mock 객체를 생성한다..
class MailServiceTest {
@Mock
private MailSendClient mailSendClient;
@Mock
private MailSendHistoryRepository mailSendHistoryRepository;
@InjectMocks // NOTE: MailService의 생성자를 확인하여 Mock 객체로 생성된 객체를 주입한다. -> DI와 동일
private MailService mailService;
@DisplayName("메일 전송 테스트")
@Test
void sendMail() {
// given
// @Mock Stubbing
// when(mailSendClient.sendMail(anyString(), anyString(), anyString(), anyString()))
// .thenReturn(true);
BDDMockito.given(mailSendClient.sendMail(anyString(), anyString(), anyString(), anyString()))
.willReturn(true);
// when
boolean result = mailService.sendMail("fromEmail", "toEmail", "subject", "content");
// then
assertThat(result).isTrue();
// save 행위가 1번 호출 됐는지 검증 하는 메서드
verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
}
}
테스트 케이스를 작성할 때 현재까지 BDD 방식을 따라왔다. 그렇기에 Mock 객체 생성 또는 Stubbing 과정을 테스트를 위한 준비 과정으로써 Given 에 속하는 것이 맞다.
하지만, Mockito 의 Stubbing 메서드 명칭은 Mockito.when 이다. 테스트 코드도 하나의 문서 처럼 활용할 수 있기 때문에 읽는이로 하여금 오해를 살 수 있는 부분이된다.
역시 소프트웨어 세계는 대부분 누군가 했던 고민의 흔적이 있듯, 이런 Mockito 라이브러리를 그대로 래핑하여 BDDMockito 라는 라이브러리를 제공하고, 위 코드에서 볼 수 있듯이 given 이라는 명칭을 쓴다.
모든 동작은 Mockito 와 같지만 더 자연스러운 읽는 흐름을 갖을 수 있기 때문에, 앞으로 Given 절에서 Stubbing 할 땐 BDDMockito 로 꾸미지 않았지만 꾸민 느낌을 주어 편안함을 제공해보자.
당신은 Classicist 인가요? Mockist 인가요?
Classicist
실제 객체의 메서드 반환 값을 중점으로 테스트 하며 동작에 대한 신뢰성을 바탕으로 하는 소프트웨어 테스팅 접근 방식
이 입장은 모든 객체에 대한 Mocking 을 진행하게 되면 실제 반영되는 코드가 변경 되었을 시 모킹 객체의 행동 값을 재정의 하지 않으면 테스트는 통과하지만, 사용자 행동은 오류가 나는 상황을 일련의 방지하고자 하는 목적이다.
다만, 난 클래시스트이기 때문에 모킹은 절대 하지않아 의 입장은 아니기 때문에 최소한으로 사용하자는 주의이다.
"엣헴, 누가 모키토 소리를 내었어? 😤"
... Mockist 내용 추가 예정
Last updated