Stub và mock trong unittest với Mockito2

MockStub là nền tảng cho việc unit test nhanh và đơn giản. Mock hữu ích trong trường hợp bạn có sự phụ thuộc vào hệ thống bên ngoài, việc đọc file tốn nhiều thời gian, kết nối database không đáng tin cậy, hoặc gởi email sau mỗi lần test...

Không giống như integration hay funtional test, tức khi mà toàn bộ hệ thống được kiểm thử, unit test lại tập trung vào một single class. Mọi thứ khác nên là một simple class hoặc mock. Trong bài viết này, chúng ta sẽ focus vào unit test sử dụng Mockito2 - một mocking framework thống trị trong testing với Java.

Vị trí của Unit Test trong Testing Pyramid

Nó nằm ở dưới cùng của tháp kiểm thử (Testing Pyramid).

  • Kiểm thử 1 single class
  • Chỉ cần source code của ứng dụng mà không cần 1 bản build cụ thể.
  • Nhanh
  • Không bị ảnh hưởng bởi các hệ thống bên ngoài, vd: web service, database ...
  • Thực hiện ít hoặc không có I/O, vd: không có kết nối database thực sự...

Những test này là thành phần chính của toàn bộ test suite, và bao gồm số lượng lớn trong toàn bộ test của bạn. Đa số có sự nhầm lẫn giữa unit testintegration test, service test, system test hoặc functional test.

Sự phân biệt này rất quan trọng, một test case như write database hay read JSON từ web service thì không phải là một unit test. Nó có thể trở thành unit test nếu bạn mock database hay external web service đó. Unit testintegration test nên được xử lý khác nhau.

Sự cần thiết của mock và stub

Mocking là hành động loại bỏ sự phụ thuộc bên ngoài khỏi unit test để tạo được môi trường kiểm soát được xung quanh nó. Thông thường, chúng ta mock tất cả các class khác tương tác với lớp được test. Các thành phần phổ biến thường nên được mock:

  • Database connection
  • Web services
  • Class mà thực thi chậm
  • Classside effect
  • Class với các hành vi không xác định

Mockstub là các fake Java class thay thế các thành phần phụ thuộc bên ngoài như nêu trên. Các fake class này sẽ được hướng dẫn (instruction) trước khi chúng có thể hoạt động theo ý của bạn.

  • Stub là một fake class kèm theo các giá trị trả về theo các hành động đã được lập trình sẵn theo cách mà bạn hướng đến. Nó sẽ được inject vào class đang cần test để cung cấp cho bạn quyền kiểm soát tuyệt đối với những gì cần test như là đầu vào. Ví dụ: Stub có thể là một database connection cho phép bạn bắt chước bất kì kịch bản nào mà không cần một database thực sự.
  • Mock là một fake class có thể được kiểm tra sau khi kết thúc một bài test về sự tương tác của lớp đang được test. Ví dụ: Bạn có thể verify một phương thức của nó thực thi hay không và được thực thi bao nhiêu lần. Điền hình của mock là các classside effect cần được kiểm tra, như class gởi email hoặc gởi dữ liệu đến một dịch vụ bên ngoài.

Basic Stubbing với Mockito

Giả sử chúng ta có một vài class như bên dưới

// Entity class
@Entity
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private long id;
    private String firstName;
    private String lastName;
    //...getters and setters redacted for brevity...
}

// Business class
public class CustomerReader {
    @PersistenceContext
    private EntityManager entityManager;
    public String findFullName(Long customerID){
        Customer customer = entityManager.find(Customer.class, customerID);
        return customer.getFirstName() +" "+customer.getLastName();
    }
    //... other stucks
}

CustomerReader class đọc dữ liệu customer từ database thông qua EntityManager, làm thế nào để viết test cho class này?

Một giải pháp ngây ngô là điền trước một database thực sự với vài dữ liệu customer và tiến hành test. Tuy nhiên, việc này có rất nhiều vấn đề. Thứ nhất, nó tạo ra sự phụ thuộc cứng vào database đang chạy. Thứ hai, bạn cần thêm bước tạo dữ liệu test. Trong ví dụ này, nó có thể hoạt động, như trong thực tế không nên chút nào.

Giải pháp tốt nhất cho một unit test thực sự là loại bỏ hoàn toàn sự phụ thuộc vào database. Chúng ta sẽ Stub một database connection và hướng (lừa) class rằng nó đang thực thi với một EntityManager thực sự, trong khi EntityManager chỉ là một Mockito Stub. Bằng cách này, chúng ta có toàn quyền kiểm soát những gì được thực thi hoặc trả về bởi database connection mà không phải xử lý một database thực tế.

