Java程式設計技巧

皇家線上客服16228719999發表於2021-05-22

Java程式設計技巧之單元測試用例編寫流程 一 編寫單元測試用例

1 測試框架簡介

Mockito是一個單元測試模擬框架,可以讓你寫出優雅、簡潔的單元測試程式碼。Mockito採用了模擬技術,模擬了一些在應用中依賴的複雜物件,從而把測試物件和依賴物件隔離開來。

PowerMock是一個單元測試模擬框架,是在其它單元測試模擬框架的基礎上做出擴充套件。通過提供定製的類載入器以及一些位元組碼篡改技術的應用,PowerMock實現了對靜態方法、構造方法、私有方法以及final方法的模擬支援等強大的功能。但是,正因為PowerMock進行了位元組碼篡改,導致部分單元測試用例並不被JaCoco統計覆蓋率。

通過作者多年單元測試的編寫經驗,優先推薦使用Mockito提供的功能;只有在Mockito提供的功能不能滿足需求時,才會採用PowerMock提供的功能;但是,不推薦使用影響JaCoco統計覆蓋率的PowerMock功能。在本文中,我們也不會對影響JaCoco統計覆蓋率的PowerMock功能進行介紹。

下面,將以Mockito為主、以PowerMock為輔,介紹一下如何編寫單元測試用例。

2 測試框架引入

為了引入Mockito和PowerMock包,需要在maven專案的pom.xml檔案中加入以下包依賴:

org.powermock powermock-core ${powermock.version} test org.powermock powermock-api-mockito2 ${powermock.version} test org.powermock powermock-module-junit4 ${powermock.version} test

其中,powermock.version為2.0.9,為當前的最新版本,可根據實際情況修改。在PowerMock包中,已經包含了對應的Mockito和JUnit包,所以無需單獨引入Mockito和JUnit包。

3 典型程式碼案例

一個典型的服務程式碼案例如下:

/** * 使用者服務類 / @Service public class UserService { /* 服務相關 / /* 使用者DAO / @Autowired private UserDAO userDAO; /* 標識生成器 */ @Autowired private IdGenerator idGenerator;

/** 引數相關 */
/** 可以修改 */
@Value("${userService.canModify}")
private Boolean canModify;

/**
 * 建立使用者
 * 
 * @param userCreate 使用者建立
 * @return 使用者標識
 */
public Long createUser(UserVO userCreate) {
    // 獲取使用者標識
    Long userId = userDAO.getIdByName(userCreate.getName());

    // 根據存在處理
    // 根據存在處理: 不存在則建立
    if (Objects.isNull(userId)) {
        userId = idGenerator.next();
        UserDO create = new UserDO();
        create.setId(userId);
        create.setName(userCreate.getName());
        userDAO.create(create);
    }
    // 根據存在處理: 已存在可修改
    else if (Boolean.TRUE.equals(canModify)) {
        UserDO modify = new UserDO();
        modify.setId(userId);
        modify.setName(userCreate.getName());
        userDAO.modify(modify);
    }
    // 根據存在處理: 已存在禁修改
    else {
        throw new UnsupportedOperationException("不支援修改");
    }

    // 返回使用者標識
    return userId;
}

}

4 測試用例編寫

採用Mockito和PowerMock單元測試模擬框架,編寫的單元測試用例如下:

UserServiceTest.java

/** * 使用者服務測試類 / @RunWith(PowerMockRunner.class) public class UserServiceTest { /* 模擬依賴物件 / /* 使用者DAO / @Mock private UserDAO userDAO; /* 標識生成器 */ @Mock private IdGenerator idGenerator;

/** 定義被測物件 */
/** 使用者服務 */
@InjectMocks
private UserService userService;

/**
 * 在測試之前
 */
@Before
public void beforeTest() {
    // 注入依賴物件
    Whitebox.setInternalState(userService, "canModify", Boolean.TRUE);
}

/**
 * 測試: 建立使用者-新
 */
@Test
public void testCreateUserWithNew() {
    // 模擬依賴方法
    // 模擬依賴方法: userDAO.getByName
    Mockito.doReturn(null).when(userDAO).getIdByName(Mockito.anyString());
    // 模擬依賴方法: idGenerator.next
    Long userId = 1L;
    Mockito.doReturn(userId).when(idGenerator).next();

    // 呼叫被測方法
    String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
    UserVO userCreate = JSON.parseObject(text, UserVO.class);
    Assert.assertEquals("使用者標識不一致", userId, userService.createUser(userCreate));

    // 驗證依賴方法
    // 驗證依賴方法: userDAO.getByName
    Mockito.verify(userDAO).getIdByName(userCreate.getName());
    // 驗證依賴方法: idGenerator.next
    Mockito.verify(idGenerator).next();
    // 驗證依賴方法: userDAO.create
    ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
    Mockito.verify(userDAO).create(userCreateCaptor.capture());
    text = ResourceHelper.getResourceAsString(getClass(), "userCreateDO.json");
    Assert.assertEquals("使用者建立不一致", text, JSON.toJSONString(userCreateCaptor.getValue()));

    // 驗證依賴物件
    Mockito.verifyNoMoreInteractions(idGenerator, userDAO);
}

/**
 * 測試: 建立使用者-舊
 */
@Test
public void testCreateUserWithOld() {
    // 模擬依賴方法
    // 模擬依賴方法: userDAO.getByName
    Long userId = 1L;
    Mockito.doReturn(userId).when(userDAO).getIdByName(Mockito.anyString());

    // 呼叫被測方法
    String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
    UserVO userCreate = JSON.parseObject(text, UserVO.class);
    Assert.assertEquals("使用者標識不一致", userId, userService.createUser(userCreate));

    // 驗證依賴方法
    // 驗證依賴方法: userDAO.getByName
    Mockito.verify(userDAO).getIdByName(userCreate.getName());
    // 驗證依賴方法: userDAO.modify
    ArgumentCaptor<UserDO> userModifyCaptor = ArgumentCaptor.forClass(UserDO.class);
    Mockito.verify(userDAO).modify(userModifyCaptor.capture());
    text = ResourceHelper.getResourceAsString(getClass(), "userModifyDO.json");
    Assert.assertEquals("使用者修改不一致", text, JSON.toJSONString(userModifyCaptor.getValue()));

    // 驗證依賴物件
    Mockito.verifyNoInteractions(idGenerator);
    Mockito.verifyNoMoreInteractions(userDAO);
}

/**
 * 測試: 建立使用者-異常
 */
@Test
public void testCreateUserWithException() {
    // 注入依賴物件
    Whitebox.setInternalState(userService, "canModify", Boolean.FALSE);

    // 模擬依賴方法
    // 模擬依賴方法: userDAO.getByName
    Long userId = 1L;
    Mockito.doReturn(userId).when(userDAO).getIdByName(Mockito.anyString());

    // 呼叫被測方法
    String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
    UserVO userCreate = JSON.parseObject(text, UserVO.class);
    UnsupportedOperationException exception = Assert.assertThrows("返回異常不一致",
        UnsupportedOperationException.class, () -> userService.createUser(userCreate));
    Assert.assertEquals("異常訊息不一致", "不支援修改", exception.getMessage());
}

}

userCreateVO.json

{"name":"test"}

userCreateDO.json

{"id":1,"name":"test"}

userModifyDO.json

{"id":1,"name":"test"}

通過執行以上測試用例,可以看到對原始碼進行了100%的行覆蓋。

二 測試用例編寫流程

通過上一章編寫Java類單元測試用例的實踐,可以總結出以下Java類單元測試用例的編寫流程:

圖片

單元測試用例編寫流程

上面一共有3個測試用例,這裡僅以測試用例testCreateUserWithNew(測試:建立使用者-新)為例說明。

1 定義物件階段

