2024/12/9

Mockito 簡介

在做 Java 專案測試時,常見的方法是使用 JUnit 測試框架。但在實際專案測試時,最常遇到的問題是,準備測試資料。專案程式通常都會連接資料庫,然後透過商業邏輯或一些運算,再將結果存回資料庫。

這在測試時就會遇到很多問題,例如測試案例需要某些特殊的使用者在某個特別的狀態,例如未付款的狀態,然後程式經過了某些付款程序,讓資料變成已付款狀態,又存回資料庫,更新了這個使用者的狀態。這裡遇到的問題是,沒辦法一直產生未付款狀態的使用者,這就導致每一次要測試付款程序時,就要重新準備一次測試資料。

如果該資料跟其他的測試有相依性,又讓這個問題更複雜了。

在使用 JUnit 時,常常會遇到測試的對象裡面因為包含了其他類別的物件,需要先準備/建立這些前置的物件,才能真正地去測試現在想要測試的對象。例如專案中的商業邏輯程式需要使用到資料庫的連線,就必須確實準備一個資料庫,並建立該 DAO 物件,然後才能進行測試。

這跟剛剛提到的測試資料是類似的問題,就是外部資料相依性問題。

假設目前的類別關係如下

flowchart LR
    A --> B --> D & E
    A --> C

因為 A 相依於 B 與 C,故這時候需要製作 B 與 C 的 mock 物件,用來作 A 的測試

flowchart LR
    A --> B[mock of B]
    A --> C[mock of C]

# TDD/BDD and Test Double 這篇文章提到 unit test 必須要

  • 是最小的測試單位

  • 一個案例只能測試一個方法

  • 測試案例之間沒有相依性

  • 沒有外部相依性

  • 不具備邏輯

Mockito 就是用來輔助 JUnit,製作 mock 物件,也就是達成上面所說的沒有外部相依性,也可以讓測試案例之間沒有相依性。Mock 模擬對象 是一種模擬真實對象行為的假的物件,這個假物件可以用來測試其他程式的行為。

單元測試之 mock/stub/spy/fake ? 這邊提到了幾個名詞的差異

  • mock

    模擬的假物件,可讓程式使用 mock 物件驗證商業邏輯或是互動是否正確。mock 有可能會造成測試失敗

  • stub

    也是假物件,但有點替身的意思,跟 mock 類似,是取代真實物件的假物件,使用時,該替身不會造成測試失敗

  • fake

    完全不做事情的假物件,測試僅僅會經過這些物件,但不會做任何驗證,不會造成測試失敗,也就是 stub 的意思

  • dummy

    空物件,只用來填補缺少的參數,或是其他已經測試完成的物件,測試僅僅會經過這些物件,但不會做任何驗證,不會造成測試失敗,也就是 stub 的意思

  • spy

    通常 mock 是製作整個假物件,而 spy 只會偽造類別裡面的某些 method,如果針對該偽造的方法有做驗證測試,就將 spy 視為 mock。如果沒有驗證,那就視為 stub

Mockito

使用 mockito 基本需要了解這三個部分

  • mock

    static method,用來產生 mock 物件

  • when/then

    為剛剛用 mock 產生的物件,自訂其行為,也就是自訂某些 method 回傳的資料

  • verify

    用來檢查 mock 物件的使用狀況

測試準備

測試前,先製作一個要被 mock 的類別

public class DataDAO {
    public String getDataById(String id) {
        return id;
    }

    public int getDataSize() {
        return 0;
    }

    public boolean add(String data) {
        return true;
    }

    public void clear() {
    }
}

引用 libary

        <!-- https://mvnrepository.com/artifact/org.mockito/mockito-core -->
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <version>5.12.0</version>
            <scope>test</scope>
        </dependency>

        <!-- https://mvnrepository.com/artifact/org.mockito/mockito-inline -->
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-inline</artifactId>
            <version>5.2.0</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
            <version>5.9.1</version>
            <scope>compile</scope>
        </dependency>

