Mockito Stubbing
in TDD on Tdd
Mockito란?
- 단위 테스트를 위한 자바 Mocking 프레임워크 중 하나.
- 자바 진영에선 가장 보편적인 Mocking 프레임워크이며, 테스트 대역(Test Double)의 종류 중 모의(Mock) 객체를 필요로 할 때 사용.
- 다른 자바 Mocking 프레임워크에는 JMock, EasyMock 등이 있다.
테스트 대역(Test Double)
- 테스트를 위해 실제 객체를 대체하는 것을 말한다.
모의(Mock) 객체
- 호출했을 때 사전에 정의된 명세대로의 결과를 돌려주도록 미리 프로그램돼있는 테스트용 객체를 말한다.
Stubbing이란?
- 모의 객체 생성 및 모의 객체의 동작을 지정하는 것을 Stubbing이라고 한다.
실습
요구사항
- 주문관리 > 일일 매출 통계 > 총 결제완교 금액 메일 전송
구현 순서
- OrderStatisticsService sendOrderStatisticsMail
- 해당 일자에 결제 완료된 주문들을 가져와서
- OrderRepository findOrdersBy
- 테스트 생략
- OrderRepository findOrdersBy
- 해당 일자에 결제 완료된 주문들을 가져와서
- 총 매출 합계를 계산하고
- 메일 전송
- MailService sendMail
- MailSendClient sendEmail
- MailSendHistory(Entity)
- MailSendHistoryRepository
- MailServiceTest sendMail
- MailSendClient a, b, c
- MailService sendMail
- 메일 전송
- OrderStatisticsServiceTest
- tearDown
- OrderStatisticsServiceTest sendOrderStatisticsMail
- Order Builder
- OrderStatisticsServiceTest createProduct
OrderStatisticsServiceTest.java
@SpringBootTest
class OrderStatisticsServiceTest {
@Autowired
private OrderStatisticsService orderStatisticsService;
@Autowired
private OrderProductRepository orderProductRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private ProductRepository productRepository;
@Autowired
private MailSendHistoryRepository mailSendHistoryRepository;
@MockBean
private MailSendClient mailSendClient;
@AfterEach
void tearDown() {
orderProductRepository.deleteAllInBatch();
orderRepository.deleteAllInBatch();
productRepository.deleteAllInBatch();
mailSendHistoryRepository.deleteAllInBatch();
}
@DisplayName("결제완료 주문들을 조회하여 매출 통계 메일을 전송한다.")
@Test
void sendOrderStatisticsMail() {
// given
LocalDateTime now = LocalDateTime.of(2023, 3, 5, 0, 0);
Product product1 = createProduct(HANDMADE, "001", 1000);
Product product2 = createProduct(HANDMADE, "002", 2000);
Product product3 = createProduct(HANDMADE, "003", 3000);
List<Product> products = List.of(product1, product2, product3);
productRepository.saveAll(products);
Order order1 = createPaymentCompletedOrder(LocalDateTime.of(2023, 3, 4, 23, 59, 59), products);
Order order2 = createPaymentCompletedOrder(now, products);
Order order3 = createPaymentCompletedOrder(LocalDateTime.of(2023, 3, 5, 23, 59, 59), products);
Order order4 = createPaymentCompletedOrder(LocalDateTime.of(2023, 3, 6, 0, 0), products);
// stubbing
when(mailSendClient.sendEmail(any(String.class), any(String.class), any(String.class), any(String.class)))
.thenReturn(true);
// when
boolean result = orderStatisticsService.sendOrderStatisticsMail(LocalDate.of(2023, 3, 5), "test@test.com");
// then
assertThat(result).isTrue();
List<MailSendHistory> histories = mailSendHistoryRepository.findAll();
assertThat(histories).hasSize(1)
.extracting("content")
.contains("총 매출 합계는 12000원입니다.");
}
private Order createPaymentCompletedOrder(LocalDateTime now, List<Product> products) {
Order order = Order.builder()
.products(products)
.orderStatus(OrderStatus.PAYMENT_COMPLETED)
.registeredDateTime(now)
.build();
return orderRepository.save(order);
}
private Product createProduct(ProductType type, String productNumber, int price) {
return Product.builder()
.type(type)
.productNumber(productNumber)
.price(price)
.sellingStatus(SELLING)
.name("메뉴 이름")
.build();
}
}
OrderRepository.java
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o where o.registeredDateTime >= :startDateTime" +
" and o.registeredDateTime < :endDateTime" +
" and o.orderStatus = :orderStatus")
List<Order> findOrdersBy(LocalDateTime startDateTime, LocalDateTime endDateTime, OrderStatus orderStatus);
}
MailService.java
@RequiredArgsConstructor
@Service
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.sendEmail(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;
}
}
MailSendClient.java
package sample.cafekiosk.spring.client.mail;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class MailSendClient {
public boolean sendEmail(String fromEmail, String toEmail, String subject, String content) {
log.info("메일 전송");
throw new IllegalArgumentException("메일 전송");
}
public void a() {
log.info("a");
}
public void b() {
log.info("b");
}
public void c() {
log.info("c");
}
}
MailSendHistory.java
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class MailSendHistory extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String fromEmail;
private String toEmail;
private String subject;
private String content;
@Builder
private MailSendHistory(String fromEmail, String toEmail, String subject, String content) {
this.fromEmail = fromEmail;
this.toEmail = toEmail;
this.subject = subject;
this.content = content;
}
}
MailSendHistoryRepository.java
@Repository
public interface MailSendHistoryRepository extends JpaRepository<MailSendHistory, Long> {
}
MailServiceTest.java
@ExtendWith(MockitoExtension.class)
class MailServiceTest {
@Mock
private MailSendClient mailSendClient;
@Mock
private MailSendHistoryRepository mailSendHistoryRepository;
@InjectMocks
private MailService mailService;
@DisplayName("메일 전송 테스트")
@Test
void sendMail() {
// given
// Mockito.when(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
// .thenReturn(true);
given(mailSendClient.sendEmail(anyString(), anyString(), anyString(), anyString()))
.willReturn(true);
// doReturn(true)
// .when(mailSendClient)
// .sendEmail(anyString(), anyString(), anyString(), anyString());
// when
boolean result = mailService.sendMail("", "", "", "");
// then
assertThat(result).isTrue();
verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
}
}
OrderStatisticsServiceTest.java
@SpringBootTest
class OrderStatisticsServiceTest {
@Autowired
private OrderStatisticsService orderStatisticsService;
@Autowired
private OrderProductRepository orderProductRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private ProductRepository productRepository;
@Autowired
private MailSendHistoryRepository mailSendHistoryRepository;
@MockBean // 부트 3.4.0 부터 @MockitoBean 으로 대체
private MailSendClient mailSendClient;
@AfterEach
void tearDown() {
orderProductRepository.deleteAllInBatch();
orderRepository.deleteAllInBatch();
productRepository.deleteAllInBatch();
mailSendHistoryRepository.deleteAllInBatch();
}
@DisplayName("결제완료 주문들을 조회하여 매출 통계 메일을 전송한다.")
@Test
void sendOrderStatisticsMail() {
// given
LocalDateTime now = LocalDateTime.of(2023, 3, 5, 0, 0);
Product product1 = createProduct(HANDMADE, "001", 1000);
Product product2 = createProduct(HANDMADE, "002", 2000);
Product product3 = createProduct(HANDMADE, "003", 3000);
List<Product> products = List.of(product1, product2, product3);
productRepository.saveAll(products);
Order order1 = createPaymentCompletedOrder(LocalDateTime.of(2023, 3, 4, 23, 59, 59), products);
Order order2 = createPaymentCompletedOrder(now, products);
Order order3 = createPaymentCompletedOrder(LocalDateTime.of(2023, 3, 5, 23, 59, 59), products);
Order order4 = createPaymentCompletedOrder(LocalDateTime.of(2023, 3, 6, 0, 0), products);
// stubbing
when(mailSendClient.sendEmail(any(String.class), any(String.class), any(String.class), any(String.class)))
.thenReturn(true);
// when
boolean result = orderStatisticsService.sendOrderStatisticsMail(LocalDate.of(2023, 3, 5), "test@test.com");
// then
assertThat(result).isTrue();
List<MailSendHistory> histories = mailSendHistoryRepository.findAll();
assertThat(histories).hasSize(1)
.extracting("content")
.contains("총 매출 합계는 12000원입니다.");
}
private Order createPaymentCompletedOrder(LocalDateTime now, List<Product> products) {
Order order = Order.builder()
.products(products)
.orderStatus(OrderStatus.PAYMENT_COMPLETED)
.registeredDateTime(now)
.build();
return orderRepository.save(order);
}
private Product createProduct(ProductType type, String productNumber, int price) {
return Product.builder()
.type(type)
.productNumber(productNumber)
.price(price)
.sellingStatus(SELLING)
.name("메뉴 이름")
.build();
}
}
Order.java
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "orders")
@Entity
public class Order extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
private OrderStatus orderStatus;
private int totalPrice;
private LocalDateTime registeredDateTime;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderProduct> orderProducts = new ArrayList<>();
@Builder
private Order(List<Product> products, OrderStatus orderStatus, LocalDateTime registeredDateTime) {
this.orderStatus = orderStatus;
this.totalPrice = calculateTotalPrice(products);
this.registeredDateTime = registeredDateTime;
this.orderProducts = products.stream()
.map(product -> new OrderProduct(this, product))
.collect(Collectors.toList());
}
public static Order create(List<Product> products, LocalDateTime registeredDateTime) {
return Order.builder()
.orderStatus(OrderStatus.INIT)
.products(products)
.registeredDateTime(registeredDateTime)
.build();
}
private int calculateTotalPrice(List<Product> products) {
return products.stream()
.mapToInt(Product::getPrice)
.sum();
}
}
tip
- 메일 등 긴 작업에는 트랜잭션을 걸지 않는것을 추천
- Test Double : 테스트를 목적으로 프로덕션 오브젝트를 대체하는 오브젝트를 뜻한다.
- Dummy : 아무 것도 하지 않는 깡통 객체
- Fake : 단순한 형태로 동일한 기능은 수행하나, 프로덕션에서 쓰기에는 부족한 객체(FakeRepository)
- Stub : 테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체 그외에는 응답하지 않는다.(상태검증)
- Spy : Stub이면서 호출된 내용을 기록하여 보여줄 수 있는 객체 일부는 실제 객체처럼 동작시키고 일부만 Stubbing할 수 있다.
- Mock : 행위에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체(행위검증)
- @Mock : 해당 서비스만 작동(가짜 오브젝트 생성)
- @Spy : 해당 서비스만 mock으로 사용하고 나머지 서비스는 실제 사용(실제 오브젝트 생성)
- @InjectMocks : @Mock이 붙은 목객체를 @InjectMocks이 붙은 객체에 주입시킬 수 있다. @InjectMocks(Service) @Mock(DAO) 이런식으로 Service테스트 목객체에 DAO 목객체를 주입시켜 사용한다.
- BDDMockito : Mockito 에서 제공하던 when을 given, when, then의 문법에 맞춰 이름만 바꿔준 것, 의미에 맞게 BDDMockito 사용 추천
- Classicist vs Mockist
- Classicist : 실제 구현체를 적절히 사용
- Mockist : Mock만 사