public class CustomerReaderTest {
    @Test
    public void happyPathScenario(){
        Customer sampleCustomer = new Customer(); // 1
        sampleCustomer.setFirstName("Susan");
        sampleCustomer.setLastName("Ivanova");
        EntityManager entityManager = mock(EntityManager.class); // 2 
        when(entityManager.find(Customer.class, 1L)).thenReturn(sampleCustomer); // 3 - important LOC
        CustomerReader customerReader = new CustomerReader();
        customerReader.setEntityManager(entityManager);
        String fullName = customerReader.findFullName(1L); // 4
        assertEquals("Susan Ivanova", fullName);
    }
}

Các bước ở trên:

  • Tạo 1 sample customer, chúng ta sử dụng real class vì nó đơn giản là một POJO class, không cần phải mock nó.
  • Tiếp đó, mock đối tượng EntityManager bằng cách gọi mock() function (có thể sử dụng annotation @Mock)
  • Bước cực kì quan trọng trong line code tiếp theo. Nó định nghĩa (hướng) điều gì sẽ xảy ra khi gọi hàm find() từ entityManager. Tại đây, chúng ta setup rằng sample customer được tạo ở bước 1 sẽ được trả về khi id = 1L. Kể từ đây, CustomerReader class sẽ không hoàn toàn biết được rằng EntityManager là fake. Nó chỉ việc gọi findFullName (bước 4) và nhận về sample customer mà không quan tâm Mockito ở bên dưới tất cả.

Test case này thỏa mãn tất cả các yêu cầu của một unit test, nó không phụ thuộc bên ngoài, chỉ cần Java code, nhanh và hoàn toàn mang tính quyết định, hoàn toàn không cần database.

Thần chú When-Then trong Mockito

Sử dụng stubbing directive đơn giản

when(something).thenReturn(somethingElse)

sẽ giúp ích rất nhiều cho các unit test case của bạn. Tùy vào ứng dụng, đây có thể là tính năng (thần chú) Mockito duy nhất mà bạn cần.

Khi bạn có nhiều test methods, sẽ hợp lý khi di chuyển việc mock đến một mơi duy nhất và chỉ phân biệt hành vi của nó cho từ test riêng lẻ.

Ở ví dụ trước, có thể bạn đã nhận ra class CustomerReader không chính xác, vì nó chưa xử lý trường hợp null, ví dụ trường database ID không tồn tại trong database. Mặc dù chúng ta có thể copy-paste các unit test, tuy nhiên sẽ tốt hơn nếu chúng ta sắp xếp code với một common test method.

public class CustomerReaderTest {
    //Class to be tested
    private CustomerReader customerReader;
    //Dependencies
    private EntityManager entityManager;
    @Before
    public void setup(){
        customerReader = new CustomerReader();
        entityManager = mock(EntityManager.class);
        customerReader.setEntityManager(entityManager);
    }
    @Test
    public void customerInDb(){
        Customer sampleCustomer = new Customer();
        sampleCustomer.setFirstName("Susan");
        sampleCustomer.setLastName("Ivanova");

        when(entityManager.find(Customer.class, 1L)).thenReturn(sampleCustomer);

        String fullName = customerReader.findFullName(1L);
        assertEquals("Susan Ivanova",fullName);
    }
    @Test
    public void customerNotPresentInDb(){
        when(entityManager.find(Customer.class, 1L)).thenReturn(null);

        String fullName = customerReader.findFullName(1L);
        assertEquals("", fullName);
    }
}

Ở ví dụ này, chúng ta di chuyển mockcommon code vào setup method, toàn bộ code trong method này sẽ chạy trước khi chạy mỗi test method. Sự khác nhau duy nhất giữa các test methodwhen directive.

Basic Mocking với Mockito

Cùng xem trường hợp mock là cần thiết để thay thế một stub.

public class LateInvoiceNotifier {
    private final EmailSender emailSender;
    private final InvoiceStorage invoiceStorage;
    public LateInvoiceNotifier(final EmailSender emailSender, final InvoiceStorage invoiceStorage){
        this.emailSender = emailSender;
        this.invoiceStorage = invoiceStorage;
    }
    public void notifyIfLate(Customer customer){
        if(invoiceStorage.hasOutstandingInvoice(customer)){
            emailSender.sendEmail(customer);
        }
    }
}

