一文讓你快速上手 Mockito 單元測試框架

mghio發表於2020-05-31

前言

在計算機程式設計中,單元測試是一種軟體測試方法,通過該方法可以測試原始碼的各個單元功能是否適合使用。為程式碼編寫單元測試有很多好處,包括可以及早的發現程式碼錯誤,促進更改,簡化整合,方便程式碼重構以及許多其它功能。使用 Java 語言的朋友應該用過或者聽過 Junit 就是用來做單元測試的,那麼為什麼我們還需要 Mockito 測試框架呢?想象一下這樣的一個常見的場景,當前要測試的類依賴於其它一些類物件時,如果用 Junit 來進行單元測試的話,我們就必須手動建立出這些依賴的物件,這其實是個比較麻煩的工作,此時就可以使用 Mockito 測試框架來模擬那些依賴的類,這些被模擬的物件在測試中充當真實物件的虛擬物件或克隆物件,而且 Mockito 同時也提供了方便的測試行為驗證。這樣就可以讓我們更多地去關注當前測試類的邏輯,而不是它所依賴的物件。

生成 Mock 物件方式

要使用 Mockito,首先需要在我們的專案中引入 Mockito 測試框架依賴,基於 Maven 構建的專案引入如下依賴即可:

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>3.3.3</version>
    <scope>test</scope>
</dependency>

如果是基於 Gradle 構建的專案,則引入如下依賴:

testCompile group: 'org.mockito', name: 'mockito-core', version: '3.3.3'

使用 Mockito 通常有兩種常見的方式來建立 Mock 物件。

1、使用 Mockito.mock(clazz) 方式

通過 Mockito 類的靜態方法 mock 來建立 Mock 物件,例如以下建立了一個 List 型別的 Mock 物件:

List<String> mockList = Mockito.mock(ArrayList.class);

由於 mock 方法是一個靜態方法,所以通常會寫成靜態匯入方法的方式,即 List mockList = mock(ArrayList.class)。

2、使用 @Mock 註解方式

第二種方式就是使用 @Mock 註解方式來建立 Mock 物件,使用該方式創需要注意的是要在執行測試方法前使用 MockitoAnnotations.initMocks(this) 或者單元測試類上加上 @ExtendWith(MockitoExtension.class) 註解,如下所示程式碼建立了一個 List 型別的 Mock 物件(PS: @BeforeEach 是 Junit 5 的註解,功能類似於 Junit 4 的 @Before 註解。):