mock

  • 用類別定義產生 mock object

    public static <T> T mock(Class<T> classToMock)

  • 用類別名稱產生 mock object 後,指定這個 mock object 的名稱

    public static <T> T mock(Class<T> classToMock, String name)

  • 產生 mock object,自訂 Answer

    public static <T> T mock(Class<T> classToMock, Answer defaultAnswer)

  • 產生 MockSettings 自訂 Answer

    private String randomId() {
        return UUID.randomUUID().toString();
    }
    @Test
    public void mock_test1() {
        // public static <T> T mock(Class<T> classToMock)
        // 用類別定義產生 mock object

        // 利用 mock 產生 DataDAO 的 mock object
        DataDAO dataDAOmock = mock(DataDAO.class);
        // 當透過這個 mock object 呼叫 add method 時,永遠回傳 false
        when(dataDAOmock.add(anyString())).thenReturn(false);

        boolean added = dataDAOmock.add( randomId() );

        // verify 可檢查是否有呼叫 add method
        verify(dataDAOmock).add(anyString());
        // 以 JUnit 檢查 add method 的 return value
        assertFalse(added);
    }

    @Test
    public void mock_test2_name() {
        // public static <T> T mock(Class<T> classToMock, String name)
        // 用類別名稱產生 mock object 後,指定這個 mock object 的名稱

        // 利用 mock 產生 DataDAO 的 mock object
        DataDAO dataDAOmock = mock(DataDAO.class, "test2DataDAOMock");
        // 當透過這個 mock object 呼叫 add method 時,永遠回傳 false
        when(dataDAOmock.add(anyString())).thenReturn(false);

        boolean added = dataDAOmock.add( randomId() );

        TooFewActualInvocations exception = assertThrows(TooFewActualInvocations.class, () -> {
            verify(dataDAOmock, times(2)).add(anyString());
        });
//        exception.printStackTrace();
//        rg.mockito.exceptions.verification.TooFewActualInvocations:
//        test2DataDAOMock.add(<any string>);
//        Wanted 2 times:
//-> at mock.DataDAO.add(DataDAO.java:13)
//        But was 1 time:
//-> at mock.DataDAOMockitoTest.test2(DataDAOMockitoTest.java:41)
//        ......
        assertTrue(exception.getMessage().contains("test2DataDAOMock.add"));
    }

    static class CustomAnswer implements Answer<Boolean> {
        @Override
        public Boolean answer(InvocationOnMock invocation) throws Throwable {
            return false;
        }
    }
    @Test
    public void mock_test3_answer() {
        // public static <T> T mock(Class<T> classToMock, Answer defaultAnswer)
        // 產生 mock object,自訂 Answer
        DataDAO dataDAOmock = mock(DataDAO.class, new CustomAnswer());

        boolean added = dataDAOmock.add( randomId() );

        verify(dataDAOmock).add(anyString());
        assertFalse(added);
    }

    @Test
    public void mock_test4_MockSettings() {
        // 產生 MockSettings 自訂 Answer
        MockSettings customSettings = withSettings().defaultAnswer(new CustomAnswer());

        DataDAO dataDAOmock = mock(DataDAO.class, customSettings);
        boolean added = dataDAOmock.add( randomId() );
        verify(dataDAOmock).add(anyString());
        assertFalse(added);
    }

when/then

  • 當透過這個 mock object 呼叫 method 時,永遠回傳某個值

  • 用 doReturn 方式設定 return 的結果

  • 設定 method 呼叫時,會 throw Excpetion

  • 設定 void return 的 method,會 throw Exception

  • 設定 method 多次呼叫時,有不同的 return 結果

  • 設定 spy 的行為,spy 是對一部分的 method 做 mock

  • 設定呼叫 mock 的某個 method 要呼叫真實的物件的 method

  • 自訂 Answer

    @Test
    public void when_test1() {
        // 利用 mock 產生 DataDAO 的 mock object
        DataDAO dataDAOmock = mock(DataDAO.class);
        // 當透過這個 mock object 呼叫 add method 時,永遠回傳 false
        when(dataDAOmock.add(anyString())).thenReturn(false);

        boolean added = dataDAOmock.add( randomId() );
        assertFalse(added);

        // 用另一種方式設定 return 的結果
        doReturn(false).when(dataDAOmock).add(anyString());
        boolean added2 = dataDAOmock.add( randomId() );
        assertFalse(added2);

        // 設定 method 呼叫時,會 throw Excpetion
        when(dataDAOmock.add(anyString())).thenThrow(IllegalStateException.class);
        IllegalStateException exception = assertThrows(IllegalStateException.class, () -> {
            dataDAOmock.add( randomId() );
        });

        // 設定 void return 的 method,會 throw Exception
        doThrow(NullPointerException.class).when(dataDAOmock).clear();
        assertThrows(NullPointerException.class, () -> dataDAOmock.clear());

        // 設定 method 多次呼叫時,有不同的 return 結果
        DataDAO dataDAOmock2 = mock(DataDAO.class);
        when(dataDAOmock2.add(anyString()))
                .thenReturn(false)
                .thenThrow(IllegalStateException.class);
        assertThrows(IllegalStateException.class, () -> {
            dataDAOmock2.add( randomId() );
            dataDAOmock2.add( randomId() );
        });

        // 設定 spy 的行為
        // mock 是接管所有物件的 method,但 spy 則是對一部分的 method 做 mock
        DataDAO dataDAO = new DataDAO();
        DataDAO spy = spy(dataDAO);
        doThrow(NullPointerException.class).when(spy).getDataSize();
        assertThrows(NullPointerException.class, () -> spy.getDataSize());
        assertEquals("test", spy.getDataById("test"));

        // 設定呼叫 mock 的某個 method 要呼叫真實的物件的 method
        DataDAO dataDAOmock3 = mock(DataDAO.class);
        when(dataDAOmock3.getDataSize()).thenCallRealMethod();
        assertEquals( 0, dataDAOmock3.getDataSize());

        // 自訂 Answer
        doAnswer(invocation -> "Always the same").when(dataDAOmock3).getDataById(anyString());
        String data = dataDAOmock3.getDataById("1");
        assertEquals("Always the same", data);
    }