Class này có 2 external dependencies, và sử dụng constuctor injection.

Trong thực tế, InvoiceStorageweb service kết nối với hệ thống CRM bên ngoài, hoạt động chậm. Như đã biết, một unit test không bao giờ sử dụng web service thực tế.

EmailSender class cũng là một hệ thống ngoài từ một third-party cung cấp chức năng emai. Vậy nên, chúng ta phải mock nó.

Tuy nhiên, có một vấn đề là khi bạn cố gắng viết test case cho các lớp này, chả có gì để asserted. Method chúng ta muốn test notifyIfLate là một phương thức void không trả về bất cứ gì. Vậy test nó như thế nào?

Trong trường hợp này chúng ta sẽ focus vào side effect của code. Ở đây, send một emailside effect. Email chỉ được gởi khi outstanding invoice customer xuất hiện. Mockito cung cấp verify directive cho việc testing side effect.

public class LateInvoiceNotifierTest {
    //Class to be tested
    private LateInvoiceNotifier lateInvoiceNotifier;
    //Dependencies (will be mocked)
    private EmailSender emailSender;
    private InvoiceStorage invoiceStorage;
    //Test data
    private Customer sampleCustomer;
    @Before
    public void setup(){
        invoiceStorage = mock(InvoiceStorage.class);
        emailSender = mock(EmailSender.class);

        lateInvoiceNotifier = new LateInvoiceNotifier(emailSender,invoiceStorage);

        sampleCustomer = new Customer();
        sampleCustomer.setFirstName("Susan");
        sampleCustomer.setLastName("Ivanova");
    }
    @Test
    public void lateInvoice(){
        when(invoiceStorage.hasOutstandingInvoice(sampleCustomer)).thenReturn(true); // Stub

        lateInvoiceNotifier.notifyIfLate(sampleCustomer);

        verify(emailSender).sendEmail(sampleCustomer); // specified argument is sampleCustomer.
    }
    @Test
    public void noLateInvoicePresent(){
        when(invoiceStorage.hasOutstandingInvoice(sampleCustomer)).thenReturn(false); // Stub

        lateInvoiceNotifier.notifyIfLate(sampleCustomer);

        verify(emailSender, times(0)).sendEmail(sampleCustomer);
    }
} 

Như ở trên, chúng ta Stub InvoiceStorage class bằng when-then syntax. Cả 2 test method đều không sử dụng JUnit assert statement. Thay vào đó, chúng ta sử dụng verify directive để kiểm tra mock sau mỗi lần chạy, và pass test case nếu một method được gọi với đúng các argument chỉ định.

Trong test method 2, chúng ta test trường hợp sendEmail method không được gọi. Vì thế, times(0) với 0 là số lần mà method sendEmail đã gọi. Default times = 1 sẽ có thể được bỏ qua (trong test method 1).

Chú ý rằng Mock vẫn có thể được Stub khi cần. Bạn có thể nghĩ rằng mocksuperset (tập cha) của stub. Vì thế nên Mockito gọi cả 2 là Mock.

Verify arguments với Argument Captor

Trong ví dụ trước đó, chúng ta chỉ verify đơn giản một method được gọi hay không. Đôi lúc chúng ta cần chi tiết hơn, ngoài việc method được gọi hay không, chúng ta verify argument của method được test.

Mockito cung cấp cho chúng ta ArgumentCaptor để hỗ trợ việc kiểm tra method argument:

class MathUtils {
	public int add(int x, int y) {
		return x + y;
	}
}

@Test
void test() {
	MathUtils mockMathUtils = mock(MathUtils.class);
	when(mockMathUtils.add(1, 1)).thenReturn(2);

	ArgumentCaptor myCaptor = ArgumentCaptor.forClass(Integer.class);

	assertEquals(2, mockMathUtils.add(1, 1));

	verify(mockMathUtils).add(myCaptor.capture(), myCaptor.capture());

	List allValues = myCaptor.getAllValues();
	assertEquals(List.of(1, 1), allValues);
}

Trong ví dụ đơn giản ở trên, chúng ta sử dụng một ArgumentCaptor và định nghĩa nó là holder của một Integer class. Sau đó, trong verify directive, chúng ta sử dụng captor bằng cách gọi capture() method.

Tại điểm này, khi unit test hoàn thành, captor sẽ chứa chính xác argument gởi đến mock MathUtils khi method add() được gọi. Chúng ta có thể extract instance của argument bằng cách gọi getValue() hoặc toàn bộ argument bằng getAllValues().

