쌩로그

Practical Testing 실용적인 테스트 가이드 - Section 06. Mock을 마주하는 자세 본문

Spring/Test

Practical Testing 실용적인 테스트 가이드 - Section 06. Mock을 마주하는 자세

.쌩수. 2024. 2. 2. 00:22
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. Mockito로 stubbing하기
      2-2 Test Double
      2-3 @Mock, @Spy, @InjectionMocks
      2-4. BDDMockito
      2-5. classicist VS Mockist
  3. 요약

1. 포스팅 개요

인프런에서 박우빈님의 Practical Testing: 실용적인 테스트 가이드 강의 섹션 6 Mock을 마무리하는 자세를 학습하며 정리한 포스팅이다.

이번 섹션을 통해서 Mocking을 언제 어떻게 그 다음에 어디서 써야 하는지를 살펴본다.

| 참고 이전 포스팅

2. 본론

2-1. Mockito로 stubbing하기

주문 통계에 대한 서비스를 하나 만들 것이다.

로직은 다음과 같다.

// 해당일자에 결제 완료된 주문들을 가져와서
// 총 매출 합게를 계산하고,
// 메일을 전송한다.

다음은 결제 완료 라는 값을 가지고 있는 주문상태 enum이다.

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum OrderStatus {

    INIT("주문생성"),
    CANCELED("주문 취소"),
    PAYMENT_COMPLETED("결제 완료"),
    PAYMENT_FAILED("결제 실패"),
    RECEIVED("주문 접수"),
    COMPLETED("처리 완료");

    private final String text;
}

다음은 주문 Entity이다.

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import sample.cafekiosk.spring.domain.BaseEntity;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.orderproduct.OrderProduct;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "orders")
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<>();

    public Order(List<Product> products, LocalDateTime registeredDateTime) {
        this.orderStatus = OrderStatus.INIT;
        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 registeredDateTme) {
        return new Order(products, registeredDateTme);
    }

    private int calculateTotalPrice(List<Product> products) {
        return products.stream()
                .mapToInt(Product::getPrice)
                .sum();
    }
}

이 중 필요한 것은 주문일자에 대한 정보를 가진 registerDateTime이다.

    private LocalDateTime registeredDateTime;

이제 셔비스를 작성해보자.
OrderStatisticsService 이름의 서비스 클래스다.

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import sample.cafekiosk.spring.domain.order.OrderRepository;

import java.time.LocalDate;

@RequiredArgsConstructor
@Service
public class OrderStatisticsService {

    private OrderRepository orderRepository;

    public void sendOrderStatisticsMail(LocalDate orderDate, String email) {
        // 해당 일자에 결제완료된 주문들을 가져와서

        // 총 매출 합계를 계산하고

        // 메일 전송
    }

}

로직은 다음과 같다.

해당 일자에 결제 완료된 주문들을 가져온다에 해당하는 다음과 같다.

// 해당 일자에 결제완료된 주문들을 가져와서
List<Order> orders = orderRepository.findOrdersBy(
         orderDate.atStartOfDay(),
         orderDate.plusDays(1).atStartOfDay(),
         OrderStatus.PAYMENT_COMPLETED
);

참고로 위의 날짜 관련 로직은 다음과 같다.
주문 시작일은 포함하고,
주문 시작일에 하루를 더한 날은 포함하지 않는다.

orderRepository에서 메서드를 확인해보면 다음과 같다.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;

@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);
}

날짜의 범위를 보면 startDate는 포함하되 endDate는 포함되지 않도록 한다.

다시 서비스 클래스를 보자.
일단 작성된 서비스 클래스는 다음과 같다.

public boolean sendOrderStatisticsMail(LocalDate orderDate, String email) {
   // 해당 일자에 결제완료된 주문들을 가져와서
   List<Order> orders = orderRepository.findOrdersBy(
            orderDate.atStartOfDay(),
            orderDate.plusDays(1).atStartOfDay(),
            OrderStatus.PAYMENT_COMPLETED
   );

   // 총 매출 합계를 계산하고
   int totalAmount = orders.stream()
            .mapToInt(Order::getTotalPrice)
            .sum();

   // 메일 전송 // 이 부분에서 Mocking 처리 필요함.
   boolean result = mailService.sendMail("no-reply@cafekiosk.com",
            email,
            String.format("[매출통계] %s", orderDate),
            String.format("총 매출 합계는 %s원입니다.", totalAmount)
   );
   if(!result) {
      throw new IllegalArgumentException("매출 통계 메일 전송에 실패했습니다.");
   }

   return true;
}

