一 前言
《論語》中孔子有言:“工欲善其事,必先利其器。”
今年7月,作者迎接更大的挑戰,接手并維護了幾個Java後端項目。在熟悉業務和代碼的過程中,快速地對原有項目進行單元測試用例的補充,使其單元測試覆寫率達到70%+甚至于100%。有同僚問我:“你寫單元測試為什麼這麼快?”我微微一笑:“工欲善其事,必先利其器。而我快速編寫Java單元測試用例的技巧就是——JSON序列化。”
是的,做任何事情,都要講究方式方法;隻要方式方法對了,就會事半功倍。這裡,作者系統性地總結了JSON序列化在編寫Java單元測試用例中的使用技巧,希望能夠讓大家“讀有所得、得有所思、思有所獲”。
1 冗長的單元測試代碼
在編寫單元測試用例的過程中,經常會出現以下冗長的單元測試代碼。
冗長的資料模拟代碼
1)模拟類屬性值
在模拟類屬性值時,會遇到以下的冗長代碼:
Map<Long, String> languageMap = new HashMap<>(MapHelper.DEFAULT);
languageMap.put(1L, "Java");
languageMap.put(2L, "C++");
languageMap.put(3L, "Python");
languageMap.put(4L, "JavaScript");
... // 約幾十行
Whitebox.setInternalState(developmentService, "languageMap", languageMap);
2)模拟方法參數值
在模拟方法參數值時,會遇到以下的冗長代碼:
List<UserCreateVO> userCreateList = new ArrayList<>();
UserCreateVO userCreate0 = new UserCreateVO();
userCreate0.setName("Changyi");
userCreate0.setTitle("Java Developer");
... // 約幾十行
userCreateList.add(userCreate0);
UserCreateVO userCreate1 = new UserCreateVO();
userCreate1.setName("Tester");
userCreate1.setTitle("Java Tester");
... // 約幾十行
userCreateList.add(userCreate1);
... // 約幾十條
userService.batchCreate(userCreateList);
3)模拟方法傳回值
在模拟方法傳回值時,會遇到以下的冗長代碼:
Long companyId = 1L;
List<UserDO> userList = new ArrayList<>();
UserDO user0 = new UserDO();
user0.setId(1L);
user0.setName("Changyi");
user0.setTitle("Java Developer");
... // 約幾十行
userList.add(user0);
UserDO user1 = new UserDO();
user1.setId(2L);
user1.setName("Tester");
user1.setTitle("Java Tester");
... // 約幾十行
userList.add(user1);
... // 約幾十條
Mockito.doReturn(userList).when(userDAO).queryByCompanyId(companyId);
冗長的資料驗證代碼
1)驗證方法傳回值
在驗證方法傳回值時,會遇到以下的冗長代碼:
Long companyId = 1L;
List<UserVO> userList = userService.queryByCompanyId(companyId);
UserVO user0 = userList.get(0);
Assert.assertEquals("name不一緻", "Changyi", user0.getName());
Assert.assertEquals("title不一緻", "Java Developer", user0.getTitle());
... // 約幾十行
UserVO user1 = userList.get(1);
Assert.assertEquals("name不一緻", "Tester", user1.getName());
Assert.assertEquals("title不一緻", "Java Tester", user1.getTitle());
... // 約幾十行
... // 約幾十條
2)驗證方法參數值
在驗證方法參數值時,會遇到以下的冗長代碼:
ArgumentCaptor<List<UserDO>> userCreateListCaptor = CastUtils.cast(ArgumentCaptor.forClass(List.class));
Mockito.verify(userDAO).batchCreate(userCreateListCaptor.capture());
List<UserDO> userCreateList = userCreateListCaptor.getValue();
UserDO userCreate0 = userCreateList.get(0);
Assert.assertEquals("name不一緻", "Changyi", userCreate0.getName());
Assert.assertEquals("title不一緻", "Java Developer", userCreate0.getTitle());
... // 約幾十行
UserDO userCreate1 = userCreateList.get(1);
Assert.assertEquals("name不一緻", "Tester", userCreate1.getName());
Assert.assertEquals("title不一緻", "Java Tester", userCreate1.getTitle());
... // 約幾十行
... // 約幾十條
2 采用JSON序列化簡化
常言道:“眼見為實,耳聽為虛。”下面,就通過JSON序列化來簡化上面的單元測試用例代碼,讓大家先睹為快。
簡化資料模拟代碼
對于資料模拟,首先需要先加載JSON資源檔案為字元串,然後通過JSON反序列化字元串為資料對象,最後用于模拟類屬性值、方法參數值和方法傳回值。這樣,就精簡了原來冗長的指派語句。
利用JSON反序列化,簡化模拟類屬性值代碼如下:
String text = ResourceHelper.getResourceAsString(getClass(), path + "languageMap.json");
Map<Long, String> languageMap = JSON.parseObject(text, new TypeReference<Map<Long, String>>() {});
Whitebox.setInternalState(mobilePhoneService, "languageMap", languageMap);
其中,JSON資源檔案languageMap.json的内容如下:
{1:"Java",2:"C++",3:"Python",4:"JavaScript"...}
利用JSON反序列化,簡化模拟方法參數值代碼如下:
String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateList.json");
List<UserCreateVO> userCreateList = JSON.parseArray(text, UserCreateVO.class);
userService.batchCreate(userCreateList);
其中,JSON資源檔案userCreateList.json的内容如下:
[{"name":"Changyi","title":"Java Developer"...},{"name":"Tester","title":"Java Tester"...},...]
利用JSON反序列化,簡化模拟方法傳回值代碼如下:
Long companyId = 1L;
String text = ResourceHelper.getResourceAsString(getClass(), path + "userList.json");
List<UserDO> userList = JSON.parseArray(text, UserDO.class);
Mockito.doReturn(userList).when(userDAO).queryByCompanyId(companyId);
其中,JSON資源檔案userList.json的内容如下:
[{"id":1,"name":"Changyi","title":"Java Developer"...},{"id":2,"name":"Tester","title":"Java Tester"...},...]
簡化資料驗證代碼
對于資料驗證,首先需要先加載JSON資源檔案為字元串,然後通過JSON序列化資料對象為字元串,最後驗證兩字元串是否一緻。這樣,就精簡了原來冗長的驗證語句。
利用JSON序列化,簡化驗證方法傳回值代碼如下:
Long companyId = 1L;
List<UserVO> userList = userService.queryByCompanyId(companyId);
String text = ResourceHelper.getResourceAsString(getClass(), path + "userList.json");
Assert.assertEquals("使用者清單不一緻", text, JSON.toJSONString(userList));
[{"name":"Changyi","title":"Java Developer"...},{"name":"Tester","title":"Java Tester"...},...]
利用JSON序列化,簡化驗證方法參數值代碼如下:
ArgumentCaptor<List<UserDO>> userCreateListCaptor = CastUtils.cast(ArgumentCaptor.forClass(List.class));
Mockito.verify(userDAO).batchCreate(userCreateListCaptor.capture());
String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateList.json");
Assert.assertEquals("使用者建立清單不一緻", text, JSON.toJSONString(userCreateListCaptor.getValue()));
[{"name":"Changyi","title":"Java Developer"...},{"name":"Tester","title":"Java Tester"...},...]
3 測試用例及資源命名
俗話說:“沒有規矩,不成方圓。”是以,為了更好地利用JSON序列化技巧,首先對測試用例和資源檔案進行規範化命名。
測試類命名
按照行業慣例,測試類的命名應以被測試類名開頭并以Test結尾。比如:UserService(使用者服務類)的測試類需要命名為UserServiceTest(使用者服務測試類)。
單元測試類應該放在被測試類的同一工程的"src/test/java"目錄下,并且要放在被測試類的同一包下。注意,單元測試類不允許寫在業務代碼目錄下,否則在編譯時沒法過濾這些測試用例。
測試方法命名
按照行業規範,測試方法命名應以test開頭并以被測試方法結尾。比如:batchCreate(批量建立)的測試方法需要命名為testBatchCreate(測試:批量建立),queryByCompanyId(根據公司辨別查詢)的測試方法需要命名為testQueryByCompanyId(測試:根據公司辨別查詢)。
當一個方法對應多個測試用例時,就需要建立多個測試方法,原有測試方法命名已經不能滿足需求了。有人建議在原有的測試方法命名的基礎上,添加123等序号表示不同的用例。比如:testBatchCreate1(測試:批量建立1)、testBatchCreate2(測試:批量建立2)……但是,這種方法不能明确每個單元測試的用意。
這裡,作者建議在原有的測試方法命名的基礎上,添加”With+條件“來表達不同的測試用例方法。
- 按照結果命名:
-
- testBatchCreateWithSuccess(測試:批量建立-成功);
- testBatchCreateWithFailure(測試:批量建立-失敗);
- testBatchCreateWithException(測試:批量建立-異常);
- 按照參數命名:
-
- testBatchCreateWithListNull(測試:批量建立-清單為NULL);
- testBatchCreateWithListEmpty(測試:批量建立-清單為空);
- testBatchCreateWithListNotEmpty(測試:批量建立-清單不為空);
- 按照意圖命名:
-
- testBatchCreateWithNormal(測試:批量建立-正常);
- testBatchCreateWithGray(測試:批量建立-灰階);
當然,還有形成其它的測試方法命名方式,也可以把不同的測試方法命名方式混用,隻要能清楚地表達出這個測試用例的涵義即可。
測試類資源目錄命名
這裡,作者建議的資源目錄命名方式為——以test開頭且以被測試類名結尾。比如:UserService(使用者服務類)的測試資源目錄可以命名為testUserService。
那麼,這個資源目錄應該放在哪兒了?作者提供了2個選擇:
- 放在“src/test/java”目錄下,跟測試類放在同一目錄下——這是作者最喜歡的方式;
- 放在“src/test/resources”目錄下,跟測試類放在同一目錄下——建議IDEA使用者采用這種方式。
測試方法資源目錄命名
在前面的小節中,我們針對測試方法進行了規範命名。這裡,我們可以直接拿來使用——即用測試方法名稱來命名測試目錄。當然,這些測試方法資源目錄應該放在測試類資源目錄下。比如:測試類UserServiceTest(使用者服務測試類)的測試方法testBatchCreateWithSuccess(測試:批量建立-成功)的測試資源目錄就是testUserService/testBatchCreateWithSuccess。
另外,也可以采用“測試方法名稱”+“測試條件名稱”二級目錄的命名方式。比如:測試類UserServiceTest(使用者服務測試類)的測試方法testBatchCreateWithSuccess(測試:批量建立-成功)的測試資源目錄就是testUserService/testBatchCreate/success。
這裡,作者首推的是第一種方式,因為測試方法名稱和資源目錄名稱能夠保持一緻。
測試資源檔案命名
在被測試代碼中,所有參數、變量都已經有了命名。是以,建議優先使用這些參數和變量的名稱,并加字尾“.json”辨別檔案格式。如果這些資源檔案名稱沖突,可以添加字首以示區分。比如:userCreateList的資源檔案名稱為"userCreateList.json"。
另外,在測試用例代碼中,把這些測試資源檔案加載後,反序列化為對應的資料對象,這些資料對象的變量名稱也應該跟資源檔案名稱保持一緻。
String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateList.json");
List<UserCreateVO> userCreateList = JSON.parseArray(text, UserCreateVO.class);
userService.batchCreate(userCreateList);
測試資源檔案存儲
在測試資源目錄和名稱定義好之後,就需要存入測試資源檔案了。存儲方式總結如下:
- 如果是測試類下所有測試用例共用的資源檔案,建議存儲在測試類資源目錄下,比如:testUserService;
- 如果是測試用例獨有的資源檔案,建議存儲在測試方法資源目錄下,比如:testUserService/testBatchCreateWithSuccess;
- 如果是某一被測方法所有的測試用例共用的資源檔案,建議存儲在不帶任何修飾的測試方法資源目錄下,比如:testUserService/testBatchCreate;
- 如果測試類資源目錄下隻有一個測試方法資源目錄,可以去掉這個測試方法資源目錄,把所有資源檔案存儲在測試類資源目錄下。
注意:這裡的資源檔案不光是JSON資源檔案,但也可以是其它類型的資源檔案。
Git檔案名稱過長
由于資源目錄名稱較長(大概超過50個字元),可能會導緻git檢出代碼時出現以下錯誤:
git checkout develop
error: xxx/xxx: Filename too long
或者,在添加檔案時出現以下錯誤:
git add .
error: open("xxx/xxx"): Filename too long
error: unable to index file 'xxx/xxx'
fatal: adding files failed
可以通過以下git設定參數解決:
git config --system core.longpaths true
當然,測試用例名稱和資源目錄名稱沒必要太長,可以進行一些精簡使其小于等于50個字元。
JSON資源檔案格式
關于JSON資源檔案是否格式化的建議:不要格式化JSON資源檔案内容,否則會占用更多的代碼行數,還會導緻無法直接進行文本比較。
4 測試資源使用案例
在上一章中,講了測試用例和資源的命名規則以及存放方式。但是,隻是文字的描述,沒有什麼體感。所有,這一章将舉例一個完整的案例來實際說明。
被測案例代碼
以UserService的createUser方法為例說明:
/**
* 使用者服務類
*/
@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 userCreateDO = new UserDO();
userCreateDO.setId(userId);
userCreateDO.setName(userCreate.getName());
userDAO.create(userCreateDO);
}
// 根據存在處理: 已存在可修改
else if (Boolean.TRUE.equals(canModify)) {
UserDO userModifyDO = new UserDO();
userModifyDO.setId(userId);
userModifyDO.setName(userCreate.getName());
userDAO.modify(userModifyDO);
}
// 根據存在處理: 已存在禁修改
else {
throw new UnsupportedOperationException("不支援修改");
}
// 傳回使用者辨別
return userId;
}
}
測試用例代碼
編寫完整的測試用例如下:
/**
* 使用者服務測試類
*/
@RunWith(PowerMockRunner.class)
public class UserServiceTest {
/** 模拟依賴對象 */
/** 使用者DAO */
@Mock
private UserDAO userDAO;
/** 辨別生成器 */
@Mock
private IdGenerator idGenerator;
/** 定義測試對象 */
/** 使用者服務 */
@InjectMocks
private UserService userService;
/** 定義靜态常量 */
/** 資源路徑 */
private static final String RESOURCE_PATH = "testUserService/";
/**
* 在測試之前
*/
@Before
public void beforeTest() {
// 注入依賴對象
Whitebox.setInternalState(userService, "canModify", Boolean.TRUE);
}
/**
* 測試: 建立使用者-建立
*/
@Test
public void testCreateUserWithCreate() {
// 模拟依賴方法
// 模拟依賴方法: userDAO.getByName
Mockito.doReturn(null).when(userDAO).getIdByName(Mockito.anyString());
// 模拟依賴方法: idGenerator.next
Long userId = 1L;
Mockito.doReturn(userId).when(idGenerator).next();
// 調用測試方法
String path = RESOURCE_PATH + "testCreateUserWithCreate/";
String text = ResourceHelper.getResourceAsString(getClass(), path + "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(), path + "userCreateDO.json");
Assert.assertEquals("使用者建立不一緻", text, JSON.toJSONString(userCreateCaptor.getValue()));
// 驗證依賴對象
// 驗證依賴對象: idGenerator, userDAO
Mockito.verifyNoMoreInteractions(idGenerator, userDAO);
}
/**
* 測試: 建立使用者-修改
*/
@Test
public void testCreateUserWithModify() {
// 模拟依賴方法
// 模拟依賴方法: userDAO.getByName
Long userId = 1L;
Mockito.doReturn(userId).when(userDAO).getIdByName(Mockito.anyString());
// 調用測試方法
String path = RESOURCE_PATH + "testCreateUserWithModify/";
String text = ResourceHelper.getResourceAsString(getClass(), path + "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(), path + "userModifyDO.json");
Assert.assertEquals("使用者修改不一緻", text, JSON.toJSONString(userModifyCaptor.getValue()));
// 驗證依賴對象
// 驗證依賴對象: idGenerator
Mockito.verifyZeroInteractions(idGenerator);
// 驗證依賴對象: userDAO
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 path = RESOURCE_PATH + "testCreateUserWithException/";
String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateVO.json");
UserVO userCreate = JSON.parseObject(text, UserVO.class);
UnsupportedOperationException exception = Assert.assertThrows("傳回異常不一緻",
UnsupportedOperationException.class, () -> userService.createUser(userCreate));
Assert.assertEquals("異常消息不一緻", "不支援修改", exception.getMessage());
// 驗證依賴方法
// 驗證依賴方法: userDAO.getByName
Mockito.verify(userDAO).getIdByName(userCreate.getName());
// 驗證依賴對象
// 驗證依賴對象: idGenerator
Mockito.verifyZeroInteractions(idGenerator);
// 驗證依賴對象: userDAO
Mockito.verifyNoMoreInteractions(userDAO);
}
}
資源檔案目錄
測試用例所涉及的資源檔案目錄如下:

其中,資源檔案内容比較簡單,這裡就不再累述了。
POM檔案配置
根項目的pom.xml檔案需要做以下配置:
<?xml version="1.0" encoding="UTF-8" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
...
<!-- 屬性管理 -->
<properties>
...
<junit.version>4.13.1</junit.version>
<mockito.version>3.3.3</mockito.version>
<powermock.version>2.0.9</powermock.version>
</properties>
<!-- 依賴管理 -->
<dependencyManagement>
<dependencies>
...
<!-- PowerMock -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-module-junit4</artifactId>
<version>${powermock.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.powermock</groupId>
<artifactId>powermock-api-mockito2</artifactId>
<version>${powermock.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- 建構管理 -->
<build>
<pluginManagement>
<plugins>
...
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>2.6</version>
<executions>
...
<execution>
<id>copy-test-resources</id>
<phase>compile</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<encoding>UTF-8</encoding>
<outputDirectory>${project.build.directory}/test-classes</outputDirectory>
<resources>
<resource>
<directory>src/test/java</directory>
<includes>
<include>**/*.txt</include>
<include>**/*.csv</include>
<include>**/*.json</include>
<include>**/*.properties</include>
</includes>
</resource>
<resource>
<directory>src/test/resources</directory>
<includes>
<include>**/*.txt</include>
<include>**/*.csv</include>
<include>**/*.json</include>
<include>**/*.properties</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
簡要說明如下:
- 在屬性配置中,配置了單元測試所依賴的包版本;
- 在依賴配置中,配置了單元測試所依賴的包名稱;
- 在建構配置中,配置了編譯時需要拷貝目錄下的資源檔案(如果有其它的資源檔案格式,需要在pom中配置添加)。
工具類代碼
在上面單元測試用例中,需要使用到一個工具類ResourceHelper(資源指派類),代碼如下:
/**
* 資源輔助類
*/
public final class ResourceHelper {
/**
* 構造方法
*/
private ResourceHelper() {
throw new UnsupportedOperationException();
}
/**
* 以字元串方式擷取資源
*
* @param clazz 類
* @param name 資源名稱
* @return 字元串
*/
public static <T> String getResourceAsString(Class<T> clazz, String name) {
try (InputStream is = clazz.getResourceAsStream(name)) {
return IOUtils.toString(is, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException(String.format("以字元串方式擷取資源(%s)異常", name), e);
}
}
}
5 JSON資源檔案的來源
JSON資源檔案來源方式很多,作者根據實際操作經驗,總結出以下幾種以供大家參考。
來源于自己組裝
直接利用JSON編輯器或者純文字編輯器,自己一個字段一個字段地編寫JSON資源資料。
[{"name":"Changyi","title":"Java Developer"...},{"name":"Tester","title":"Java Tester"...},...]
注意:這種方式容易出現JSON格式錯誤及字元串轉義問題。
來源于代碼生成
做為程式員,能夠用程式生成JSON資源資料,就絕不手工組裝JSON資源資料。下面,便是利用Fastjson的JSON.toJSONString方法生成JSON資源資料。
public static void main(String[] args) {
List<UserCreateVO> userCreateList = new ArrayList<>();
UserCreateVO userCreate0 = new UserCreateVO();
userCreate0.setName("Changyi");
userCreate0.setTitle("Java Developer");
... // 約幾十行
userCreateList.add(userCreate0);
UserCreateVO userCreate1 = new UserCreateVO();
userCreate1.setName("Tester");
userCreate1.setTitle("Java Tester");
... // 約幾十行
userCreateList.add(userCreate1);
... // 約幾十條
System.out.println(JSON.toJSONString(userCreateList));
}
執行該程式後,生成的JSON資源資料如下:
[{"name":"Changyi","title":"Java Developer"...},{"name":"Tester","title":"Java Tester"...},...]
注意:這種方式能夠避免JSON格式錯誤及字元串轉義問題。
來源于線上日志
如果是事後補充單元測試,首先想到的就是利用線上日志。比如:
2021-08-31 18:55:40,867 INFO [UserService.java:34] - 根據公司辨別(1)查詢所有使用者:[{"id":1,"name":"Changyi","title":"Java Developer"...},{"id":2,"name":"Tester","title":"Java Tester"...},...]
從上面的日志中,我們可以得到方法userDAO.queryByCompanyId的請求參數companyId取值為"1",傳回結果為“[{"id":1,"name":"Changyi","title":"Java Developer"...},{"id":2,"name":"Tester","title":"Java Tester"...},...]”。
注意:要想得到現成的JSON資源資料,就必須輸出完整的JSON資料内容。但是,由于JSON資料内容過大,一般不建議全部輸出。是以,從線上日志中也不一定能夠拿到現成的JSON資源資料。
來源于內建測試
內建測試,就是把整個或部分項目環境運作起來,能夠連接配接資料庫、Redis、MetaQ、HSF等所依賴的第三方服務環境,然後測試某一個方法的功能是否能夠達到預期。
/**
* 使用者DAO測試類
*/
@Slf4j
@RunWith(PandoraBootRunner.class)
@DelegateTo(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {ExampleApplication.class})
public class UserDaoTest {
/** 使用者DAO */
@Resource
private UserDAO userDAO;
/**
* 測試: 根據公司辨別查詢
*/
@Test
public void testQueryByCompanyId() {
Long companyId = 1L;
List<UserDO> userList = userDAO.queryByCompanyId(companyId);
log.info("userList={}", JSON.toJSONString(userList));
}
}
執行上面內建測試用例,輸出的日志内容如下:
2021-08-31 18:55:40,867 INFO [UserDaoTest.java:24] - userList=[{"id":1,"name":"Changyi","title":"Java Developer"...},{"id":2,"name":"Tester","title":"Java Tester"...},...]
上面日志中,userList後面的就是我們需要的JSON資源資料。
我們也可以用內建測試得到方法内部的方法調用的參數值和傳回值,具體方法如下:
- 首先,在源代碼中添加日志輸出語句;
- 然後,執行單元測試用例,得到對應的方法調用參數值和傳回值;
- 最後,删除源代碼中日志輸出語句,恢複源代碼為原來的樣子。
來源于測試過程
有一些資料,是由被測方法生成的,比如:方法傳回值和調用參數。針對這類資料,可以在測試過程中生成,然後逐一進行資料核對,最後整理成JSON資源檔案。
被測方法:
public void batchCreate(List<UserCreate> createList) {
List<UserDO> userList = createList.stream()
.map(UserService::convertUser).collect(Collectors.toList());
userDAO.batchCreate(userList);
}
測試用例:
@Test
public void testBatchCreate() {
// 調用測試方法
List<UserCreate> createList = ...;
userService.batchCreate(createList);
// 驗證測試方法
ArgumentCaptor<List<UserDO>> userListCaptor = CastUtils.cast(ArgumentCaptor.forClass(List.class));
Mockito.verify(userDAO).batchCreate(userListCaptor.capture());
Assert.assertEquals("使用者清單不一緻", "", JSON.toJSONString(userListCaptor.getValue()));
}
執行單元測試後,提示以下問題:
org.junit.ComparisonFailure: 使用者清單不一緻 expected:<[]> but was:<[[{"name":"Changyi","title":"Java Developer"...},{"name":"Tester","title":"Java Tester"...},...]]>
上面的錯誤資訊中,後面括号中的就是我們需要需要的JSON資源資料。
注意:一定要進行資料核對,這有可能是錯誤代碼生成的錯誤資料。用錯誤資料去驗證生成它的代碼,當然不會測試出其中的問題。
6 JSON序列化技巧
這裡以Fastjson為例,介紹一些JSON序列化技巧。
序列化對象
利用JSON.toJSONString方法序列化對象:
UserVO user = ...;
String text = JSON.toJSONString(user);
序列化數組
利用JSON.toJSONString方法序列化數組:
UserVO[] users = ...;
String text = JSON.toJSONString(users);
序列化集合
利用JSON.toJSONString方法序列化集合(繼承至Collection,比如List、Set等集合):
List<UserVO> userList = ...;
String text = JSON.toJSONString(userList);
序列化映射
利用JSON.toJSONString方法序列化映射:
Map<Long, UserVO> userMap = ...;
String text = JSON.toJSONString(userMap, SerializerFeature.MapSortField);
其中,為了保證每次序列化的映射字元串一緻,需要指定序列化參數MapSortField進行排序。
序列化模闆對象
利用JSON.toJSONString方法序列化模闆對象:
Result<UserVO> result = ...;
String text = JSON.toJSONString(result);
序列化指定屬性字段
利用JSON.toJSONString方法序列化指定屬性字段,主要通過設定屬性預過濾器(SimplePropertyPreFilter)的包含屬性字段清單(includes)實作。主要應用于隻想驗證某些字段的情況,比如隻驗證跟測試用例有關的字段。
1)指定所有類的屬性字段
利用JSON.toJSONString方法序列化指定所有類的屬性字段:
UserVO user = ...;
SimplePropertyPreFilter filter = new SimplePropertyPreFilter();
filter.getIncludes().addAll(Arrays.asList("id", "name"));
String text = JSON.toJSONString(user, filter);
2)指定單個類的屬性字段
利用JSON.toJSONString方法序列化指定單個類的屬性字段:
List<UserVO> userList = ...;
SimplePropertyPreFilter filter = new SimplePropertyPreFilter(UserVO.class);
filter.getIncludes().addAll(Arrays.asList("id", "name"));
String text = JSON.toJSONString(userList, filter);
3)指定多個類的屬性字段
利用JSON.toJSONString方法序列化指定多個類的屬性字段:
Pair<UserVO, CompanyVO> userCompanyPair = ...;
SimplePropertyPreFilter userFilter = new SimplePropertyPreFilter(UserVO.class);
userFilter.getUncludes().addAll(Arrays.asList("id", "name"));
SimplePropertyPreFilter companyFilter = new SimplePropertyPreFilter(CompanyVO.class);
companyFilter.getIncludes().addAll(Arrays.asList("id", "name"));
String text = JSON.toJSONString(userCompanyPair, new SerializeFilter[]{userFilter, companyFilter});
序列化字段排除屬性字段
利用JSON.toJSONString方法序列化過濾屬性字段,主要通過設定屬性預過濾器(SimplePropertyPreFilter)的排除屬性字段清單(excludes)實作。主要應用于不想驗證某些字段的情況,比如排除無法驗證的随機屬性字段。
1)排除所有類的屬性字段
利用JSON.toJSONString方法序列化排除所有類的屬性字段:
UserVO user = ...;
SimplePropertyPreFilter filter = new SimplePropertyPreFilter();
filter.getExcludes().addAll(Arrays.asList("gmtCreate", "gmtModified"));
String text = JSON.toJSONString(user, filter);
2)排除單個類的屬性字段
利用JSON.toJSONString方法序列化排除單個類的屬性字段:
List<UserVO> userList = ...;
SimplePropertyPreFilter filter = new SimplePropertyPreFilter(UserVO.class);
filter.getExcludes().addAll(Arrays.asList("gmtCreate", "gmtModified"));
String text = JSON.toJSONString(userList, filter);
3)排除多個類的屬性字段
利用JSON.toJSONString方法序列化排除多個類的屬性字段:
Pair<UserVO, CompanyVO> userCompanyPair = ...;
SimplePropertyPreFilter userFilter = new SimplePropertyPreFilter(UserVO.class);
userFilter.getExcludes().addAll(Arrays.asList("gmtCreate", "gmtModified"));
SimplePropertyPreFilter companyFilter = new SimplePropertyPreFilter(CompanyVO.class);
companyFilter.getExcludes().addAll(Arrays.asList("createTime", "modifyTime"));
String text = JSON.toJSONString(userCompanyPair, new SerializeFilter[]{userFilter, companyFilter});
自定義序列化
對應一些類對象,需要序列化為特殊格式文本,就必須自定義序列化器。比如:Geometry序列化文本,通常采用WKT(Well-known text)表示,便于使用者快速閱讀了解。
1)全局配置序列化器
通過JSON序列化全局配置指定類序列化器:
Geometry geometry = ...;
SerializeConfig.getGlobalInstance().put(Geometry.class, new GeometrySerializer());
String text = JSON.toJSONString(geometry);
注意:這種方式不支援類繼承,必須指定到具體類。比如要序列化Point對象,就必須配置Point類的序列化器。
2)特定配置序列化器
通過JSON序列化特定配置指定類序列化器:
Geometry geometry = ...;
SerializeConfig config = new SerializeConfig();
config.put(Geometry.class, new GeometrySerializer());
String text = JSON.toJSONString(geometry, config);
3)注解配置序列化器
通過JSON序列化注解配置指定類序列化器:
public class User {
...
@JSONField(serializeUsing = GeometrySerializer.class)
private Geometry location;
...
}
User user = ...;
String text = JSON.toJSONString(user);
其中:GeometrySerializer為自定義類,這裡就不貼出具體實作了。
7 JSON反序列化技巧
這裡以Fastjson為例,介紹一些JSON反序列化技巧。
反序列化對象
利用JSON.parseObject方法反序列化對象:
String text = ...;
UserVO user = JSON.parseObject(text, UserVO.class);
反序列化數組
利用JSON.parseObject方法反序列化數組:
String text = ...;
UserVO[] users = JSON.parseObject(text, UserVO[].class);
反序列化集合
利用JSON.parseArray方法反序列化清單:
String text = ...;
List<UserVO> userList = JSON.parseArray(text, UserVO.class);
利用JSON.parseObject方法反序列化集合:
String text = ...;
Set<UserVO> userSet = JSON.parseObject(text, new TypeReference<Set<UserVO>>() {});
反序列化映射
利用JSON.parseObject方法反序列化映射:
String text = ...;
Map<Long, UserVO> userList = JSON.parseObject(text, new TypeReference<Map<Long, UserVO>>() {});
注意:如果映射的key是複雜類型,這種方法反序列會報格式錯誤,需要自定義反序列化器。
反序列化模闆對象
利用JSON.parseObject方法反序列化模闆對象:
String text = ...;
Result<UserVO> result = JSON.parseArray(text, new TypeReference<Result<UserVO>>() {});
反序列化非公有字段
由于某些屬性字段沒有公有設定方法,或者以字段名稱作為公有設定方法。當需要反序列化這些屬性字段時,需要指定SupportNonPublicField(支援非公有字段)反序列化參數。
String text = ...;
UserVO user = JSON.parseObject(text, UserVO.class, Feature.SupportNonPublicField);
反序列化Builder模式類
有些同學喜歡用Builder模式,導緻實體類并沒有公有構造方法。當利用Fastjson反序列化這些類是,就會出現以下問題:
com.alibaba.Fastjson.JSONException: default constructor not found. class com.example.User
隻要對應的Builder類有預設構造方法,就可以采用下面的方式序列化。
String text = ...;
User user = JSON.parseObject(text, User.UserBuilder.class, Feature.SupportNonPublicField).build();
首先通過JSON.parseObject方法+SupportNonPublicField參數反序列化Builder對象,然後通過Builder對象的build方法來構造實體對象。
如果對應的Builder類沒有預設構造方法,或者需要反序列化模闆對象時,需要自定義JSON反序列化器。
反序列化丢失字段值
Fastjson支援沒有預設構造方法的類的反序列化,但存在丢失字段值的問題。
@Getter
@Setter
@ToString
class User {
private Long id;
private String name;
public User(Long id) {
this.id = id;
}
}
String text = "{\"id\":123,\"name\":\"test\"}";
User user = JSON.parseObject(text, User.class); // 會丢失name值
咨詢過Fastjson維護人員,目前還沒有解決這個bug,有待後續版本中解決。如果要反序列化這種類,可以考慮添加預設構造方法或自定義反序列化器。
自定義反序列化器
對應一些類對象,需要把特殊格式文本反序列化為對象,就必須自定義反序列化器。比如:Geometry序列化文本,通常采用WKT(Well-known text)表示,便于使用者快速閱讀了解。
1)全局配置反序列化器
通過JSON序列化全局配置指定類反序列化器:
String text = ...;
ParserConfig.getGlobalInstance().putDeserializer(Geometry.class, new GeometryDeserializer());
Geometry geometry = JSON.parseObject(text, Geometry.class);
注意:這種方式不支援類繼承,必須指定到具體類。比如要序列化Point對象,就必須配置Point類的反序列化器。
2)特定配置反序列化器
通過JSON序列化特定配置指定類反序列化器:
String text = ...;
ParserConfig config = new ParserConfig();
config.putDeserializer(Geometry.class, new GeometryDeserializer());
Geometry geometry = JSON.parseObject(text, Geometry.class, config);
3)注解配置反序列化器
通過JSON序列化注解配置指定類反序列化器:
public class User {
...
@JSONField(deserializeUsing = GeometryDeserializer.class)
private Geometry location;
...
}
String text = ...;
User user = JSON.parseObject(text, User.class);
其中:GeometryDeserializer為自定義類,這裡就不貼出具體實作了。
8 不必要的JSON序列化
以上章節,都是說JSON資源檔案在單元測試中如何運用,如何利用JSON資源檔案把單元測試編寫得更優雅。有時候,任何手段都有兩面性,過渡依賴JSON資源檔案測試,也會把單元測試複雜化。這裡,作者總結了幾個例子以示說明。
完全透傳的對象
1)完全透傳的參數對象
在測試方法中,有些參數沒有被任何修改,隻是完全被透傳而已。
public void batchCreate(List<UserCreate> createList) {
userDAO.batchCreate(createList);
}
@Test
public void testBatchCreate() {
// 調用測試方法
List<UserCreate> createList = new ArrayList<>();
userService.batchCreate(createList);
// 驗證測試方法
Mockito.verify(userDAO).batchCreate(createList);
}
其中,不需要ArgumentCaptor去捕獲userDAO.batchCreate的參數并驗證參數值,這裡隻需要驗證createList是不是同一個對象即可。
2)完全透傳的傳回對象
在測試方法中,有些傳回值沒有被任何修改,隻是完全被透傳而已。
public List<UserVO> queryByCompanyId(Long companyId) {
return userDAO.queryByCompanyId(companyId);
}
@Test
public void testQueryByCondition() {
// 模拟依賴方法
Long companyId = 1L;
List<UserVO> userList = new ArrayList<>();
Mockito.doReturn(userList).when(userDAO).queryByCompanyId(companyId);
// 調用測試方法
Assert.assertEquals("使用者清單不一緻", userList, userService.queryByCompanyId(companyId));
}
其中,userList對象不需要構造資料,隻需要驗證是不是同一個對象即可。
完全透傳的屬性
1)完全透傳的參數值屬性
在測試方法中,有些參數值屬性沒有被任何修改,隻是完全被透傳而已。
public void handleResult(Result<UserVO> result) {
if (!result.isSuccess()) {
metaProducer.sendCouponMessage(result.getData());
}
}
@Test
public void testHandleResultWithSuccess() {
// 調用測試方法
UserVO user = new UserVO();
Result<UserVO> result = Result.success(user);
userService.handleResult(result);
// 驗證依賴方法
Mockito.verify(metaProducer).sendCouponMessage(user);
}
其中,user對象不需要構造資料,隻需要驗證是不是同一個對象即可。
2)完全透傳的傳回值屬性
在測試方法中,有些傳回值屬性沒有被任何修改,隻是完全被透傳而已。
public UserVO get(Long userId) {
Result<UserVO> result = userHsfService.get(userId);
if (!result.isSuccess()) {
throw new ExmapleException(String.format("擷取使用者(%s)失敗:%s", userId, result.getMessage()));
}
return result.getData();
}
@Test
public void testGetWithSuccess() {
// 模拟依賴方法
Long userId = 123L;
UserVO user = UserVO();
Mockito.doReturn(Result.success(user)).when(userHsfService).get(userId);
// 調用測試方法
Assert.assertEquals("使用者資訊不一緻", user, userService.get(userId));
}
僅用少數字段的對象
1)僅用少數字段的參數值對象
在測試方法中,有些參數值對象字段雖多,但隻會用到其中少數字段。
public void create(UserCreate userCreate) {
Boolean exist = userDAO.existByName(userCreate.getName());
if (Boolean.TRUE.equals(exist)) {
throw new ExmapleException(String.format("使用者(%s)已存在", userCreate.getName()));
}
userDAO.create(userCreate);
}
@Test
public void testCreateWithException() {
UserCreate userCreate = new UserCreate();
userCreate.setName("changyi");
ExmapleException exception = Assert.assertThrows("異常類型不一緻", ExmapleException.class, () -> userService.create(userCreate));
Assert.assertEquals("異常消息不一緻", String.format("使用者(%s)已存在", userCreate.getName()), exception.getMessage());
}
其中,不需要構造參數值userCreate的所有屬性字段,隻需構造使用到的name屬性字段即可。
2)僅用少數字段的傳回值對象
在測試方法中,有些傳回值對象字段雖多,但隻會用到其中少數字段。
public boolean isVip(Long userId) {
UserDO user = userDAO.get(userId);
return VIP_ROLE_ID_SET.contains(user.getRoleId());
}
@Test
public void testIsVipWithTrue() {
// 模拟依賴方法
Long userId = 123L;
UserDO user = new UserDO();
user.setRoleId(VIP_ROLE_ID);
Mockito.doReturn(user).when(userDAO).get(userId);
// 調用測試方法
Assert.assertTrue("傳回值不為真", userService.isVip());
}
其中,不需要構造傳回值user的所有屬性字段,隻需構造使用到的roleId屬性字段即可。
使用new還是mock初始化對象?
在上面案例中,我們都采用new來初始化對象并采用set來模拟屬性值的。有些同學會問,為什麼不采用mock來初始化對象、用doReturn-when來模拟屬性值?我想說,都是一樣的效果,隻是前者顯得更簡潔而已。
關于使用new還是mock初始化對象,這個問題在網上一直有争論,雙方都各有自己的理由。
這裡,按照作者的個人使用習慣,進行了簡單的歸納總結如下:
9 JSON結合Mockito妙用
上面已經介紹過,JSON序列化在編寫Java單元測試用例時最大的妙用有兩點:
- JSON反序列化字元串為資料對象,大大減少了資料對象的模拟代碼;
- JSON序列化資料對象為字元串,把資料對象驗證簡化為字元串驗證,大大減少了資料對象的驗證代碼。
除此之外,JSON序列化結合Mockito,往往會起到意想不到的效果,能産生一些非常巧妙有效的用法。
模拟方法傳回多個值
當一個方法需要多次調用,但傳回值跟輸入參數無關,隻跟調用順序有關的時,可以用數組來模拟方法傳回值。先加載一個清單JSON資源檔案,通過JSON.parseObject方法轉化為數組,然後利用Mockito的doReturn-when或when-thenReturn文法來模拟方法傳回多個值。
String text = ResourceHelper.getResourceAsString(getClass(), path + "recordList.json");
Record[] records = JSON.parseObject(text, Record[].class);
Mockito.doReturn(records[0], ArrayUtils.subarray(records, 1, records.length)).when(recordReader).read();
模拟方法傳回對應值
當一個方法需要多次調用,但傳回值跟調用順序有關,隻能調輸入參數有關的時,可以用映射來模拟方法傳回值。先加載一個映射JSON資源檔案,通過JSON.parseObject方法轉化為映射,然後利用Mockito的doAnswer-when或when-thenAnswer文法來模拟方法傳回對應值(根據指定參數傳回映射中的對應值)。
String text = ResourceHelper.getResourceAsString(getClass(), path + "roleMap.json");
Map<Long, String> roleIdMap = JSON.parseObject(text, new TypeReference<Map<Long, String>>() {});
Mockito.doAnswer(invocation -> userMap.get(invocation.getArgument(0))).when(roleService).get(roleId);
驗證多次方法調用參數
當驗證一個方法調用參數時,需要用ArgumentCaptor來捕獲這個參數,然後通過getValue方法驗證這個參數。如果這個方法被多次調用,就沒有必要依次驗證了,可以通過getAllValues方法擷取一個清單,然後通過JSON.toJSONString轉化為JSON字元串,然後跟JSON資源檔案進行統一驗證。
ArgumentCaptor<UserCreateVO> userCreateCaptor = ArgumentCaptor.forClass(UserCreateVO.class);
Mockito.verify(userDAO, Mockito.atLeastOnce()).create(userCreateCaptor.capture());
String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateList.json");
Assert.assertEquals("使用者建立清單不一緻", text, JSON.toJSONString(userCreateCaptor.getAllValues()));
當然,二者結合的妙用不僅限于此。