Chú ý rằng argument có thể là bất kì một đối tượng Java phức tạp nào (vd: nested class, data structure /list...). Mockito có thể capture mà không gặp 1 vấn đề nào, và bạn có thể verify chúng theo cách bạn muốn.

Forming Dynamic Responses for Mocks

Sức mạnh tiếp theo của Mockito cho phép bạn có thể custom reponse từ mock mà phụ thuộc vào argument của lời gọi hàm. Đây là một kĩ thuật tiên tiến và chỉ cần thiết cho 1 vài trường hợp rất cụ thể trong unit test. Nếu được lựa chọn, tốt nhất là bạn nên trả về các kết quả được xác định trước thông qua mock/stub (như các ví dụ trước đó) để test của bạn trở để dễ đọc. Chỉ nên sử dụng Dynamic response như là kế sách cuối cùng.

Dynamic Manipulation of Arguments

public class CustomerDao {
    @PersistenceContext
    private EntityManager entityManager;
    private Logger logger;
    public void saveCustomer(String firstName, String lastName) {
        if (firstName == null || lastName==null) {
            logger.error("Missing customer information");
            throw new IllegalArgumentException();
        }
        Customer customer = new Customer(firstName, lastName);
        entityManager.persist(customer);
        entityManager.flush();
        logger.info("Saved customer with id {}", customer.getId());
    }
}

Bạn có thể thấy ngay việc unit test cho class này có một chút khó khăn. Mặc dù logic của DAO là rất cơ bản, nhưng vấn đề là khi một customer được lưu bằng persist() method, database ID của nó được gởi đến logger. Đối với ví dụ giả định này, code sẽ hoạt động tốt trong hệ thống thực tế, vì database sẽ gán ID cho đối tượng ngay khi customer được lưu. Tuy nhiên, làm thế nào chúng ta có thể sao chếp quá trình xử lý này trong unit test? Vì method persist không trả về argument nên chúng ta không thể mock nó với when-then directive. Tuy nhiên, Mockito vẫn có giải pháp:

public class CustomerDaoTest {
    // Class to be tested
    private CustomerDao customerDao;
    // Dependencies (will be mocked)
    private EntityManager entityManager;
    private Logger logger;
    @Before
    public void setup() {
        customerDao = new CustomerDao();

        entityManager = mock(EntityManager.class);
        customerDao.setEntityManager(entityManager);

        logger = mock(Logger.class);
        customerDao.setLogger(logger);
    }
    @Test
    public void happyPath() {
        doAnswer(new Answer<Void>() {
            public Void answer(InvocationOnMock invocation) {
                Customer customer = invocation.getArgument(0);
                customer.setId(123L);
                return null;
            }
        }).when(entityManager).persist(any(Customer.class));

        customerDao.saveCustomer("Suzan", "Ivanova");

        verify(logger).info("Saved customer with id {}", 123L);
    }
    @Test(expected = IllegalArgumentException.class)
    public void missingInformation() {
        customerDao.saveCustomer("Suzan", null);
    }
}

Unit test trên dựa trên doAnswer-when directive. Mockito cho phép chúng ta override để answer bất cứ method nào bằng cách implement Answer interface. Interface này có duy nhất 1 method cho phép chúng ta truy cập argument được truyền từ unit test.

Trong trường hợp cụ thể trong ví dụ trên, chúng ta biết rõ argument là một customer. Chúng ta fetch customerset database ID thành 123L, hoặc bất cứ giá trị nào bạn muốn. Chúng ta cũng hướng cho Mockito bind kết quả answer vào bất cứ any argument kiểu Customer. Ở đây, argument của persist method không được tạo bởi chúng ta, nhưng được tạo bở class được test, vì thế chúng ta không thể tạo một đối tượng mà dữ liệu test khớp với nó. Tuy nhiên, với Mockito doAnswer directive, chúng ta không cần biết trước bất kì điều gì, vì chúng ta thay đổi chúng trong runtime.

Dynamic Responses Based on Arguments

Một ví dụ thực tế hơn, trong đó answer của một mock phụ thuộc vào argument.