로직에서 mailService가 나온다.
이와 관련된 클래스들을 작성하자.

먼저 MailService(메일 서비스) 클래스다.

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import sample.cafekiosk.spring.client.mail.MailSendClient;
import sample.cafekiosk.spring.domain.history.mail.MailSendHistory;
import sample.cafekiosk.spring.domain.history.mail.MailSendHistoryRepository;

@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.sendMail(fromEmail, toEmail, subject, content);
        if(result) {
            mailSendHistoryRepository.save(MailSendHistory.builder()
                    .fromEmail(fromEmail)
                    .toEmail(toEmail)
                    .subject(subject)
                    .content(content)
                    .build()
            );
            return true;
        }

        return false;

    }
}

다음은 MailSendClient(메일 클라이언트) 클래스다.

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class MailSendClient {
    public boolean sendMail(String fromEmail, String toEmail, String subject, String content) {
        log.info("메일 전송");
        throw new IllegalArgumentException("메일 전송");
    }
}

다음은 MailHistory(메일 센드히스토리) 클래스다.

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class MailSendHistory {

    @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(히스토리를 저장하는 Repository) 클래스이다.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface MailSendHistoryRepository extends JpaRepository<MailSendHistory, Long> {
}

흐름은 다음과 같다.

일자와 이메일을 주면, 주문을 가져와서 계산하여 메일을 전송한다.
이 때 메일 전송은 메일 서비스에서 하도록 한다.
클라이언트는 메일을 발송한 뒤에 결과값을 보고 메일 서비스에서는 메일전송에 대한 히스토리를 남긴다.

이제 OrderStatisticsService에 대한 테스트를 해보자.

참고로 기존의 Order클래스의 생성자 부분이 수정되었다.

Order 클래스이다.

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import sample.cafekiosk.spring.domain.BaseEntity;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.orderproduct.OrderProduct;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "orders")
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, 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 registeredDateTme) {
        return Order.builder()
                .orderStatus(OrderStatus.INIT)
                .products(products)
                .registeredDateTime(registeredDateTme)
                .build();
    }

    private int calculateTotalPrice(List<Product> products) {
        return products.stream()
                .mapToInt(Product::getPrice)
                .sum();
    }

    public void completeOrderState() {
        this.orderStatus = OrderStatus.PAYMENT_COMPLETED;
    }
}

테스트 코드는 다음과 같다.

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.ProductRepository;
import sample.cafekiosk.spring.domain.ProductType;
import sample.cafekiosk.spring.domain.history.mail.MailSendHistory;
import sample.cafekiosk.spring.domain.history.mail.MailSendHistoryRepository;
import sample.cafekiosk.spring.domain.order.Order;
import sample.cafekiosk.spring.domain.order.OrderRepository;
import sample.cafekiosk.spring.domain.order.OrderStatus;
import sample.cafekiosk.spring.domain.orderproduct.OrderProductRepository;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

import static org.assertj.core.api.Assertions.*;
import static sample.cafekiosk.spring.domain.ProductSellingStatus.SELLING;
import static sample.cafekiosk.spring.domain.ProductType.HANDMADE;

@SpringBootTest
class OrderStatisticsServiceTest {

    @Autowired
    private OrderStatisticsService orderStatisticsService;

    @Autowired
    private OrderProductRepository orderProductRepository;

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private MailSendHistoryRepository mailSendHistoryRepository;

    @AfterEach
    void tearDown() {
        orderProductRepository.deleteAllInBatch();
        orderRepository.deleteAllInBatch();
        productRepository.deleteAllInBatch();
        mailSendHistoryRepository.deleteAllInBatch();
    }