/**
 * @author mghio
 * @date: 2020-05-30
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
//@ExtendWith(MockitoExtension.class)
public class MockitoTest {

  @Mock
  private List<String> mockList;

  @BeforeEach
  public void beforeEach() {
    MockitoAnnotations.initMocks(this);
  }
}

驗證性測試

Mockito 測試框架中提供了 Mockito.verify 靜態方法讓我們可以方便的進行驗證性測試,比如方法呼叫驗證、方法呼叫次數驗證、方法呼叫順序驗證等,下面看看具體的程式碼。

驗證方法單次呼叫

驗證方法單次呼叫的話直接 verify 方法後加上待驗證呼叫方法即可,以下程式碼的功能就是驗證 mockList 物件的 size 方法被呼叫一次。

/**
 * @author mghio
 * @date: 2020-05-28
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
@ExtendWith(MockitoExtension.class)
public class MockitoVerifyTest {

  @Mock
  List<String> mockList;

  @Test
  void verify_SimpleInvocationOnMock() {
    mockList.size();
    verify(mockList).size();
  }
}
驗證方法呼叫指定次數

除了驗證單次呼叫,我們有時候還需要驗證一些方法被呼叫多次或者指定的次數,那麼此時就可以使用 verify + times 方法來驗證方法呼叫指定次數,同時還可以結合 atLeast + atMost 方法來提供呼叫次數範圍,同時還有 never 等方法驗證不被呼叫等。

/**
 * @author mghio
 * @date: 2020-05-28
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
@ExtendWith(MockitoExtension.class)
public class MockitoVerifyTest {

  @Mock
  List<String> mockList;

  @Test
  void verify_NumberOfInteractionsWithMock() {
    mockList.size();
    mockList.size();

    verify(mockList, times(2)).size();
    verify(mockList, atLeast(1)).size();
    verify(mockList, atMost(10)).size();
  }
}
驗證方法呼叫順序

同時還可以使用 inOrder 方法來驗證方法的呼叫順序,下面示例驗證 mockList 物件的 size、add 和 clear 方法的呼叫順序。

/**
 * @author mghio
 * @date: 2020-05-28
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
@ExtendWith(MockitoExtension.class)
public class MockitoVerifyTest {

  @Mock
  List<String> mockList;

  @Test
  void verify_OrderedInvocationsOnMock() {
    mockList.size();
    mockList.add("add a parameter");
    mockList.clear();

    InOrder inOrder = inOrder(mockList);

    inOrder.verify(mockList).size();
    inOrder.verify(mockList).add("add a parameter");
    inOrder.verify(mockList).clear();
  }
}

以上只是列舉了一些簡單的驗證性測試,還有驗證測試方法呼叫超時以及更多的驗證測試可以通過相關官方文件探索學習。

驗證方法異常

異常測試我們需要使用 Mockito 框架提供的一些呼叫行為定義,Mockito 提供了 when(...).thenXXX(...) 來讓我們定義方法呼叫行為,以下程式碼定義了當呼叫 mockMap 的 get 方法無論傳入任何引數都會丟擲一個空指標 NullPointerException 異常,然後通過 Assertions.assertThrows 來驗證呼叫結果。

/**
 * @author mghio
 * @date: 2020-05-30
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
@ExtendWith(MockitoExtension.class)
public class MockitoExceptionTest {

  @Mock
  public Map<String, Integer> mockMap;

  @Test
  public void whenConfigNonVoidReturnMethodToThrowEx_thenExIsThrown() {
    when(mockMap.get(anyString())).thenThrow(NullPointerException.class);

    assertThrows(NullPointerException.class, () -> mockMap.get("mghio"));
  }
}

同時 when(...).thenXXX(...) 不僅可以定義方法呼叫丟擲異常,還可以定義呼叫方法後的返回結果,比如 when(mockMap.get("mghio")).thenReturn(21); 定義了當我們呼叫 mockMap 的 get 方法並傳入引數 mghio 時的返回結果是 21。這裡有一點需要注意,使用以上這種方式定義的 mock 物件測試實際並不會影響到物件的內部狀態,如下圖所示:

mockito_mock_object_thennoaffect.png

雖然我們已經在 mockList 物件上呼叫了 add 方法,但是實際上 mockList 集合中並沒有加入 mghio,這時候如果需要對 mock 物件有影響,那麼需要使用 spy 方式來生成 mock 物件。

public class MockitoTest {

  private List<String> mockList = spy(ArrayList.class);

  @Test
  public void add_spyMockList_thenAffect() {
    mockList.add("mghio");

    assertEquals(0, mockList.size());
  }
}

斷點後可以發現當使用 spy 方法建立出來的 mock 物件呼叫 add 方法後,mghio 被成功的加入到 mockList 集合當中。

mockito_mock_object_thenhasaffect.png

與 Spring 框架整合

Mockito 框架提供了 @MockBean 註解用來將 mock 物件注入到 Spring 容器中,該物件會替換容器中任何現有的相同型別的 bean,該註解在需要模擬特定bean(例如外部服務)的測試場景中很有用。如果使用的是 Spring Boot 2.0+ 並且當前容器中已有相同型別的 bean 的時候,需要設定 spring.main.allow-bean-definition-overriding 為 true(預設為 false)允許 bean 定義覆蓋。下面假設要測試通過使用者編碼查詢使用者的資訊,有一個資料庫操作層的 UserRepository,也就是我們等下要 mock 的物件,定義如下:

/**
 * @author mghio
 * @date: 2020-05-30
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
@Repository
public interface UserRepository {

  User findUserById(Long id);

}

還有使用者操作的相關服務 UserService 類,其定義如下所示:

/**
 * @author mghio
 * @date: 2020-05-30
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
@Service
public class UserService {

  private UserRepository userRepository;

  public UserService(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  public User findUserById(Long id) {
    return userRepository.findUserById(id);
  }
}

在測試類中使用 @MockBean 來標註 UserRepository 屬性表示這個型別的 bean 使用的是 mock 物件,使用 @Autowired 標註表示 UserService 屬性使用的是 Spring 容器中的物件,然後使用 @SpringBootTest 啟用 Spring 環境即可。

/**
 * @author mghio
 * @date: 2020-05-30
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
@SpringBootTest
public class UserServiceUnitTest {

  @Autowired
  private UserService userService;

  @MockBean
  private UserRepository userRepository;

  @Test
  public void whenUserIdIsProvided_thenRetrievedNameIsCorrect() {
    User expectedUser = new User(9527L, "mghio", "18288888880");
    when(userRepository.findUserById(9527L)).thenReturn(expectedUser);
    User actualUser = userService.findUserById(9527L);
    assertEquals(expectedUser, actualUser);
  }
}

Mockito 框架的工作原理

通過以上介紹可以發現, Mockito 非常容易使用並且可以方便的驗證一些方法的行為,相信你已經看出來了,使用的步驟是先建立一個需要 mock 的物件 Target ,該物件如下:

public class Target {

  public String foo(String name) {
    return String.format("Hello, %s", name);
  }

}

然後我們直接使用 Mockito.mock 方法和 when(...).thenReturn(...) 來生成 mock 物件並指定方法呼叫時的行為,程式碼如下:

@Test
public void test_foo() {
  String expectedResult = "Mocked mghio";
  when(mockTarget.foo("mghio")).thenReturn(expectedResult);
  String actualResult = mockTarget.foo("mghio");
  assertEquals(expectedResult, actualResult);
}

仔細觀察以上 when(mockTarget.foo("mghio")).thenReturn(expectedResult) 這行程式碼,首次使用我也覺得很奇怪,when 方法的入參竟然是方法的返回值 mockTarget.foo("mghio"),覺得正確的程式碼應該是這樣 when(mockTarget).foo("mghio"),但是這個寫法實際上無法進行編譯。既然 Target.foo 方法的返回值是 String 型別,那是不是可以使用如下方式呢?

Mockito.when("Hello, I am mghio").thenReturn("Mocked mghio");

結果是編譯通過,但是在執行時報錯:

mockito_when_method_runtime_error.png

從錯誤提示可以看出,when 方法需要一個方法呼叫的引數,實際上它只需要 more 物件方法呼叫在 when 方法之前就行,我們看看下面這個測試程式碼:

@Test
public void test_mockitoWhenMethod() {
  String expectedResult = "Mocked mghio";
  mockTarget.foo("mghio");
  when("Hello, I am mghio").thenReturn(expectedResult);
  String actualResult = mockTarget.foo("mghio");
  assertEquals(expectedResult, actualResult);
}

以上程式碼可以正常測試通過,結果如下:

mockito_mock_when_method_pass.png

為什麼這樣就可以正常測試通過?是因為當我們呼叫 mock 物件的 foo 方法時,Mockito 會攔截方法的呼叫然後將方法呼叫的詳細資訊儲存到 mock 物件的上下文中,當呼叫到 Mockito.when 方法時,實際上是從該上下文中獲取最後一個註冊的方法呼叫,然後把 thenReturn 的引數作為其返回值儲存,然後當我們的再次呼叫 mock 物件的該方法時,之前已經記錄的方法行為將被再次回放,該方法觸發攔截器重新呼叫並且返回我們在 thenReturn 方法指定的返回值。以下是 Mockito.when 方法的原始碼:

mockito_when_sourcecode.png

該方法裡面直接使用了 MockitoCore.when 方法,繼續跟進,該方法原始碼如下:

mockito_when_method_mockitocore_sourcecode.png

仔細觀察可以發現,在原始碼中並沒有用到引數 methodCall,而是從 MockingProgress 例項中獲取 OngoingStubbing 物件,這個 OngoingStubbing 物件就是前文所提到的上下文物件。個人感覺是 Mockito 為了提供簡潔易用的 API 然後才製造了 when 方法呼叫的這種“幻象”,簡而言之,Mockito 框架通過方法攔截在上下文中儲存和檢索方法呼叫詳細資訊來工作的。

如何實現一個微型的 Mock 框架

知道了 Mockito 的執行原理之後,接下來看看要如何自己去實現一個類似功能的 mock 框架出來,看到方法攔截這裡我相信你已經知道了,其實這就是 AOP 啊,但是通過閱讀其原始碼發現 Mockito 其實並沒有使用我們熟悉的 Spring AOP 或者 AspectJ 做的方法攔截,而是通過執行時增強庫 Byte Buddy 和反射工具庫 Objenesis 生成和初始化 mock 物件的。
現在,通過以上分析和原始碼閱讀可以定義出一個簡單版本的 mock 框架了,將自定義的 mock 框架命名為 imock。這裡有一點需要注意的是,Mockito 有一個好處是,它不需要進行初始化,可以直接通過其提供的靜態方法來立即使用它。在這裡我們也使用相同名稱的靜態方法,通過 Mockito 原始碼:

mockito_delegate_mockitocore.png

很容易看出 Mockito 類最終都是委託給 MockitoCore 去實現的功能,而其只提供了一些面向使用者易用的靜態方法,在這裡我們也定義一個這樣的代理物件 IMockCore,這個類中需要一個建立 mock 物件的方法 mock 和一個給方法設定返回值的 thenReturn 方法,同時該類中持有一個方法呼叫詳情 InvocationDetail 集合列表,這個類是用來記錄方法呼叫詳細資訊的,然後 when 方法僅返回列表中的最後一個 InvocationDetail,這裡列表可以直接使用 Java 中常用的 ArrayList 即可,這裡的 ArrayList 集合列表就實現了 Mockito 中的 OngoingStubbing 的功能。
根據方法的三要素方法名、方法引數和方法返回值很容易就可以寫出 InvocationDetail 類的程式碼,為了對方法在不同類有同名的情況區分,還需要加上類全稱欄位和重寫該類的 equals 和 hashCode 方法(判斷是否在呼叫方法集合列表時需要根據該方法判斷),程式碼如下所示:

/**
 * @author mghio
 * @date: 2020-05-30
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
public class InvocationDetail<T> {

  private String attachedClassName;

  private String methodName;

  private Object[] arguments;

  private T result;

  public InvocationDetail(String attachedClassName, String methodName, Object[] arguments) {
    this.attachedClassName = attachedClassName;
    this.methodName = methodName;
    this.arguments = arguments;
  }

  public void thenReturn(T t) {
    this.result = t;
  }

  public T getResult() {
    return result;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    InvocationDetail<?> behaviour = (InvocationDetail<?>) o;
    return Objects.equals(attachedClassName, behaviour.attachedClassName) &&
        Objects.equals(methodName, behaviour.methodName) &&
        Arrays.equals(arguments, behaviour.arguments);
  }

  @Override
  public int hashCode() {
    int result = Objects.hash(attachedClassName, methodName);
    result = 31 * result + Arrays.hashCode(arguments);
    return result;
  }
}

接下來就是如何去建立我們的 mock 物件了,在這裡我們也使用 Byte Buddy 和 Objenesis 庫來建立 mock 物件,IMockCreator 介面定義如下:

/**
 * @author mghio
 * @date: 2020-05-30
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
public interface IMockCreator {

  <T> T createMock(Class<T> mockTargetClass, List<InvocationDetail> behaviorList);

}

實現類 ByteBuddyIMockCreator 使用 Byte Buddy 庫在執行時動態生成 mock 類物件程式碼然後使用 Objenesis 去例項化該物件。程式碼如下:

/**
 * @author mghio
 * @date: 2020-05-30
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
public class ByteBuddyIMockCreator implements IMockCreator {

  private final ObjenesisStd objenesisStd = new ObjenesisStd();

  @Override
  public <T> T createMock(Class<T> mockTargetClass, List<InvocationDetail> behaviorList) {
    ByteBuddy byteBuddy = new ByteBuddy();

    Class<? extends T> classWithInterceptor = byteBuddy.subclass(mockTargetClass)
        .method(ElementMatchers.any())
        .intercept(MethodDelegation.to(InterceptorDelegate.class))
        .defineField("interceptor", IMockInterceptor.class, Modifier.PRIVATE)
        .implement(IMockIntercepable.class)
        .intercept(FieldAccessor.ofBeanProperty())
        .make()
        .load(getClass().getClassLoader(), Default.WRAPPER).getLoaded();

    T mockTargetInstance = objenesisStd.newInstance(classWithInterceptor);
    ((IMockIntercepable) mockTargetInstance).setInterceptor(new IMockInterceptor(behaviorList));

    return mockTargetInstance;
  }
}

基於以上分析我們可以很容易寫出建立 mock 物件的 IMockCore 類的程式碼如下:

/**
 * @author mghio
 * @date: 2020-05-30
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
public class IMockCore {

  private final List<InvocationDetail> invocationDetailList = new ArrayList<>(8);

  private final IMockCreator mockCreator = new ByteBuddyIMockCreator();

  public <T> T mock(Class<T> mockTargetClass) {
    T result = mockCreator.createMock(mockTargetClass, invocationDetailList);
    return result;
  }

  @SuppressWarnings("unchecked")
  public <T> InvocationDetail<T> when(T methodCall) {
    int currentSize = invocationDetailList.size();
    return (InvocationDetail<T>) invocationDetailList.get(currentSize - 1);
  }
}

提供給使用者的類 IMock 只是對 IMockCore 進行的簡單呼叫而已,程式碼如下:

/**
 * @author mghio
 * @date: 2020-05-30
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
public class IMock {

  private static final IMockCore IMOCK_CORE = new IMockCore();

  public static <T> T mock(Class<T> clazz) {
    return IMOCK_CORE.mock(clazz);
  }

  public static <T> InvocationDetail when(T methodCall) {
    return IMOCK_CORE.when(methodCall);
  }
}

通過以上步驟,我們就已經實現了一個微型的 mock 框架了,下面來個實際例子測試一下,首先建立一個 Target 物件:

/**
 * @author mghio
 * @date: 2020-05-30
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
public class Target {

  public String foo(String name) {
    return String.format("Hello, %s", name);
  }

}

然後編寫其對應的測試類 IMockTest 類如下:

/**
 * @author mghio
 * @date: 2020-05-30
 * @version: 1.0
 * @description:
 * @since JDK 1.8
 */