public class MassUserRegistration {
    private final EventRecorder eventRecorder;
    private final UserRepository userRepository;
    public MassUserRegistration(final EventRecorder eventRecorder, final UserRepository userRepository) {
        this.eventRecorder = eventRecorder;
        this.userRepository = userRepository;
    }
    private void register(String firstName, String lastName) {
        Customer newCustomer = userRepository.saveCustomer(firstName, lastName);
        Event event = new Event();
        event.setTimestamp(newCustomer.getSince());
        event.setCustomerName(newCustomer.getFullName());
        event.setType(Type.REGISTRATION);
        eventRecorder.recordEvent(event);
    }
    public void massRegister(List<Customer> rawCustomerNames) {
        for (Customer customer:rawCustomerNames) {
            register(customer.getFirstName(),customer.getLastName());
        }
    }
}

Ví dụ này, class truyền 1 list customersave vào UserRepository. Với mỗi customer, một event type REGISTRATION được phát hành.

Chúng ta cần test massRegister method vì registerprivate. Về lý thuyết, chúng ta có thể truyền 1 list chỉ có 1 customer trong unit test case. Nhưng trong thực tế, tốt nhất vẫn nên thử với một list chứa danh sách rất lớn customer. Code có thể đơn giản và hoàn toàn không có test trường hợp lỗi, nhưng trong hệ thống thực tế có thể có một số kiểm tra tính nhất quán trước khi customer được register. Một unit test thực tế (realistic unit test) cần truyền một danh sách lớn các customer với các vấn đề khác nhau, để tất cả các kiểm tra có thể được đánh giá trong quá trình unit testing.

Giả sử chúng ta muốn test list gồm 20 customer. Lúc saveRepository method trả về argument, về lý thuyết chúng ta có thể dùng when-then directive 20 lần để hướng nó chính xác output cần được gởi.

public class MassUserRegistrationTest {
    //Class to be tested
    private MassUserRegistration massUserRegistration;
    //Dependencies (will be mocked)
    private UserRepository userRepository;
    private EventRecorder eventRecorder;
    //Test data
    private List<Customer> sampleCustomers;
    @Before
    public void setup(){
        sampleCustomers = new ArrayList<>();

        eventRecorder = mock(EventRecorder.class);
        userRepository = mock(UserRepository.class);

        when(userRepository.saveCustomer(anyString(), 
                        anyString())).thenAnswer(new Answer<Customer>() {
            public Customer answer(InvocationOnMock invocation) throws Throwable {
                String firstName = invocation.getArgument(0);
                String lastName = invocation.getArgument(1);
                Customer newCustomer = new Customer(firstName, lastName);
                newCustomer.setFullName(firstName+" "+lastName);
                newCustomer.setSince(LocalDate.now());
                return newCustomer;
            }
        });
        massUserRegistration = new MassUserRegistration(eventRecorder,userRepository);
    }
    @Test
    public void registerTwentyAccounts(){
        sampleCustomers.add(new Customer("Susan", "Ivanova"));
        sampleCustomers.add(new Customer("Lyta", "Alexander"));
        sampleCustomers.add(new Customer("Vir", "Cotto"));
        sampleCustomers.add(new Customer("Stephen", "Frankling"));
        //[...20 customers redacted for brevity...]

        massUserRegistration.massRegister(sampleCustomers);

        ArgumentCaptor<Event> myCaptor = ArgumentCaptor.forClass(Event.class);
        verify(eventRecorder, times(sampleCustomers.size())).recordEvent(myCaptor.capture());

        List<Event> eventsThatWereSent = myCaptor.getAllValues();
        assertEquals(sampleCustomers.size(),eventsThatWereSent.size());
        for(int i=0;i< eventsThatWereSent.size();i++){
            Event event= eventsThatWereSent.get(i);
            assertNotNull(event.getTimestamp());
            assertEquals(Event.Type.REGISTRATION, event.getType());
            assertEquals(sampleCustomers.get(i).getFirstName()
            +" "+sampleCustomers.get(i).getLastName(),event.getCustomerName());
        }
    }
}

Trong test class, 2 việc cần lưu ý:

  1. Trong setup, chúng ta override saveCustomer method bằng Answer interface. Fetch 2 argument bằng anyString() matcher, tạo Customer instancefill thông tin cần thiết.
  2. Theo đó, với bất kì kích thước của dữ liệu test, UserRepository mock sẽ luôn trả về response chính xác cho class được kiểm thử.

Lưu ý rằng, unit test được viết sao cho kích thước dữ liệu đầu vào thực sự không liên quan. Chúng ta có thể mở rộng dữ liệu kiểm thử từ 20 lên 100 hoặc 1000 customer, riêng mockingverification code sẽ không đổi. Điều này không xảy ra nếu chúng ta tự đặt một response cho từng customer cụ thể.

Reference:

HAPPY CODING