    @Test
    @DisplayName("결제완료 주문들을 조회하여 매출 통계 메일을 전송한다.")
    void sendOrderStatisticsMailTest() {
        // given
        LocalDateTime now = LocalDateTime.of(2024, 2, 1, 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(2024,1,31,23,59, 59), products);
        Order order2 = createPaymentCompletedOrder(now, products);
        Order order3 = createPaymentCompletedOrder(LocalDateTime.of(2024,2,1,23,59,59), products);
        Order order4 = createPaymentCompletedOrder(LocalDateTime.of(2024,2,2,0,0), products);

        // when
        boolean result = orderStatisticsService.sendOrderStatisticsMail(LocalDate.of(2024,2,1), "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();
    }

}

결과는 다음과 같다.

지금 사실 메일 전송을 한다고 가정했는데, 실제 메일이 아니라 예외를 발생시킴으로써 메일 전송 이벤트가 발생했음을 나타내려고 다음과 같이 했다.

현제 테스트에서 Mocking처리를 해야하는 부분은 다음과 같다.

// OrderStatisticsService의 sendOrderStatisticsMail 메서드 중 메일 전송하는 부분

// 메일 전송 // 이 부분에서 Mocking 처리 필요함.
boolean result = mailService.sendMail("no-reply@cafekiosk.com",
         email,
         String.format("[매출통계] %s", orderDate),
         String.format("총 매출 합계는 %s원입니다.", totalAmount)
);
if(!result) {
   throw new IllegalArgumentException("매출 통계 메일 전송에 실패했습니다.");
}

이 부분이다.
실제 테스트를 할 때 mail을 전송하도록 해도되지만,
시간을 따졌을 때 비용적인 측면이 있다.
따라서 테스트를 진행할 때는 Mocking처리를 해줘야 할 필요가 있다.

일단 MailService안에 MailClient를 Mocking처리해보자.

OrderStatisticsServiceTest에 다음과 같이 작성해주면 된다.

@MockBean
private MailSendClient mailSendClient;

이렇게 선언해주고, 동작을 정의해주면 된다.

잠시 Mock

기본적으로 Mock은 우리가 가짜 객체를 넣어놓고
"이 가짜 객체가 이렇게 행동했으면 좋겠어"
"이 가짜 객체가 이런 요청을 했을 때 이런 결과값을 주면 좋겠어"
과 같이 이런 것을 지정해줄 수 있다.
given, when, then 절 중 Mockito를 사용해서 when에 method call을 넣을 수 있다.


우리는 지금 Mock객체로 만든 MailSendClient가 send 이메일을 했을 때 매개변수로 4개의 String을 넣어줄 수 있다.

public boolean sendMail(String fromEmail, String toEmail, String subject, String content) {
    log.info("메일 전송");
    throw new IllegalArgumentException("메일 전송");
}

그런데 String을 4개를 동일하게 넣어주기가 힘들다.

어떤 String이 올지 모르기 때문에 Mockito에서 제공하는 any를 사용해서 "어떤 String 값이든 좋다"는 의미로 다음과 같이 테스트코드에 작성해준다.

그리고 "어떤 값을 return 해주면 좋을지 지정"해 줄 수도 있다.

작성한 코드는 다음과 같다.

Mockito.when(mailSendClient.sendMail(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString()))
                .thenReturn(true);

(인텔리제이를 이용하면, Mockito를 static import로 쉽게 뺄 수 있다.)

완성된 테스트 코드는 다음과 같다.

@Test
@DisplayName("결제완료 주문들을 조회하여 매출 통계 메일을 전송한다.")
void sendOrderStatisticsMailTest() {
    // given
    LocalDateTime now = LocalDateTime.of(2024, 2, 1, 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(2024,1,31,23,59, 59), products);
    Order order2 = createPaymentCompletedOrder(now, products);
    Order order3 = createPaymentCompletedOrder(LocalDateTime.of(2024,2,1,23,59,59), products);
    Order order4 = createPaymentCompletedOrder(LocalDateTime.of(2024,2,2,0,0), products);

    Mockito.when(mailSendClient.sendMail(Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString()))
            .thenReturn(true);


    // when
    boolean result = orderStatisticsService.sendOrderStatisticsMail(LocalDate.of(2024,2,1), "test@test.com");

    // then
    assertThat(result).isTrue();

    List<MailSendHistory> histories = mailSendHistoryRepository.findAll();
    assertThat(histories).hasSize(1)
            .extracting("content")
            .contains("총 매출 합계는 12000원입니다.");

}

결과는 다음과 같다.

아까 실패한 원인은 다음과 같았다.

여기서 예외를 던졌기 때문이다.
하지만 Mock으로 처리하게 해서 true가 반환되도록 지정했고,
그에 따라 테스트가 통과되었다.

이처럼 Mock을 사용하면 실제 서비스 로직을 타는 것이 아니라,
개발자가 임의로 지정한대로 결과를 반환한다.

이렇게 Mock객체에 우리가 원하는 행위를 지정하는 것을 "Stubbing 한다"

중간 과정에서 메일을 전송하거나, 네트워크를 타거하 하는 부분은 불필요한 과정이기 때문에 Mock을 통해서 테스트를 했다.

참고로.. 메일을 전송하는 로직에는 트랜잭션을 안 붙이는 것이 좋다.
왜냐하면 트랜잭션을 타지 않아도 되기 때문이다.
그리고 트랜잭션을 타면 DB 커넥션을 계속 물고 있어야 하기 때문이다.
(추후 메일 관련 기능을 해야할 수 있는데 참고해야겠다. ㅎㅎ)

2-2 Test Double

Test Double의 유래는 다음과 같다.

한국에서는 실제 배우가 있고, 실제 배우가 연기할 때 액션 영화 같은 곳을 보면 주로 위험한 장면 같은 경우는 대역을 사용하는데, 그 대역을 한국에서는 스턴트맨이라 한다.

그런데 영어로는 이를 스턴트 더블이라고 한다.

강의 중 나온 햄식이형이다.
여기서 유래한 단어다.

그리고 Test Double 과 관련해서 마틴 파울러의 유명한 글이 있다.
거기서 정의한 Test Double의 5가지 종류가 있다.

Dummy