public class IMockTest {

  @Test
  public void test_foo_method() {
    String exceptedResult = "Mocked mghio";
    Target mockTarget = IMock.mock(Target.class);

    IMock.when(mockTarget.foo("mghio")).thenReturn(exceptedResult);

    String actualResult = mockTarget.foo("mghio");

    assertEquals(exceptedResult, actualResult);
  }

}

以上測試的可以正常執行,達到了和 Mockito 測試框架一樣的效果,執行結果如下:

mockito_imock_test_pass.png

上面只是列出了一些關鍵類的原始碼,自定義 IMock 框架的所有程式碼已上傳至 Github 倉庫 imock,感興趣的朋友可以去看看。

總結

本文只是介紹了 Mockito 的一些使用方法,這只是該框架提供的最基礎功能,更多高階的用法可以去官網閱讀相關的文件,然後介紹了框架中 when(...).thenReturn(...) 定義行為方法的實現方式並按照其原始碼思路實現了一個相同功能的簡易版的 imock 。雖然進行單元測試有很多優點,但是也不可盲目的進行單元測試,在大部分情況下只要做好對專案中邏輯比較複雜、不容易理解的核心業務模組以及專案中公共依賴的模組的單元測試就可以了。


參考文章

Mockito
Objenesis
Byte Buddy

相關文章