verify

  • 檢查是否有呼叫某個 method

  • 檢查呼叫某個 method 的次數

  • 檢查是否沒有使用 mock object

  • 檢查是不是沒有呼叫某個 method

  • 檢查是不是沒有非預期的操作互動 verifyNoMoreInteractions

  • 檢查 呼叫 method 操作的順序

  • 檢查是不是沒有呼叫某個 method

  • 檢查呼叫 method 的次數,至少 或是 最多 幾次

  • 檢查是否有使用某個參數呼叫 method

  • 檢查是否有使用任意參數呼叫 method

  • 利用 argument capture 檢查

    @Test
    public void verify_test1() {
        DataDAO dataDAOmock = mock(DataDAO.class);
        dataDAOmock.getDataSize();
        // 檢查是否有呼叫某個 method
        verify(dataDAOmock).getDataSize();

        // 檢查呼叫某個 method 的次數
        verify(dataDAOmock, times(1)).getDataSize();

        DataDAO dataDAOmock2 = mock(DataDAO.class);
        // 檢查是否沒有使用 mock object
        verifyNoInteractions(dataDAOmock2);
        // 檢查是不是沒有呼叫某個 method
        verify(dataDAOmock2, times(0)).getDataSize();

        // 檢查是不是沒有非預期的操作互動 verifyNoMoreInteractions
        DataDAO dataDAOmock3 = mock(DataDAO.class);
        dataDAOmock3.getDataSize();
        dataDAOmock3.clear();
        verify(dataDAOmock3).getDataSize();
        assertThrows(NoInteractionsWanted.class, () -> verifyNoMoreInteractions(dataDAOmock3));

        // 檢查 呼叫 method 操作的順序
        DataDAO dataDAOmock4 = mock(DataDAO.class);
        dataDAOmock4.getDataSize();
        dataDAOmock4.add("a parameter");
        dataDAOmock4.clear();

        InOrder inOrder = Mockito.inOrder(dataDAOmock4);
        inOrder.verify(dataDAOmock4).getDataSize();
        inOrder.verify(dataDAOmock4).add("a parameter");
        inOrder.verify(dataDAOmock4).clear();

        // 檢查是不是沒有呼叫某個 method
        verify(dataDAOmock4, never()).getDataById("");

        // 檢查呼叫 method 的次數,至少 或是 最多 幾次
        DataDAO dataDAOmock5 = mock(DataDAO.class);
        dataDAOmock5.clear();
        dataDAOmock5.clear();
        dataDAOmock5.clear();
        verify(dataDAOmock5, atLeast(1)).clear();
        verify(dataDAOmock5, atMost(5)).clear();

        // 檢查是否有使用某個參數呼叫 method
        DataDAO dataDAOmock6 = mock(DataDAO.class);
        dataDAOmock6.getDataById("test1");
        verify(dataDAOmock6).getDataById("test1");
        assertThrows(WantedButNotInvoked.class, () -> verify(dataDAOmock6).getDataById("test"));
        // 檢查是否有使用任意參數呼叫 method
        verify(dataDAOmock6).getDataById(anyString());

        // 利用 argument capture 檢查
        DataDAO dataDAOmock7 = mock(DataDAO.class);
        dataDAOmock7.getDataById("someElement");

        ArgumentCaptor<String> argumentCaptor = ArgumentCaptor.forClass(String.class);
        verify(dataDAOmock7).getDataById(argumentCaptor.capture());

        String capturedArgument = argumentCaptor.getValue();
        assertEquals( "someElement", capturedArgument);
    }

Note

在執行測試時,有遇到這樣的錯誤資訊

OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended

原因是 VM warning: Sharing is only supported for boot loader classes · Issue #3111 · mockito/mockito · GitHub

CDS: Class Data Sharing 將一組類別預處理為共享存檔文件,然後可以在運行時進行內存映射以減少啓動時間。主要目的是減少啓動時間。應用程式對於它使用的核心類別的數量越小,節省的啓動時間部分就越大。自 JDK 12 開始,就預先打包了一份預設的 CDS 檔案。

解決方式是加上 JVM 執行參數

-Xshare:off

Reference

Mockito - 維基百科,自由的百科全書

Mockito - mockito-core 5.12.0 javadoc

# SpringBoot - 單元測試工具 Mockito

Mockito 简明教程| waylau.com

Mockito's Mock Methods | Baeldung

Mockito When/Then Cookbook | Baeldung

Mockito Verify Cookbook | Baeldung

沒有留言:

張貼留言