  • 아무것도 없고, 동작도 않 하고 행위도 하지 않는 아무것도 안 하는 깡통 객체다.

Fake

  • 단순한 형태로 동일한 기능은 수행하나, 프로덕션에서 쓰기에는 부족한 객체다.
  • 예를 들면 Repository를 할 때 실제 DB가 아닌 메모리에서 Map과 같은 객체를 사용하는 형태를 말한다.

Stub

  • 테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체이다.
  • 그 외(예를 들어 요청하지 않는 경우)에는 응답하지 않는다.

Spy

  • Stub이면서 호출된 내용을 기록하여 보여줄 수 있는 객체다.
  • 일부는 실제 객체처럼 동작시키고 일부만 Stubbing 할 수 있다.

Mock

  • 행위에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체다.

이 5가지가 Test Double이란 것이다.

그런데 Stub과 Mock이 유사하여 많이 헷갈려하지만, 딱 구분하면 다음과 같다.

Stub상태를 검증(State Verification)한다
Mock행위를 검증(Behavior Verification)한다.

이처럼 목적이 다르다.

Stub은 어떤 메소드 혹은 기능을 요청했을 때 요청이 이뤄진 후의 내부적인 상태가 바뀌는 것에 초점이 맞춰져 있고,

Mock"이 메서드가 이런 값을 줬을 떄 어떤 값을 넘겨줄거야" 같은 행위에 대한 것에 초점이 맞춰져 있다.

글은 여기에서 확인할 수 있다.

원문에서 차이점을 코드와 같이 설명하는데,
Stub을 예를 들면 메일을 보낸다고 했을 때 메일을 몇 번 보냈는지에 대한 상태를 검증한다.

Mock을 예를 들면 메일을 보내는 메서드가 호출되었다. 와 같은 행위에 대해 검증한다.

2-3 @Mock, @Spy, @InjectionMocks - 순수 Mockito로 검증해보기

이번엔 순수 Mockito를 가지고 MailService를 테스트해본다.

MailService를 테스트 해볼 것이다.
MailService 클래스는 다음과 같다.

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import sample.cafekiosk.spring.client.mail.MailSendClient;
import sample.cafekiosk.spring.domain.history.mail.MailSendHistory;
import sample.cafekiosk.spring.domain.history.mail.MailSendHistoryRepository;

@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.sendMail(fromEmail, toEmail, subject, content);
        if(result) {
            mailSendHistoryRepository.save(MailSendHistory.builder()
                    .fromEmail(fromEmail)
                    .toEmail(toEmail)
                    .subject(subject)
                    .content(content)
                    .build()
            );
            return true;
        }

        return false;

    }
}

다음은 테스트 코드다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import sample.cafekiosk.spring.client.mail.MailSendClient;
import sample.cafekiosk.spring.domain.history.mail.MailSendHistoryRepository;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

class MailServiceTest {

    @Test
    @DisplayName("메일 전송 테스트")
    void sendMailTest() {
        // given
        MailSendClient mailSendClient = mock(MailSendClient.class);
        MailSendHistoryRepository mailSendHistoryRepository = mock(MailSendHistoryRepository.class);

        MailService mailService = new MailService(mailSendClient, mailSendHistoryRepository);

        when(mailSendClient.sendMail(anyString(), anyString(), anyString(), anyString()))
                .thenReturn(true);


        // when
        boolean result = mailService.sendMail("", "", "", "");

        // then
        assertThat(result).isTrue();
    }

}