第1步是定義物件階段,主要包括定義被測物件、模擬依賴物件(類成員)、注入依賴物件(類成員)3大部分。

定義被測物件

在編寫單元測試時,首先需要定義被測物件,或直接初始化、或通過Spy包裝……其實,就是把被測試服務類進行例項化。

/** 定義被測物件 / /* 使用者服務 */ @InjectMocks private UserService userService;

模擬依賴物件(類成員)

在一個服務類中,我們定義了一些類成員物件——服務(Service)、資料訪問物件(DAO)、引數(Value)等。在Spring框架中,這些類成員物件通過@Autowired、@Value等方式注入,它們可能涉及複雜的環境配置、依賴第三方介面服務……但是,在單元測試中,為了解除對這些類成員物件的依賴,我們需要對這些類成員物件進行模擬。

/** 模擬依賴物件 / /* 使用者DAO / @Mock private UserDAO userDAO; /* 標識生成器 */ @Mock private IdGenerator idGenerator;

注入依賴物件(類成員)

當模擬完這些類成員物件後,我們需要把這些類成員物件注入到被測試類的例項中。以便在呼叫被測試方法時,可能使用這些類成員物件,而不至於丟擲空指標異常。

/** 定義被測物件 / /* 使用者服務 / @InjectMocks private UserService userService; /* * 在測試之前 */ @Before public void beforeTest() { // 注入依賴物件 Whitebox.setInternalState(userService, "canModify", Boolean.TRUE); }

2 模擬方法階段

第2步是模擬方法階段,主要包括模擬依賴物件(引數或返回值)、模擬依賴方法2大部分。

模擬依賴物件(引數或返回值)

通常,在呼叫一個方法時,需要先指定方法的引數,然後獲取到方法的返回值。所以,在模擬方法之前,需要先模擬該方法的引數和返回值。

Long userId = 1L;

模擬依賴方法

在模擬完依賴的引數和返回值後,就可以利用Mockito和PowerMock的功能,進行依賴方法的模擬。如果依賴物件還有方法呼叫,還需要模擬這些依賴物件的方法。

// 模擬依賴方法 // 模擬依賴方法: userDAO.getByName Mockito.doReturn(null).when(userDAO).getIdByName(Mockito.anyString()); // 模擬依賴方法: idGenerator.next Mockito.doReturn(userId).when(idGenerator).next();

3 呼叫方法階段

第3步是呼叫方法階段,主要包括模擬依賴物件(引數)、呼叫被測方法、驗證引數物件(返回值)3步。

模擬依賴物件(引數)

在呼叫被測方法之前,需要模擬被測方法的引數。如果這些引數還有方法呼叫,還需要模擬這些引數的方法。

String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json"); UserVO userCreate = JSON.parseObject(text, UserVO.class);

呼叫被測方法

在準備好引數物件後,就可以呼叫被測試方法了。如果被測試方法有返回值,需要定義變數接收返回值;如果被測試方法要丟擲異常,需要指定期望的異常。

userService.createUser(userCreate)

驗證資料物件(返回值)

在呼叫被測試方法後,如果被測試方法有返回值,需要驗證這個返回值是否符合預期;如果被測試方法要丟擲異常,需要驗證這個異常是否滿足要求。

Assert.assertEquals("使用者標識不一致", userId, userService.createUser(userCreate));

4 驗證方法階段

第4步是驗證方法階段,主要包括驗證依賴方法、驗證資料物件(引數)、驗證依賴物件3步。

驗證依賴方法

作為一個完整的測試用例,需要對每一個模擬的依賴方法呼叫進行驗證。

// 驗證依賴方法 // 驗證依賴方法: userDAO.getByName Mockito.verify(userDAO).getIdByName(userCreate.getName()); // 驗證依賴方法: idGenerator.next Mockito.verify(idGenerator).next(); // 驗證依賴方法: userDAO.create ArgumentCaptor userCreateCaptor = ArgumentCaptor.forClass(UserDO.class); Mockito.verify(userDAO).create(userCreateCaptor.capture());

驗證資料物件(引數)

對應一些模擬的依賴方法,有些引數物件是被測試方法內部生成的。為了驗證程式碼邏輯的正確性,就需要對這些引數物件進行驗證,看這些引數物件值是否符合預期。

text = ResourceHelper.getResourceAsString(getClass(), "userCreateDO.json"); Assert.assertEquals("使用者建立不一致", text, JSON.toJSONString(userCreateCaptor.getValue()));

驗證依賴物件

作為一個完整的測試用例,應該保證每一個模擬的依賴方法呼叫都進行了驗證。正好,Mockito提供了一套方法,用於驗證模擬物件所有方法呼叫都得到了驗證。

// 驗證依賴物件 Mockito.verifyNoMoreInteractions(idGenerator, userDAO);

三 定義被測物件

在編寫單元測試時,首先需要定義被測物件,或直接初始化、或通過Spy包裝……其實,就是把被測試服務類進行例項化。

1 直接構建物件

直接構建一個物件,總是簡單又直接。

UserService userService = new UserService();

2 利用Mockito.spy方法

Mockito提供一個spy功能,用於攔截那些尚未實現或不期望被真實呼叫的方法,預設所有方法都是真實方法,除非主動去模擬對應方法。所以,利用spy功能來定義被測物件,適合於需要模擬被測類自身方法的情況,適用於普通類、介面和虛基類。

UserService userService = Mockito.spy(new UserService()); UserService userService = Mockito.spy(UserService.class); AbstractOssService ossService = Mockito.spy(AbstractOssService.class);

3 利用@Spy註解

@Spy註解跟Mockito.spy方法一樣,可以用來定義被測物件,適合於需要模擬被測類自身方法的情況,適用於普通類、介面和虛基類。@Spy註解需要配合@RunWith註解使用。

@RunWith(PowerMockRunner.class) public class CompanyServiceTest { @Spy private UserService userService = new UserService();

...

}

注意:@Spy註解物件需要初始化。如果是虛基類或介面,可以用Mockito.mock方法例項化。

4 利用@InjectMocks註解

@InjectMocks註解用來建立一個例項,並將其它物件(@Mock、@Spy或直接定義的物件)注入到該例項中。所以,@InjectMocks註解本身就可以用來定義被測物件。@InjectMocks註解需要配合@RunWith註解使用。

@RunWith(PowerMockRunner.class) public class UserServiceTest { @InjectMocks private UserService userService; ...

}

四 模擬依賴物件

在編寫單元測試用例時,需要模擬各種依賴物件——類成員、方法引數和方法返回值。

1 直接構建物件

如果需要構建一個物件,最簡單直接的方法就是——定義物件並賦值。

Long userId = 1L; String userName = "admin"; UserDO user = new User(); user.setId(userId); user.setName(userName); List userIdList = Arrays.asList(1L, 2L, 3L);

2 反序列化物件

如果物件欄位或層級非常龐大,採用直接構建物件方法,可能會編寫大量構建程式程式碼。這種情況,可以考慮反序列化物件,將會大大減少程式程式碼。由於JSON字串可讀性高,這裡就以JSON為例,介紹反序列化物件。

反序列化模型物件

String text = ResourceHelper.getResourceAsString(getClass(), "user.json"); UserDO user = JSON.parseObject(text, UserDO.class);

反序列化集合物件

String text = ResourceHelper.getResourceAsString(getClass(), "userList.json"); List userList = JSON.parseArray(text, UserDO.class);

反序列化對映物件

String text = ResourceHelper.getResourceAsString(getClass(), "userMap.json"); Map userMap = JSON.parseObject(text, new TypeReference>() {});

3 利用Mockito.mock方法

Mockito提供一個mock功能,用於攔截那些尚未實現或不期望被真實呼叫的方法,預設所有方法都已被模擬——方法為空並返回預設值(null或0),除非主動執行doCallRealMethod或thenCallRealMethod操作,才能夠呼叫真實的方法。

利用Mockito.mock方法模擬依賴物件,主要用於以下幾種情形:

只使用類例項,不使用類屬性;

類屬性太多,但使用其中少量屬性(可以mock屬性返回值);

類是介面或虛基類,並不關心其具體實現類。

MockClass mockClass = Mockito.mock(MockClass.class); List userIdList = (List)Mockito.mock(List.class);

4 利用@Mock註解

@Mock註解跟Mockito.mock方法一樣,可以用來模擬依賴物件,適用於普通類、介面和虛基類。@Mock註解需要配合@RunWith註解使用。

@RunWith(PowerMockRunner.class) public class UserServiceTest { @Mock private UserDAO userDAO; ...

}

5 利用Mockito.spy方法

Mockito.spy方法跟Mockito.mock方法功能相似,只是Mockito.spy方法預設所有方法都是真實方法,除非主動去模擬對應方法。

UserService userService = Mockito.spy(new UserService()); UserService userService = Mockito.spy(UserService.class); AbstractOssService ossService = Mockito.spy(AbstractOssService.class);

6 利用@Spy註解

@Spy註解跟Mockito.spy方法一樣,可以用來模擬依賴物件,適用於普通類、介面和虛基類。@Spy註解需要配合@RunWith註解使用。

@RunWith(PowerMockRunner.class) public class CompanyServiceTest { @Spy private UserService userService = new UserService();

...

}

注意:@Spy註解物件需要初始化。如果是虛基類或介面,可以用Mockito.mock方法例項化。

五 注入依賴物件

當模擬完這些類成員物件後,我們需要把這些類成員物件注入到被測試類的例項中。以便在呼叫被測試方法時,可能使用這些類成員物件,而不至於丟擲空指標異常。

1 利用Setter方法注入

如果類定義了Setter方法,可以直接呼叫方法設定欄位值。

userService.setMaxCount(100); userService.setUserDAO(userDAO);

2 利用ReflectionTestUtils.setField方法注入

JUnit提供ReflectionTestUtils.setField方法設定屬性欄位值。

ReflectionTestUtils.setField(userService, "maxCount", 100); ReflectionTestUtils.setField(userService, "userDAO", userDAO);

3 利用Whitebox.setInternalState方法注入

PowerMock提供Whitebox.setInternalState方法設定屬性欄位值。

Whitebox.setInternalState(userService, "maxCount", 100); Whitebox.setInternalState(userService, "userDAO", userDAO);

4 利用@InjectMocks註解注入

@InjectMocks註解用來建立一個例項,並將其它物件(@Mock、@Spy或直接定義的物件)注入到該例項中。@InjectMocks註解需要配合@RunWith註解使用。

@RunWith(PowerMockRunner.class) public class UserServiceTest { @Mock private UserDAO userDAO; private Boolean canModify;

@InjectMocks
private UserService userService;
...

}

5 設定靜態常量欄位值

有時候,我們需要對靜態常量物件進行模擬,然後去驗證是否執行了對應分支下的方法。比如:需要模擬Lombok的@Slf4j生成的log靜態常量。但是,Whitebox.setInternalState方法和@InjectMocks註解並不支援設定靜態常量,需要自己實現一個設定靜態常量的方法:

public final class FieldHelper { public static void setStaticFinalField(Class clazz, String fieldName, Object fieldValue) throws NoSuchFieldException, IllegalAccessException { Field field = clazz.getDeclaredField(fieldName); FieldUtils.removeFinalModifier(field); FieldUtils.writeStaticField(field, fieldValue, true); } }

具體使用方法如下:

FieldHelper.setStaticFinalField(UserService.class, "log", log);

注意:經過測試,該方法對於int、Integer等基礎型別並不生效,應該是編譯器常量優化導致。

六 模擬依賴方法

在模擬完依賴的引數和返回值後,就可以利用Mockito和PowerMock的功能,進行依賴方法的模擬。如果依賴物件還有方法呼叫,還需要模擬這些依賴物件的方法。

1 根據返回模擬方法

模擬無返回值方法

Mockito.doNothing().when(userDAO).delete(userId);

模擬方法單個返回值

Mockito.doReturn(user).when(userDAO).get(userId); Mockito.when(userDAO.get(userId)).thenReturn(user);

模擬方法多個返回值

直接列舉出多個返回值:

Mockito.doReturn(record0, record1, record2, null).when(recordReader).read(); Mockito.when(recordReader.read()).thenReturn(record0, record1, record2, null);

轉化列表為多個返回值:

List recordList = ...; Mockito.doReturn(recordList.get(0), recordList.subList(1, recordList.size()).toArray()).when(recordReader).read(); Mockito.when(recordReader.read()).thenReturn(recordList.get(0), recordList.subList(1, recordList.size()).toArray());

模擬方法定製返回值

可利用Answer定製方法返回值:

Map userMap = ...; Mockito.doAnswer(invocation -> userMap.get(invocation.getArgument(0))) .when(userDAO).get(Mockito.anyLong()); Mockito.when(userDAO.get(Mockito.anyLong())) .thenReturn(invocation -> userMap.get(invocation.getArgument(0))); Mockito.when(userDAO.get(Mockito.anyLong())) .then(invocation -> userMap.get(invocation.getArgument(0)));

模擬方法丟擲單個異常

指定單個異常型別:

Mockito.doThrow(PersistenceException.class).when(userDAO).get(Mockito.anyLong()); Mockito.when(userDAO.get(Mockito.anyLong())).thenThrow(PersistenceException.class);

指定單個異常物件:

Mockito.doThrow(exception).when(userDAO).get(Mockito.anyLong()); Mockito.when(userDAO.get(Mockito.anyLong())).thenThrow(exception);

模擬方法丟擲多個異常

指定多個異常型別:

Mockito.doThrow(PersistenceException.class, RuntimeException.class).when(userDAO).get(Mockito.anyLong()); Mockito.when(userDAO.get(Mockito.anyLong())).thenThrow(PersistenceException.class, RuntimeException.class);

指定多個異常物件:

Mockito.doThrow(exception1, exception2).when(userDAO).get(Mockito.anyLong()); Mockito.when(userDAO.get(Mockito.anyLong())).thenThrow(exception1, exception2);

直接呼叫真實方法

Mockito.doCallRealMethod().when(userService).getUser(userId); Mockito.when(userService.getUser(userId)).thenCallRealMethod();

2 根據引數模擬方法

Mockito提供do-when語句和when-then語句模擬方法。

模擬無引數方法

對於無引數的方法模擬:

Mockito.doReturn(deleteCount).when(userDAO).deleteAll(); Mockito.when(userDAO.deleteAll()).thenReturn(deleteCount);

模擬指定引數方法

對於指定引數的方法模擬:

Mockito.doReturn(user).when(userDAO).get(userId); Mockito.when(userDAO.get(userId)).thenReturn(user);

模擬任意引數方法

在編寫單元測試用例時,有時候並不關心傳入引數的具體值,可以使用Mockito引數匹配器的any方法。Mockito提供了anyInt、anyLong、anyString、anyList、anySet、anyMap、any(Class clazz)等方法來表示任意值。

Mockito.doReturn(user).when(userDAO).get(Mockito.anyLong()); Mockito.when(userDAO.get(Mockito.anyLong())).thenReturn(user);

模擬可空引數方法

Mockito引數匹配器的any具體方法,並不能夠匹配null物件。而Mockito提供一個nullable方法,可以匹配包含null物件的任意物件。此外,Mockito.any()方法也可以用來匹配可空引數。

Mockito.doReturn(user).when(userDAO) .queryCompany(Mockito.anyLong(), Mockito.nullable(Long.class)); Mockito.when(userDAO.queryCompany(Mockito.anyLong(), Mockito.any())) .thenReturn(user);

模擬必空引數方法

同樣,如果要匹配null物件,可以使用isNull方法,或使用eq(null)。

Mockito.doReturn(user).when(userDAO).queryCompany(Mockito.anyLong(), Mockito.isNull()); Mockito.when(userDAO.queryCompany(Mockito.anyLong(), Mockito.eq(null))).thenReturn(user);

模擬不同引數方法

Mockito支援按不同的引數分別模擬同一方法。

Mockito.doReturn(user1).when(userDAO).get(1L); Mockito.doReturn(user2).when(userDAO).get(2L); ...

注意:如果一個引數滿足多個模擬方法條件,會以最後一個模擬方法為準。

模擬可變引數方法

對於一些變長度引數方法,可以按實際引數個數進行模擬:

Mockito.when(userService.delete(Mockito.anyLong()).thenReturn(true); Mockito.when(userService.delete(1L, 2L, 3L).thenReturn(true);

也可以用Mockito.any()模擬一個通用匹配方法:

Mockito.when(userService.delete(Mockito.any()).thenReturn(true);

注意:Mockito.any()並不等於Mockito.any(Class type),前者可以匹配null和型別T的可變引數,後者只能匹配T必填引數。

3 模擬其它特殊方法

模擬final方法

PowerMock提供對final方法的模擬,方法跟模擬普通方法一樣。但是,需要把對應的模擬類新增到@PrepareForTest註解中。

// 新增@PrepareForTest註解 @PrepareForTest({UserService.class})

// 跟模擬普通方法完全一致 Mockito.doReturn(userId).when(idGenerator).next(); Mockito.when(idGenerator.next()).thenReturn(userId);

模擬私有方法

PowerMock提供提對私有方法的模擬,但是需要把私有方法所在的類放在@PrepareForTest註解中。

PowerMockito.doReturn(true).when(UserService.class, "isSuper", userId); PowerMockito.when(UserService.class, "isSuper", userId).thenReturn(true);

模擬構造方法

PowerMock提供PowerMockito.whenNew方法來模擬構造方法,但是需要把使用構造方法的類放在@PrepareForTest註解中。

PowerMockito.whenNew(UserDO.class).withNoArguments().thenReturn(userDO); PowerMockito.whenNew(UserDO.class).withArguments(userId, userName).thenReturn(userDO);

模擬靜態方法

PowerMock提供PowerMockito.mockStatic和PowerMockito.spy來模擬靜態方法類,然後就可以模擬靜態方法了。同樣,需要把對應的模擬類新增到@PrepareForTest註解中。

// 模擬對應的類 PowerMockito.mockStatic(HttpHelper.class); PowerMockito.spy(HttpHelper.class);

// 模擬對應的方法 PowerMockito.when(HttpHelper.httpPost(SERVER_URL)).thenReturn(response); PowerMockito.doReturn(response).when(HttpHelper.class, "httpPost", SERVER_URL); PowerMockito.when(HttpHelper.class, "httpPost", SERVER_URL).thenReturn(response);

注意:第一種方式不適用於PowerMockito.spy模擬的靜態方法類。

七 呼叫被測方法

在準備好引數物件後,就可以呼叫被測試方法了。

如果把方法按訪問許可權分類,可以簡單地分為有訪問許可權和無訪問許可權兩種。但實際上,Java語言中提供了public、protected、private和缺失共4種許可權修飾符,在不同的環境下又對應不同的訪問許可權。具體對映關係如下:

修飾符 本類 本包 子類 其它 public 有 有 有 有 protected 有 有 有 無 預設 有 有 無 無 private 有 無 無 無

下面,將根據有訪問許可權和無訪問許可權兩種情況,來介紹如何呼叫被測方法。

1 呼叫構造方法

呼叫有訪問許可權的構造方法

可以直接呼叫有訪問許可權的構造方法。

UserDO user = new User(); UserDO user = new User(1L, "admin");

呼叫無訪問許可權的構造方法

呼叫無訪問許可權的構造方法,可以使用PowerMock提供的Whitebox.invokeConstructor方法。

Whitebox.invokeConstructor(NumberHelper.class); Whitebox.invokeConstructor(User.class, 1L, "admin");

備註:該方法也可以呼叫有訪問許可權的構造方法,但是不建議使用。

2 呼叫普通方法

呼叫有訪問許可權的普通方法

可以直接呼叫有訪問許可權的普通方法。

userService.deleteUser(userId); User user = userService.getUser(userId);

呼叫無許可權訪問的普通方法

呼叫無訪問許可權的普通方法,可以使用PowerMock提供的Whitebox.invokeMethod方法。

User user = (User)Whitebox.invokeMethod(userService, "isSuper", userId);

也可以使用PowerMock提供Whitebox.getMethod方法和PowerMockito.method方法,可以直接獲取對應類方法物件。然後,通過Method的invoke方法,可以呼叫沒有訪問許可權的方法。

Method method = Whitebox.getMethod(UserService.class, "isSuper", Long.class); Method method = PowerMockito.method(UserService.class, "isSuper", Long.class); User user = (User)method.invoke(userService, userId);

備註:該方法也可以呼叫有訪問許可權的普通方法,但是不建議使用。

3 呼叫靜態方法

呼叫有許可權訪問的靜態方法

可以直接呼叫有訪問許可權的靜態方法。

boolean isPositive = NumberHelper.isPositive(-1);

呼叫無許可權訪問的靜態方法

呼叫無許可權訪問的靜態方法,可以使用PowerMock提供的Whitebox.invokeMethod方法。

String value = (String)Whitebox.invokeMethod(JSON.class, "toJSONString", object);

備註:該方法也可以呼叫有訪問許可權的靜態方法,但是不建議使用。

八 驗證依賴方法

在單元測試中,驗證是確認模擬的依賴方法是否按照預期被呼叫或未呼叫的過程。

Mockito提供了許多方法來驗證依賴方法呼叫,給我們編寫單元測試用例帶來了很大的幫助。

1 根據引數驗證方法呼叫

驗證無引數方法呼叫

Mockito.verify(userDAO).deleteAll();

驗證指定引數方法呼叫

Mockito.verify(userDAO).delete(userId); Mockito.verify(userDAO).delete(Mockito.eq(userId));

驗證任意引數方法呼叫

Mockito.verify(userDAO).delete(Mockito.anyLong());

驗證可空引數方法呼叫

Mockito.verify(userDAO).queryCompany(Mockito.anyLong(), Mockito.nullable(Long.class));

驗證必空引數方法呼叫

Mockito.verify(userDAO).queryCompany(Mockito.anyLong(), Mockito.isNull());

驗證可變引數方法呼叫

對於一些變長度引數方法,可以按實際引數個數進行驗證:

Mockito.verify(userService).delete(Mockito.any(Long.class)); Mockito.verify(userService).delete(1L, 2L, 3L);

也可以用Mockito.any()進行通用驗證:

Mockito.verify(userService).delete(Mockito.any());

2 驗證方法呼叫次數

驗證方法預設呼叫1次

Mockito.verify(userDAO).delete(userId);

驗證方法從不呼叫

Mockito.verify(userDAO, Mockito.never()).delete(userId);

驗證方法呼叫n次

Mockito.verify(userDAO, Mockito.times(n)).delete(userId);

驗證方法呼叫至少1次

Mockito.verify(userDAO, Mockito.atLeastOnce()).delete(userId);

驗證方法呼叫至少n次

Mockito.verify(userDAO, Mockito.atLeast(n)).delete(userId);

驗證方法呼叫最多1次

Mockito.verify(userDAO, Mockito.atMostOnce()).delete(userId);

驗證方法呼叫最多n次

Mockito.verify(userDAO, Mockito.atMost(n)).delete(userId);

驗證方法呼叫指定n次

Mockito允許按順序進行驗證方法呼叫,未被驗證到的方法呼叫將不會被標記為已驗證。

Mockito.verify(userDAO, Mockito.call(n)).delete(userId);

驗證物件及其方法呼叫1次

用於驗證物件及其方法呼叫1次,如果該物件還有別的方法被呼叫或者該方法呼叫了多次,都將導致驗證方法呼叫失敗。

Mockito.verify(userDAO, Mockito.only()).delete(userId);

相當於:

Mockito.verify(userDAO).delete(userId); Mockito.verifyNoMoreInteractions(userDAO);

3 驗證方法呼叫並捕獲引數值

Mockito提供ArgumentCaptor類來捕獲引數值,通過呼叫forClass(Class clazz)方法來構建一個ArgumentCaptor物件,然後在驗證方法呼叫時來捕獲引數,最後獲取到捕獲的引數值並驗證。如果一個方法有多個引數都要捕獲並驗證,那就需要建立多個ArgumentCaptor物件。

ArgumentCaptor的主要介面方法:

capture方法,用於捕獲方法引數;

getValue方法,用於獲取捕獲的引數值,如果捕獲了多個引數值,該方法只返回最後一個引數值;

getAllValues方法,使用者獲取捕獲的所有引數值。

使用ArgumentCaptor.forClass方法定義引數捕獲器

在測試用例方法中,直接使用ArgumentCaptor.forClass方法定義引數捕獲器。

ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserDO.class); Mockito.verify(userDAO).modify(userCaptor.capture()); UserDO user = userCaptor.getValue();

注意:定義泛型類的引數捕獲器時,存在強制型別轉換,會引起編譯器警告。

使用@Captor註解定義引數捕獲器

也可以用Mockito提供的@Captor註解,在測試用例類中定義引數捕獲器。

@RunWith(PowerMockRunner.class) public class UserServiceTest { @Captor private ArgumentCaptor userCaptor; @Test public void testModifyUser() { ... Mockito.verify(userDAO).modify(userCaptor.capture()); UserDO user = userCaptor.getValue(); } }

注意:定義泛型類的引數捕獲器時,由於是Mockito自行初始化,不會引起編譯器警告。

捕獲多次方法呼叫的引數值列表

ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserDO.class); Mockito.verify(userDAO, Mockito.atLeastOnce()).modify(userCaptor.capture()); List userList = userCaptor.getAllValues();

4 驗證其它特殊方法

驗證final方法呼叫

final方法的驗證跟普通方法類似,這裡不再累述。

驗證私有方法呼叫

PowerMockito提供verifyPrivate方法驗證私有方法呼叫。

PowerMockito.verifyPrivate(myClass, times(1)).invoke("unload", any(List.class));

驗證構造方法呼叫

PowerMockito提供verifyNew方法驗證構造方法呼叫。

PowerMockito.verifyNew(MockClass.class).withNoArguments(); PowerMockito.verifyNew(MockClass.class).withArguments(someArgs);

驗證靜態方法呼叫

PowerMockito提供verifyStatic方法驗證靜態方法呼叫。

PowerMockito.verifyStatic(StringUtils.class); StringUtils.isEmpty(string);

九 驗證資料物件

JUnit測試框架中Assert類就是斷言工具類,主要驗證單元測試中實際資料物件與期望資料物件一致。在呼叫被測方法時,需要對返回值和異常進行驗證;在驗證方法呼叫時,也需要對捕獲的引數值進行驗證。

1 驗證資料物件空值

驗證資料物件為空

通過JUnit提供的Assert.assertNull方法驗證資料物件為空。

Assert.assertNull("使用者標識必須為空", userId);

驗證資料物件非空

通過JUnit提供的Assert.assertNotNull方法驗證資料物件非空。

Assert.assertNotNull("使用者標識不能為空", userId);

2 驗證資料物件布林值

驗證資料物件為真

通過JUnit提供的Assert.assertTrue方法驗證資料物件為真。

Assert.assertTrue("返回值必須為真", NumberHelper.isPositive(1));

驗證資料物件為假

通過JUnit提供的Assert.assertFalse方法驗證資料物件為假。

Assert.assertFalse("返回值必須為假", NumberHelper.isPositive(-1));

3 驗證資料物件引用

在單元測試用例中,對於一些引數或返回值物件,不需要驗證物件具體取值,只需要驗證物件引用是否一致。

驗證資料物件一致

JUnit提供的Assert.assertSame方法驗證資料物件一致。

UserDO expectedUser = ...; Mockito.doReturn(expectedUser).when(userDAO).get(userId); UserDO actualUser = userService.getUser(userId); Assert.assertSame("使用者必須一致", expectedUser, actualUser);

驗證資料物件不一致

JUnit提供的Assert.assertNotSame方法驗證資料物件一致。

UserDO expectedUser = ...; Mockito.doReturn(expectedUser).when(userDAO).get(userId); UserDO actualUser = userService.getUser(otherUserId); Assert.assertNotSame("使用者不能一致", expectedUser, actualUser);

4 驗證資料物件值

JUnit提供Assert.assertEquals、Assert.assertNotEquals、Assert.assertArrayEquals方法組,可以用來驗證資料物件值是否相等。

驗證簡單資料物件

對於簡單資料物件(比如:基礎型別、包裝型別、實現了equals的資料型別……),可以直接通過JUnit的Assert.assertEquals和Assert.assertNotEquals方法組進行驗證。

Assert.assertNotEquals("使用者名稱稱不一致", "admin", userName); Assert.assertEquals("賬戶金額不一致", 10000.0D, accountAmount, 1E-6D);

驗證簡單陣列或集合物件

對於簡單陣列物件(比如:基礎型別、包裝型別、實現了equals的資料型別……),可以直接通過JUnit的Assert.assertArrayEquals方法組進行驗證。對於簡單集合物件,也可以通過Assert.assertEquals方法驗證。

Long[] userIds = ...; Assert.assertArrayEquals("使用者標識列表不一致", new Long[] {1L, 2L, 3L}, userIds);

List userIdList = ...; Assert.assertEquals("使用者標識列表不一致", Arrays.asList(1L, 2L, 3L), userIdList);

驗證複雜資料物件

對於複雜的JavaBean資料物件,需要驗證JavaBean資料物件的每一個屬性欄位。

UserDO user = ...; Assert.assertEquals("使用者標識不一致", Long.valueOf(1L), user.getId()); Assert.assertEquals("使用者名稱稱不一致", "admin", user.getName()); Assert.assertEquals("使用者公司標識不一致", Long.valueOf(1L), user.getCompany().getId()); ...

驗證複雜陣列或集合物件

對於複雜的JavaBean陣列和集合物件,需要先展開陣列和集合物件中每一個JavaBean資料物件,然後驗證JavaBean資料物件的每一個屬性欄位。

List expectedUserList = ...; List actualUserList = ...; Assert.assertEquals("使用者列表長度不一致", expectedUserList.size(), actualUserList.size()); UserDO[] expectedUsers = expectedUserList.toArray(new UserDO[0]); UserDO[] actualUsers = actualUserList.toArray(new UserDO[0]); for (int i = 0; i < actualUsers.length; i++) { Assert.assertEquals(String.format("使用者(%s)標識不一致", i), expectedUsers[i].getId(), actualUsers[i].getId()); Assert.assertEquals(String.format("使用者(%s)名稱不一致", i), expectedUsers[i].getName(), actualUsers[i].getName()); Assert.assertEquals("使用者公司標識不一致", expectedUsers[i].getCompany().getId(), actualUsers[i].getCompany().getId()); ... }

通過序列化驗證資料物件

如上一節例子所示,當資料物件過於複雜時,如果採用Assert.assertEquals依次驗證每個JavaBean物件、驗證每一個屬性欄位,測試用例的程式碼量將會非常龐大。這裡,推薦使用序列化手段簡化資料物件的驗證,比如利用JSON.toJSONString方法把複雜的資料物件轉化為字串,然後再使用Assert.assertEquals方法進行驗證字串。但是,序列化值必須具備有序性、一致性和可讀性。

List userList = ...; String text = ResourceHelper.getResourceAsString(getClass(), "userList.json"); Assert.assertEquals("使用者列表不一致", text, JSON.toJSONString(userList));

通常使用JSON.toJSONString方法把Map物件轉化為字串,其中key-value的順序具有不確定性,無法用於驗證兩個物件是否一致。這裡,JSON提供序列化選項SerializerFeature.MapSortField(對映排序欄位),可以用於保證序列化後的key-value的有序性。

Map> userMap = ...; String text = ResourceHelper.getResourceAsString(getClass(), "userMap.json"); Assert.assertEquals("使用者對映不一致", text, JSON.toJSONString(userMap, SerializerFeature.MapSortField));

驗證資料物件私有屬性欄位

有時候,單元測試用例需要對複雜物件的私有屬性欄位進行驗證。而PowerMockito提供的Whitebox.getInternalState方法,獲取輕鬆地獲取到私有屬性欄位值。

MapperScannerConfigurer configurer = myBatisConfiguration.buildMapperScannerConfigurer(); Assert.assertEquals("基礎包不一致", "com.alibaba.example", Whitebox.getInternalState(configurer, "basePackage"));

5 驗證異常物件內容

異常作為Java語言的重要特性,是Java語言健壯性的重要體現。捕獲並驗證異常資料內容,也是測試用例的一種。

通過@Test註解驗證異常物件

JUnit的註解@Test提供了一個expected屬性,可以指定一個期望的異常型別,用來捕獲並驗證異常。但是,這種方式只能驗證異常型別,並不能驗證異常原因和訊息。

@Test(expected = ExampleException.class) public void testGetUser() { // 模擬依賴方法 Mockito.doReturn(null).when(userDAO).get(userId);

// 呼叫被測方法
userService.getUser(userId);

}

通過@Rule註解驗證異常物件

如果想要驗證異常原因和訊息,就需求採用@Rule註解定義ExpectedException物件,然後在測試方法的前面宣告要捕獲的異常型別、原因和訊息。

@Rule private ExpectedException exception = ExpectedException.none(); @Test public void testGetUser() { // 模擬依賴方法 Long userId = 123L; Mockito.doReturn(null).when(userDAO).get(userId); // 呼叫被測方法 exception.expect(ExampleException.class); exception.expectMessage(String.format("使用者(%s)不存在", userId)); userService.getUser(userId); }

通過Assert.assertThrows驗證異常物件

在最新版的JUnit中,提供了一個更為簡潔的異常驗證方式——Assert.assertThrows方法。

@Test public void testGetUser() { // 模擬依賴方法 Long userId = 123L; Mockito.doReturn(null).when(userDAO).get(userId); // 呼叫被測方法 ExampleException exception = Assert.assertThrows("異常型別不一致", ExampleException.class, () -> userService.getUser(userId)); Assert.assertEquals("異常訊息不一致", "處理異常", exception.getMessage()); }

十 驗證依賴物件

1 驗證模擬物件沒有任何方法呼叫

Mockito提供了verifyNoInteractions方法,可以驗證模擬物件在被測試方法中沒有任何呼叫。

Mockito.verifyNoInteractions(idGenerator, userDAO);

2 驗證模擬物件沒有更多方法呼叫

Mockito提供了verifyNoMoreInteractions方法,在驗證模擬物件所有方法呼叫後使用,可以驗證模擬物件所有方法呼叫是否都得到驗證。如果模擬物件存在任何未驗證的方法呼叫,就會丟擲NoInteractionsWanted異常。

Mockito.verifyNoMoreInteractions(idGenerator, userDAO);

備註:Mockito的verifyZeroInteractions方法與verifyNoMoreInteractions方法功能相同,但是目前前者已經被廢棄。

3 清除模擬物件所有方法呼叫標記

在編寫單元測試用例時,為了減少單元測試用例數和程式碼量,可以把多組引數定義在同一個單元測試用例中,然後用for迴圈依次執行每一組引數的被測方法呼叫。為了避免上一次測試的方法呼叫影響下一次測試的方法呼叫驗證,最好使用Mockito提供clearInvocations方法清除上一次的方法呼叫。

// 清除所有物件呼叫 Mockito.clearInvocations(); // 清除指定物件呼叫 Mockito.clearInvocations(idGenerator, userDAO);

十一 典型案例及解決方案

這裡,只收集了幾個經典案例,解決了特定環境下的特定問題。

1 測試框架特性導致問題

在編寫單元測試用例時,或多或少會遇到一些問題,大多數是由於對測試框架特性不熟悉導致,比如:

Mockito不支援對靜態方法、構造方法、final方法、私有方法的模擬;

Mockito的any相關的引數匹配方法並不支援可空引數和空引數;

採用Mockito的引數匹配方法時,其它引數不能直接用常量或變數,必須使用Mockito的eq方法;

使用when-then語句模擬Spy物件方法會先執行真實方法,應該使用do-when語句;

PowerMock對靜態方法、構造方法、final方法、私有方法的模擬需要把對應的類新增到@PrepareForTest註解中;

PowerMock模擬JDK的靜態方法、構造方法、final方法、私有方法時,需要把使用這些方法的類加入到@PrepareForTest註解中,從而導致單元測試覆蓋率不被統計;

PowerMock使用自定義的類載入器來載入類,可能導致系統類載入器認為有型別轉換問題;需要加上@PowerMockIgnore({“javax.crypto.*”})註解,來告訴PowerMock這個包不要用PowerMock的類載入器載入,需要採用系統類載入器來載入。

……

對於這些問題,可以根據提示資訊查閱相關資料解決,這裡就不再累述了。

2 捕獲引數值已變更問題

在編寫單元測試用例時,通常採用ArgumentCaptor進行引數捕獲,然後對引數物件值進行驗證。如果引數物件值沒有變更,這個步驟就沒有任何問題。但是,如果引數物件值在後續流程中發生變更,就會導致驗證引數值失敗。

原始程式碼

public void readData(RecordReader recordReader, int batchSize, Function dataParser, Predicate> dataStorage) { try { // 依次讀取資料 Record record; boolean isContinue = true; List dataList = new ArrayList<>(batchSize); while (Objects.nonNull(record = recordReader.read()) && isContinue) { // 解析新增資料 T data = dataParser.apply(record); if (Objects.nonNull(data)) { dataList.add(data); }

        // 批量儲存資料
        if (dataList.size() == batchSize) {
            isContinue = dataStorage.test(dataList);
            dataList.clear();
        }
    }

    // 儲存剩餘資料
    if (CollectionUtils.isNotEmpty(dataList)) {
        dataStorage.test(dataList);
        dataList.clear();
    }
} catch (IOException e) {
    String message = READ_DATA_EXCEPTION;
    log.warn(message, e);
    throw new ExampleException(message, e);
}

}

測試用例

@Test public void testReadData() throws Exception { // 模擬依賴方法 // 模擬依賴方法: recordReader.read Record record0 = Mockito.mock(Record.class); Record record1 = Mockito.mock(Record.class); Record record2 = Mockito.mock(Record.class); TunnelRecordReader recordReader = Mockito.mock(TunnelRecordReader.class); Mockito.doReturn(record0, record1, record2, null).when(recordReader).read(); // 模擬依賴方法: dataParser.apply Object object0 = new Object(); Object object1 = new Object(); Object object2 = new Object(); Function dataParser = Mockito.mock(Function.class); Mockito.doReturn(object0).when(dataParser).apply(record0); Mockito.doReturn(object1).when(dataParser).apply(record1); Mockito.doReturn(object2).when(dataParser).apply(record2); // 模擬依賴方法: dataStorage.test Predicate> dataStorage = Mockito.mock(Predicate.class); Mockito.doReturn(true).when(dataStorage).test(Mockito.anyList());

// 呼叫測試方法
odpsService.readData(recordReader, 2, dataParser, dataStorage);

// 驗證依賴方法
// 模擬依賴方法: recordReader.read
Mockito.verify(recordReader, Mockito.times(4)).read();
// 模擬依賴方法: dataParser.apply
Mockito.verify(dataParser, Mockito.times(3)).apply(Mockito.any(Record.class));
// 驗證依賴方法: dataStorage.test
ArgumentCaptor<List<Object>> recordListCaptor = ArgumentCaptor.forClass(List.class);
Mockito.verify(dataStorage, Mockito.times(2)).test(recordListCaptor.capture());
Assert.assertEquals("資料列表不一致", Arrays.asList(Arrays.asList(object0, object1), Arrays.asList(object2)), recordListCaptor.getAllValues());

}

問題現象

執行單元測試用例失敗,丟擲以下異常資訊:

java.lang.AssertionError: 資料列表不一致 expected:<[[java.lang.Object@1e3469df, java.lang.Object@79499fa], [java.lang.Object@48531d5]]> but was:<[[], []]>

問題原因

由於引數dataList在呼叫dataStorage.test方法後,都被主動呼叫dataList.clear方法進行清空。由於ArgumentCaptor捕獲的是物件引用,所以最後捕獲到了同一個空列表。

解決方案

可以在模擬依賴方法dataStorage.test時,儲存傳入引數的當前值進行驗證。程式碼如下:

@Test public void testReadData() throws Exception { // 模擬依賴方法 ... // 模擬依賴方法: dataStorage.test List dataList = new ArrayList<>(); Predicate> dataStorage = Mockito.mock(Predicate.class); Mockito.doAnswer(invocation -> dataList.addAll((List)invocation.getArgument(0))) .when(dataStorage).test(Mockito.anyList()); // 呼叫測試方法 odpsService.readData(recordReader, 2, dataParser, dataStorage); // 驗證依賴方法 ... // 驗證依賴方法: dataStorage.test Mockito.verify(dataStorage, Mockito.times(2)).test(Mockito.anyList()); Assert.assertEquals("資料列表不一致", Arrays.asList(object0, object1, object2), dataList); }

3 模擬Lombok的log物件問題

Lombok的@Slf4j註解,廣泛地應用於Java專案中。在某些程式碼分支裡,可能只有log記錄日誌的操作,為了驗證這個分支邏輯被正確執行,需要在單元測試用例中對log記錄日誌的操作進行驗證。

原始方法

@Slf4j @Service public class ExampleService { public void recordLog(int code) { if (code == 1) { log.info("執行分支1"); return; } if (code == 2) { log.info("執行分支2"); return; } log.info("執行預設分支"); } ... }

測試用例

@RunWith(PowerMockRunner.class) public class ExampleServiceTest { @Mock private Logger log; @InjectMocks private ExampleService exampleService; @Test public void testRecordLog1() { exampleService.recordLog(1); Mockito.verify(log).info("執行分支1"); } }

問題現象

執行單元測試用例失敗,丟擲以下異常資訊:

Wanted but not invoked: logger.info("執行分支1");

原因分析

經過調式跟蹤,發現ExampleService中的log物件並沒有被注入。通過編譯發現,Lombok的@Slf4j註解在ExampleService類中生成了一個靜態常量log,而@InjectMocks註解並不支援靜態常量的注入。

解決方案

採用作者實現的FieldHelper.setStaticFinalField方法,可以實現對靜態常量的注入模擬物件。

@RunWith(PowerMockRunner.class) public class ExampleServiceTest { @Mock private Logger log; @InjectMocks private ExampleService exampleService; @Before public void beforeTest() throws Exception { FieldHelper.setStaticFinalField(ExampleService.class, "log", log); } @Test public void testRecordLog1() { exampleService.recordLog(1); Mockito.verify(log).info("執行分支1"); } }

4 相容Pandora等容器問題

阿里巴巴的很多中介軟體,都是基於Pandora容器的,在編寫單元測試用例時,可能會遇到一些坑。

原始方法

@Slf4j public class MetaqMessageSender { @Autowired private MetaProducer metaProducer; public String sendMetaqMessage(String topicName, String tagName, String messageKey, String messageBody) { try { // 組裝訊息內容 Message message = new Message(); message.setTopic(topicName); message.setTags(tagName); message.setKeys(messageKey); message.setBody(messageBody.getBytes(StandardCharsets.UTF_8));

        // 傳送訊息請求
        SendResult sendResult = metaProducer.send(message);
        if (sendResult.getSendStatus() != SendStatus.SEND_OK) {
            String msg = String.format("傳送標籤(%s)訊息(%s)狀態錯誤(%s)", tagName, messageKey, sendResult.getSendStatus());
            log.warn(msg);
            throw new ReconsException(msg);
        }
        log.info(String.format("傳送標籤(%s)訊息(%s)狀態成功:%s", tagName, messageKey, sendResult.getMsgId()));

        // 返回訊息標識
        return sendResult.getMsgId();
    } catch (MQClientException | RemotingException | MQBrokerException | InterruptedException e) {
        // 記錄訊息異常
        Thread.currentThread().interrupt();
        String message = String.format("傳送標籤(%s)訊息(%s)狀態異常:%s", tagName, messageKey, e.getMessage());
        log.warn(message, e);
        throw new ReconsException(message, e);
    }
}

}

測試用例

@RunWith(PowerMockRunner.class) public class MetaqMessageSenderTest { @Mock private MetaProducer metaProducer; @InjectMocks private MetaqMessageSender metaqMessageSender; @Test public void testSendMetaqMessage() throws Exception { // 模擬依賴方法 SendResult sendResult = new SendResult(); sendResult.setMsgId("msgId"); sendResult.setSendStatus(SendStatus.SEND_OK); Mockito.doReturn(sendResult).when(metaProducer).send(Mockito.any(Message.class));

    // 呼叫測試方法
    String topicName = "topicName";
    String tagName = "tagName";
    String messageKey = "messageKey";
    String messageBody = "messageBody";
    String messageId = metaqMessageSender.sendMetaqMessage(topicName, tagName, messageKey, messageBody);
    Assert.assertEquals("messageId不一致", sendResult.getMsgId(), messageId);

    // 驗證依賴方法
    ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
    Mockito.verify(metaProducer).send(messageCaptor.capture());
    Message message = messageCaptor.getValue();
    Assert.assertEquals("topicName不一致", topicName, message.getTopic());
    Assert.assertEquals("tagName不一致", tagName, message.getTags());
    Assert.assertEquals("messageKey不一致", messageKey, message.getKeys());
    Assert.assertEquals("messageBody不一致", messageBody, new String(message.getBody()));
}

}

問題現象

執行單元測試用例失敗,丟擲以下異常資訊:

java.lang.RuntimeException: com.alibaba.rocketmq.client.producer.SendResult was loaded by org.powermock.core.classloader.javassist.JavassistMockClassLoader@5d43661b, it should be loaded by Pandora Container. Can not load this fake sdk class.

原因分析

基於Pandora容器的中介軟體,需要使用Pandora容器載入。在上面測試用例中,使用了PowerMock容器載入,從而導致丟擲類載入異常。

解決方案

首先,把PowerMockRunner替換為PandoraBootRunner。其次,為了使@Mock、@InjectMocks等Mockito註解生效,需要呼叫MockitoAnnotations.initMocks(this)方法進行初始化。

@RunWith(PandoraBootRunner.class) public class MetaqMessageSenderTest { ... @Before public void beforeTest() { MockitoAnnotations.initMocks(this); } ... }

十二 消除型別轉換警告

在編寫測試用例時,特別是泛型型別轉換時,很容易產生型別轉換警告。常見型別轉換警告如下:

Type safety: Unchecked cast from Object to List Type safety: Unchecked invocation forClass(Class) of the generic method forClass(Class) of type ArgumentCaptor Type safety: The expression of type ArgumentCaptor needs unchecked conversion to conform to ArgumentCaptor>

作為一個有程式碼潔癖的輕微強迫症程式設計師,是絕對不容許這些型別轉換警告產生的。於是,總結了以下方法來解決這些型別轉換警告。

1 利用註解初始化

Mockito提供@Mock註解來模擬類例項,提供@Captor註解來初始化引數捕獲器。由於這些註解例項是通過測試框架進行初始化的,所以不會產生型別轉換警告。

問題程式碼

Map resultMap = Mockito.mock(Map.class); ArgumentCaptor> parameterMapCaptor = ArgumentCaptor.forClass(Map.class);

建議程式碼

@Mock private Map resultMap; @Captor private ArgumentCaptor> parameterMapCaptor;

2 利用臨時類或介面

我們無法獲取泛型類或介面的class例項,但是很容易獲取具體類的class例項。這個解決方案的思路是——先定義繼承泛型類的具體子類,然後mock、spy、forClass以及any出這個具體子類的例項,然後把具體子類例項轉換為父類泛型例項。

問題程式碼

Function dataParser = Mockito.mock(Function.class); AbstractDynamicValue dynamicValue = Mockito.spy(AbstractDynamicValue.class); ArgumentCaptor> requestCaptor = ArgumentCaptor.forClass(ActionRequest.class);

建議程式碼

/** 定義臨時類或介面 */ private interface DataParser extends Function {}; private static abstract class AbstractTemporaryDynamicValue extends AbstractDynamicValue {}; private static class VoidActionRequest extends ActionRequest {};

/** 使用臨時類或介面 */ Function dataParser = Mockito.mock(DataParser.class); AbstractDynamicValue dynamicValue = Mockito.spy(AbstractTemporaryDynamicValue.class); ArgumentCaptor> requestCaptor = ArgumentCaptor.forClass(VoidActionRequest.class);

3 利用CastUtils.cast方法

SpringData包中提供一個CastUtils.cast方法,可以用於型別的強制轉換。這個解決方案的思路是——利用CastUtils.cast方法遮蔽型別轉換警告。

問題程式碼

Function dataParser = Mockito.mock(Function.class); ArgumentCaptor> requestCaptor = ArgumentCaptor.forClass(ActionRequest.class); Map scoreMap = (Map)method.invoke(userService);

建議程式碼

Function dataParser = CastUtils.cast(Mockito.mock(Function.class)); ArgumentCaptor> requestCaptor = CastUtils.cast(ArgumentCaptor.forClass(ActionRequest.class)); Map scoreMap = CastUtils.cast(method.invoke(userService));

這個解決方案,不需要定義註解,也不需要定義臨時類或介面,能夠讓測試用例程式碼更為精簡,所以作者重點推薦使用。如果不願意引入SpringData包,也可以自己參考實現該方法,只是該方法會產生型別轉換警告。

注意:CastUtils.cast方法本質是——先轉換為Object型別,再強制轉換對應型別,本身不會對型別進行校驗。所以,CastUtils.cast方法好用,但是不要亂用,否則就是大坑(只有執行時才能發現問題)。

4 利用型別自動轉換方法

在Mockito中,提供形式如下的方法——泛型型別只跟返回值有關,而跟輸入引數無關。這樣的方法,可以根據呼叫方法的引數型別自動轉換,而無需手動強制型別轉換。如果手動強制型別轉換,反而會產生型別轉換警告。

T getArgument(int index); public static T any(); public static synchronized T invokeMethod(Object instance, String methodToExecute, Object... arguments) throws Exception;

問題程式碼

Mockito.doAnswer(invocation -> dataList.addAll((List)invocation.getArgument(0))) .when(dataStorage).test(Mockito.anyList()); Mockito.doThrow(e).when(workflow).beginToPrepare((ActionRequest)Mockito.any()); Map scoreMap = (Map)Whitebox.invokeMethod(userService, "getScoreMap");

建議程式碼

Mockito.doAnswer(invocation -> dataList.addAll(invocation.getArgument(0))) .when(dataStorage).test(Mockito.anyList()); Mockito.doThrow(e).when(workflow).beginToPrepare(Mockito.any()); Map scoreMap = Whitebox.invokeMethod(userService, "getScoreMap");

其實,SpringData的CastUtils.cast方法之所以這麼強悍,也是採用了型別自動轉化方法。

5 利用doReturn-when語句代替when-thenReturn語句

Mockito的when-thenReturn語句需要對返回型別強制校驗,而doReturn-when語句不會對返回型別強制校驗。利用這個特性,可以利用doReturn-when語句代替when-thenReturn語句解決型別轉換警告。

問題程式碼

List valueList = Mockito.mock(List.class); Mockito.when(listOperations.range(KEY, start, end)).thenReturn(valueList);

建議程式碼

List<?> valueList = Mockito.mock(List.class); Mockito.doReturn(valueList).when(listOperations).range(KEY, start, end);

6 利用Whitebox.invokeMethod方法代替Method.invoke方法

JDK提供的Method.invoke方法返回的是Object型別,轉化為具體型別時需要強制轉換,會產生型別轉換警告。而PowerMock提供的Whitebox.invokeMethod方法返回型別可以自動轉化,不會產生型別轉換警告

問題程式碼

Method method = PowerMockito.method(UserService.class, "getScoreMap"); Map scoreMap = (Map)method.invokeMethod(userService);

建議程式碼

Map scoreMap = Whitebox.invokeMethod(userService, "getScoreMap");

7 利用instanceof關鍵字

在具體型別強制轉換時,建議利用instanceof關鍵字先判斷型別,否則會產生型別轉換警告。

問題程式碼

JSONArray jsonArray = (JSONArray)object; ...

建議程式碼

if (object instanceof JSONArray) { JSONArray jsonArray = (JSONArray)object; ... }

8 利用Class.cast方法

在泛型型別強制轉換時,會產生型別轉換警告。可以採用泛型類的cast方法轉換,從而避免產生型別轉換警告。

問題程式碼

public static V parseValue(String text, Class clazz) { if (Objects.equals(clazz, String.class)) { return (V)text; } return JSON.parseObject(text, clazz); }

建議程式碼

public static V parseValue(String text, Class clazz) { if (Objects.equals(clazz, String.class)) { return clazz.cast(text); } return JSON.parseObject(text, clazz); }

9 避免不必要的型別轉換

有時候,沒有必要進行型別轉換,就儘量避免型別轉換。比如:把Object型別轉換為具體型別,但又把具體型別當Object型別使用,就沒有必要進行型別轉換。像這種情況,可以合併表示式或定義基類變數,從而避免不必要的型別轉化。

問題程式碼

Boolean isSupper = (Boolean)method.invokeMethod(userService, userId); Assert.assertEquals("期望值不為真", Boolean.TRUE, isSupper);

List userList = (Map)method.invokeMethod(userService, companyId); Assert.assertEquals("期望值不一致", expectedJson, JSON.toJSONString(userList));

建議程式碼

Assert.assertEquals("期望值不為真", Boolean.TRUE, method.invokeMethod(userService, userId));

Object userList = method.invokeMethod(userService, companyId); Assert.assertEquals("期望值不一致", expectedJson, JSON.toJSONString(userList))

相關文章