현재 이렇게 테스트를 진행하면 통과한다.

이처럼 테스트가 통과한다.
사실 중간에 mailService에서 다음과 같은 코드도 행위를 지정해줄 수 있다.

이 부분은 나중에 할 예정이다.

다음은 Mockito에서 제공하는 verify()로 명확하게 볼 수 있다.

verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));

mailSendHistoryRepository의 save 메서드가 한번만 불렸는지를 행위를 검증하는 기능이다.

verify는 참고로 메서드가 수행되고 난 후 checking되기 때문에 then절에서 검증한다.

따라서 테스트 코드는 다음과 같이 작성한다.

@Test
@DisplayName("메일 전송 테스트")
void sendMailTest() {
    // given
    MailSendClient mailSendClient = mock(MailSendClient.class);
    MailSendHistoryRepository mailSendHistoryRepository = mock(MailSendHistoryRepository.class);

    MailService mailService = new MailService(mailSendClient, mailSendHistoryRepository);

    when(mailSendClient.sendMail(anyString(), anyString(), anyString(), anyString()))
            .thenReturn(true);

    // when
    boolean result = mailService.sendMail("", "", "", "");

    // then
    assertThat(result).isTrue();
    verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));

}

결과는 다음과 같다.

그리고 Mock객체를 생성할 때 위와 같은 방식으로 Test단위에 Mock을 지정해도 되고,

아니면 다음과 같이 @Mock으로 선언해서 주입해줄 수도 있다.

그런데 @Mock을 사용할 때는 테스트에게 "우리는 Mockito를 사용해서 테스트할거야!" 라고 지정해줘야 한다.
클래스 레벨에 다음과 같이 선언한다.

@ExtendWith(MockitoExtension.class)
class MailServiceTest {

    ...

    @Mock
    private MailSendClient mailSendClient;

    @Mock
    private MailSendHistoryRepository mailSendHistoryRepository;

    ...

}

다음은 테스트 결과다.

그런데 저 Mock객체들로 만들어주는 MailService도 애터네이션으로 생성해 줄 수 있다.

@injectionMoks를 사용하면 된다.

이렇게 되면 MailService의 생성자를 보고 @Mock으로 선언했던 것들을 넣어준다.
그러면 MailService는 Mock객체로 생성된다.

그리고 테스트 코드가 엄청 간결해졌다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
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;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class MailServiceTest {

    @Mock
    private MailSendClient mailSendClient;

    @Mock
    private MailSendHistoryRepository mailSendHistoryRepository;

    @InjectMocks
    private MailService mailService;

    @Test
    @DisplayName("메일 전송 테스트")
    void sendMailTest() {
        // given
        when(mailSendClient.sendMail(anyString(), anyString(), anyString(), anyString()))
                .thenReturn(true);

        // when
        boolean result = mailService.sendMail("", "", "", "");

        // then
        assertThat(result).isTrue();
        verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
    }

}

테스트는 통과한다.

다음으로 SPY를 알아보자.

Test Double에서는 기록을 하는 객체였다.
몇 번 호출되었는지, 그 행위에 대한 기록들을 하는 객체인데, 사실 방금 봤던 verify가 하는 것과 비슷하다.

그리고 Spy의 애너테이션이 있다.
다음과 같이 해주면 된다.

만약 MailSencClient에 다음과 같이 기능이 많다고 가정했을 때
MailServiceMailSendClient의 모든 메서드를 호출하고 있다.

// MailSendClient
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class MailSendClient {
    public boolean sendMail(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");
    }

}
// MailService
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import sample.cafekiosk.spring.client.mail.MailSendClient;
import sample.cafekiosk.spring.domain.history.mail.MailSendHistory;
import sample.cafekiosk.spring.domain.history.mail.MailSendHistoryRepository;

@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.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;

    }
}

이러한 상황일 때 MailSendClient메일을 보내는 기능을 하는 메서드만 Stubbing을 하고,
다른 B, C 메서드는 원본 객체의 기능이 동일하게 작동되었으면 좋겠다 싶을 때 사용하는 것이 @Spy이다.

일단 @Mock으로 해보자.

테스트를 실행하면 통과한다.

그런데 원래 다음과 같이 로그들을 남겨야하는데, 지금 테스트 결과는 log가 나오지 않는다.

그런데 만약 다음과 같이 @Spy로 돌린다면?

참고로 @Spy로 했을 때는 when절을 사용하면 안 된다.
왜냐하면 @Spy는 실제 객체를 기반으로 만들어준다.
현재sms

따라서 @SpymailSendClient.sendMail를 하면 실제 객체의 것을 Mocking하려고 하기때문에 Stubbing이 되지 않는다.

다른 방법을 사용해야 한다.

지금은 doReturn을 사용한다.

테스트 코드는 다음과 같다.

@Test
@DisplayName("메일 전송 테스트")
void sendMailTest() {
    // given
    doReturn(true)
            .when(mailSendClient)
            .sendMail(anyString(), anyString(), anyString(), anyString());


    // when
    boolean result = mailService.sendMail("", "", "", "");

    // then
    assertThat(result).isTrue();
    verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
}

결과는 다음과 같다.

그리고 log에 a, b, c가 나오고 있다.
일부는 실제 객체의 기능을 쓰고, 일부는 Stubbing을 하고 싶을 때 @Spy를 사용한다.
(쓸 일이 많지는 않다.)

2-4. BDDMockito

@ExtendWith(MockitoExtension.class)
class MailServiceTest {

    @Mock
    private MailSendClient mailSendClient;

    @Mock
    private MailSendHistoryRepository mailSendHistoryRepository;

    @InjectMocks
    private MailService mailService;

    @Test
    @DisplayName("메일 전송 테스트")
    void sendMailTest() {
        // given
        when(mailSendClient.sendMail(anyString(), anyString(), anyString(), anyString())).thenReturn(true);

        // when
        boolean result = mailService.sendMail("", "", "", "");

        // then
        assertThat(result).isTrue();
        verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
    }

}

이 코드를 보자.
살짝 이상해보이는 부분이 있는데, 바로 given절에 when 문법이 들어가있다.
이러한 고민 끝에 만들어진 것이 바로 BDDMockito이다.

그래서 이 테스트 코드를 BDDMockito를 사용하면 다음과 같다.

@Test
@DisplayName("메일 전송 테스트")
void sendMailTest() {
    // given
    BDDMockito.given(mailSendClient.sendMail(anyString(), anyString(), anyString(), anyString()))
            .willReturn(true);


    // when
    boolean result = mailService.sendMail("", "", "", "");

    // then
    assertThat(result).isTrue();
    verify(mailSendHistoryRepository, times(1)).save(any(MailSendHistory.class));
}

결과는 다음고 같다.

BDDMockito는 Mockito를 한 번 감싼 것이다.
기능은 동일한데 BDD 형식에 맞게 이름을 바꾼 것일 뿐 다른 것은 아니다.

그리고 내부로 들어가면 BDDMockito는 Mockito를 상속한 것이다.

2-5. classicist VS Mockist

Mockist"모든 것을 Mock 위주로 하자!"는 입장이고,
classicist는 Mockist와는 반대로 "진짜 객체로 최대한 테스트를 해야 된다"라고 하는 임장이다.(그래도 Mocking을 사용하지말자는 아니다.)

(참고로 강의 제작자인 우빈님은 굳이 말하자면 classicist에 가까운 분이라고 한다.)

이 부분은 고민을 잘 해봐야 할 거 같다...

예를 들면 프로덕션 코드 중 외부 라이브러리의 객체와 프로덕션 코드가 로직 중에 같이 있는 경우가 있을것인데,
이 때 외부 라이브러리에 대해서는 Mocking처리를 하고, 우리가 작성한 프로덕션 코드는 Mocking처리를 하지않고, 테스트를 작성한다든지 할 수 있을 것이다.

이 부분은 테스트를 작성하는 개발자로서 끊임없이 생각해봐야 될 것 같다.
(추후에 한번 봐야겠다.)

3. 요약

  • Test Double에서 5가지 Stubbing 종류를 알아보았다.
  • Mock과 관련된 애너테이션을 살펴보았다.
  • BDDMockito는 given when then 절을 문맥에 맞게 사용할 수 있도록 Mockito를 상속받은 구현체다. 기능은 똑같다.
  • classist와 Mockist의 입장들을 들어봤다. 따라서 어떤 것이 보다 좋은지는 테스트를 작성하면서 계속 고민해봐야 될 거 같다.
728x90
Comments