Java單元測試技巧之 JSON序列化

前言

《論語》中孔子有言:“工欲善其事,必先利其器。

今年7月,作者希望迎接更大的挑戰,從高德地圖數據轉崗到共享出行後,接手並維護了幾個Java後端項目。在熟悉業務和代碼的過程中,快速地對原有項目進行單元測試用例的補充,使其單元測試覆蓋率達到70%+甚至於100%。有同事問我:“你寫單元測試爲什麼這麼快?”我微微一笑:“工欲善其事,必先利其器。而我快速編寫Java單元測試用例的技巧就是——JSON序列化。”

是的,做任何事情,都要講究方式方法;只要方式方法對了,就會事半功倍。這裏,作者系統性地總結了JSON序列化在編寫Java單元測試用例中的使用技巧,希望能夠讓大家“讀有所得、得有所思、思有所獲”。

1. 冗長的單元測試代碼

在編寫單元測試用例的過程中,經常會出現以下冗長的單元測試代碼。

1.1. 冗長的數據模擬代碼

1.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);

1.1.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);

1.1.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.2. 冗長的數據驗證代碼

1.2.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());
... // 約幾十行
... // 約幾十條

1.2.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序列化來簡化上面的單元測試用例代碼,讓大家先睹爲快。

2.1. 簡化數據模擬代碼

對於數據模擬,首先需要先加載JSON資源文件爲字符串,然後通過JSON反序列化字符串爲數據對象,最後用於模擬類屬性值、方法參數值和方法返回值。這樣,就精簡了原來冗長的賦值語句。

2.1.1. 模擬類屬性值

利用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"...}

2.1.2. 模擬方法參數值

利用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"...},...]

2.1.3. 模擬方法返回值

利用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"...},...]

2.2. 簡化數據驗證代碼

對於數據驗證,首先需要先加載JSON資源文件爲字符串,然後通過JSON序列化數據對象爲字符串,最後驗證兩字符串是否一致。這樣,就精簡了原來冗長的驗證語句。

2.2.1. 驗證方法返回值

利用JSON序列化,簡化驗證方法返回值代碼如下:

Long companyId = 1L;
List<UserVO> userList = userService.queryByCompanyId(companyId);
String text = ResourceHelper.getResourceAsString(getClass(), path + "userList.json");
Assert.assertEquals("用戶列表不一致", text, JSON.toJSONString(userList));

其中,JSON資源文件userList.json的內容如下:

[{"name":"Changyi","title":"Java Developer"...},{"name":"Tester","title":"Java Tester"...},...]

2.2.2. 驗證方法參數值

利用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()));

其中,JSON資源文件userCreateList.json的內容如下:

[{"name":"Changyi","title":"Java Developer"...},{"name":"Tester","title":"Java Tester"...},...]

3. 測試用例及資源命名

俗話說:“沒有規矩,不成方圓。”所以,爲了更好地利用JSON序列化技巧,首先對測試用例和資源文件進行規範化命名。

3.1. 測試類命名

按照行業慣例,測試類的命名應以被測試類名開頭並以Test結尾。比如:UserService(用戶服務類)的測試類需要命名爲UserServiceTest(用戶服務測試類)。

單元測試類應該放在被測試類的同一工程的"src/test/java"目錄下,並且要放在被測試類的同一包下。注意,單元測試類不允許寫在業務代碼目錄下,否則在編譯時沒法過濾這些測試用例。

3.2. 測試方法命名

按照行業規範,測試方法命名應以test開頭並以被測試方法結尾。比如:batchCreate(批量創建)的測試方法需要命名爲testBatchCreate(測試:批量創建),queryByCompanyId(根據公司標識查詢)的測試方法需要命名爲testQueryByCompanyId(測試:根據公司標識查詢)。

當一個方法對應多個測試用例時,就需要創建多個測試方法,原有測試方法命名已經不能滿足需求了。有人建議在原有的測試方法命名的基礎上,添加123等序號表示不同的用例。比如:testBatchCreate1(測試:批量創建1)、testBatchCreate2(測試:批量創建2)……但是,這種方法不能明確每個單元測試的用意。

這裏,作者建議在原有的測試方法命名的基礎上,添加”With+條件“來表達不同的測試用例方法。

1.按照結果命名:

    • testBatchCreateWithSuccess(測試:批量創建-成功);
    • testBatchCreateWithFailure(測試:批量創建-失敗);
    • testBatchCreateWithException(測試:批量創建-異常);

2.按照參數命名:

    • testBatchCreateWithListNull(測試:批量創建-列表爲NULL);
    • testBatchCreateWithListEmpty(測試:批量創建-列表爲空);
    • testBatchCreateWithListNotEmpty(測試:批量創建-列表不爲空);

3.按照意圖命名:

    • testBatchCreateWithNormal(測試:批量創建-正常);
    • testBatchCreateWithGray(測試:批量創建-灰度);
    • testBatchCreateWithException(測試:批量創建-異常);

當然,還有形成其它的測試方法命名方式,也可以把不同的測試方法命名方式混用,只要能清楚地表達出這個測試用例的涵義即可。

3.3. 測試類資源目錄命名

這裏,作者建議的資源目錄命名方式爲——以test開頭且以被測試類名結尾。比如:UserService(用戶服務類)的測試資源目錄可以命名爲testUserService。

那麼,這個資源目錄應該放在哪兒了?作者提供了2個選擇:

  1. 放在“src/test/java”目錄下,跟測試類放在同一目錄下——這是作者最喜歡的方式;
  2. 放在“src/test/resources”目錄下,跟測試類放在同一目錄下——建議IDEA用戶採用這種方式。

3.4. 測試方法資源目錄命名

在前面的小節中,我們針對測試方法進行了規範命名。這裏,我們可以直接拿來使用——即用測試方法名稱來命名測試目錄。當然,這些測試方法資源目錄應該放在測試類資源目錄下。比如:測試類UserServiceTest(用戶服務測試類)的測試方法testBatchCreateWithSuccess(測試:批量創建-成功)的測試資源目錄就是testUserService/testBatchCreateWithSuccess。

另外,也可以採用“測試方法名稱”+“測試條件名稱”二級目錄的命名方式。比如:測試類UserServiceTest(用戶服務測試類)的測試方法testBatchCreateWithSuccess(測試:批量創建-成功)的測試資源目錄就是testUserService/testBatchCreate/success。

這裏,作者首推的是第一種方式,因爲測試方法名稱和資源目錄名稱能夠保持一致。

3.5. 測試資源文件命名

在被測試代碼中,所有參數、變量都已經有了命名。所以,建議優先使用這些參數和變量的名稱,並加後綴“.json”標識文件格式。如果這些資源文件名稱衝突,可以添加前綴以示區分。比如:userCreateList的資源文件名稱爲"userCreateList.json"。

另外,在測試用例代碼中,把這些測試資源文件加載後,反序列化爲對應的數據對象,這些數據對象的變量名稱也應該跟資源文件名稱保持一致。

String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateList.json");
List<UserCreateVO> userCreateList = JSON.parseArray(text, UserCreateVO.class);
userService.batchCreate(userCreateList);

3.6. 測試資源文件存儲

在測試資源目錄和名稱定義好之後,就需要存入測試資源文件了。存儲方式總結如下:

  1. 如果是測試類下所有測試用例共用的資源文件,建議存儲在測試類資源目錄下,比如:testUserService;
  2. 如果是測試用例獨有的資源文件,建議存儲在測試方法資源目錄下,比如:testUserService/testBatchCreateWithSuccess;
  3. 如果是某一被測方法所有的測試用例共用的資源文件,建議存儲在不帶任何修飾的測試方法資源目錄下,比如:testUserService/testBatchCreate;
  4. 如果測試類資源目錄下只有一個測試方法資源目錄,可以去掉這個測試方法資源目錄,把所有資源文件存儲在測試類資源目錄下。

注意:這裏的資源文件不光是JSON資源文件,但也可以是其它類型的資源文件。

3.7. 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個字符。

3.8. JSON資源文件格式

關於JSON資源文件是否格式化的建議:不要格式化JSON資源文件內容,否則會佔用更多的代碼行數,還會導致無法直接進行文本比較。

4. 測試資源使用案例

在上一章中,講了測試用例和資源的命名規則以及存放方式。但是,只是文字的描述,沒有什麼體感。所有,這一章將舉例一個完整的案例來實際說明。

4.1. 被測案例代碼

以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;
    }

}

4.2. 測試用例代碼

編寫完整的測試用例如下:

/**
 * 用戶服務測試類
 */
@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);
    }

}

4.3. 資源文件目錄

測試用例所涉及的資源文件目錄如下:

其中,資源文件內容比較簡單,這裏就不再累述了。

4.4. 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>

簡要說明如下:

  1. 在屬性配置中,配置了單元測試所依賴的包版本;
  2. 在依賴配置中,配置了單元測試所依賴的包名稱;
  3. 在構建配置中,配置了編譯時需要拷貝目錄下的資源文件(如果有其它的資源文件格式,需要在pom中配置添加)。

4.5. 工具類代碼

在上面單元測試用例中,需要使用到一個工具類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);
        }
    }

}

如果在集團內部,也可以直接導入作者提供的二方工具包:

<dependency>
    <groupId>com.amap</groupId>
    <artifactId>aostools-util</artifactId>
    <version>1.0.1</version>
</dependency>

5. JSON資源文件的來源

JSON資源文件來源方式很多,作者根據實際操作經驗,總結出以下幾種以供大家參考。

5.1. 來源於自己組裝

直接利用JSON編輯器或者純文本編輯器,自己一個字段一個字段地編寫JSON資源數據。

[{"name":"Changyi","title":"Java Developer"...},{"name":"Tester","title":"Java Tester"...},...]

注意:這種方式容易出現JSON格式錯誤及字符串轉義問題。

5.2. 來源於代碼生成

做爲程序員,能夠用程序生成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格式錯誤及字符串轉義問題。

5.3. 來源於線上日誌

如果是事後補充單元測試,首先想到的就是利用線上日誌。比如:

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資源數據。

5.4. 來源於集成測試

集成測試,就是把整個或部分項目環境運行起來,能夠連接數據庫、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資源數據。

我們也可以用集成測試得到方法內部的方法調用的參數值和返回值,具體方法如下:

  1. 首先,在源代碼中添加日誌輸出語句;
  2. 然後,執行單元測試用例,得到對應的方法調用參數值和返回值;
  3. 最後,刪除源代碼中日誌輸出語句,恢復源代碼爲原來的樣子。

5.5. 來源於測試過程

有一些數據,是由被測方法生成的,比如:方法返回值和調用參數。針對這類數據,可以在測試過程中生成,然後逐一進行數據覈對,最後整理成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序列化技巧。

6.1. 序列化對象

利用JSON.toJSONString方法序列化對象:

UserVO user = ...;
String text = JSON.toJSONString(user);

6.2. 序列化數組

利用JSON.toJSONString方法序列化數組:

UserVO[] users = ...;
String text = JSON.toJSONString(users);

6.3. 序列化集合

利用JSON.toJSONString方法序列化集合(繼承至Collection,比如List、Set等集合):

List<UserVO> userList = ...;
String text = JSON.toJSONString(userList);

6.4. 序列化映射

利用JSON.toJSONString方法序列化映射:

Map<Long, UserVO> userMap = ...;
String text = JSON.toJSONString(userMap, SerializerFeature.MapSortField);

其中,爲了保證每次序列化的映射字符串一致,需要指定序列化參數MapSortField進行排序。

6.5. 序列化模板對象

利用JSON.toJSONString方法序列化模板對象:

Result<UserVO> result = ...;
String text = JSON.toJSONString(result);

6.6. 序列化指定屬性字段

利用JSON.toJSONString方法序列化指定屬性字段,主要通過設置屬性預過濾器(SimplePropertyPreFilter)的包含屬性字段列表(includes)實現。主要應用於只想驗證某些字段的情況,比如只驗證跟測試用例有關的字段。

6.6.1. 指定所有類的屬性字段

利用JSON.toJSONString方法序列化指定所有類的屬性字段:

UserVO user = ...;
SimplePropertyPreFilter filter = new SimplePropertyPreFilter();
filter.getIncludes().addAll(Arrays.asList("id", "name"));
String text = JSON.toJSONString(user, filter);

6.6.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);

6.6.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});

6.7. 序列化字段排除屬性字段

利用JSON.toJSONString方法序列化過濾屬性字段,主要通過設置屬性預過濾器(SimplePropertyPreFilter)的排除屬性字段列表(excludes)實現。主要應用於不想驗證某些字段的情況,比如排除無法驗證的隨機屬性字段。

6.7.1. 排除所有類的屬性字段

利用JSON.toJSONString方法序列化排除所有類的屬性字段:

UserVO user = ...;
SimplePropertyPreFilter filter = new SimplePropertyPreFilter();
filter.getExcludes().addAll(Arrays.asList("gmtCreate", "gmtModified"));
String text = JSON.toJSONString(user, filter);

6.7.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);

6.7.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});

6.8. 自定義序列化

對應一些類對象,需要序列化爲特殊格式文本,就必須自定義序列化器。比如:Geometry序列化文本,通常採用WKT(Well-known text)表示,便於用戶快速閱讀理解。

6.8.1. 全局配置序列化器

通過JSON序列化全局配置指定類序列化器:

Geometry geometry = ...;
SerializeConfig.getGlobalInstance().put(Geometry.class, new GeometrySerializer());
String text = JSON.toJSONString(geometry);

注意:這種方式不支持類繼承,必須指定到具體類。比如要序列化Point對象,就必須配置Point類的序列化器。

6.8.2. 特定配置序列化器

通過JSON序列化特定配置指定類序列化器:

Geometry geometry = ...;
SerializeConfig config = new SerializeConfig();
config.put(Geometry.class, new GeometrySerializer());
String text = JSON.toJSONString(geometry, config);

注意:這種方式不支持類繼承,必須指定到具體類。比如要序列化Point對象,就必須配置Point類的序列化器。

6.8.3. 註解配置序列化器

通過JSON序列化註解配置指定類序列化器:

public class User {
    ...
    @JSONField(serializeUsing = GeometrySerializer.class)
    private Geometry location;
    ...
}

User user = ...;
String text = JSON.toJSONString(user);

其中:GeometrySerializer爲自定義類,這裏就不貼出具體實現了。

7. JSON反序列化技巧

這裏以Fastjson爲例,介紹一些JSON反序列化技巧。

7.1. 反序列化對象

利用JSON.parseObject方法反序列化對象:

String text = ...;
UserVO user = JSON.parseObject(text, UserVO.class);

7.2. 反序列化數組

利用JSON.parseObject方法反序列化數組:

String text = ...;
UserVO[] users = JSON.parseObject(text, UserVO[].class);

7.3. 反序列化集合

利用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>>() {});

7.4. 反序列化映射

利用JSON.parseObject方法反序列化映射:

String text = ...;
Map<Long, UserVO> userList = JSON.parseObject(text, new TypeReference<Map<Long, UserVO>>() {});

注意:如果映射的key是複雜類型,這種方法反序列會報格式錯誤,需要自定義反序列化器。

7.5. 反序列化模板對象

利用JSON.parseObject方法反序列化模板對象:

String text = ...;
Result<UserVO> result = JSON.parseArray(text, new TypeReference<Result<UserVO>>() {});

7.6. 反序列化非公有字段

由於某些屬性字段沒有公有設置方法,或者以字段名稱作爲公有設置方法。當需要反序列化這些屬性字段時,需要指定SupportNonPublicField(支持非公有字段)反序列化參數。

String text = ...;
UserVO user = JSON.parseObject(text, UserVO.class, Feature.SupportNonPublicField);

7.7. 反序列化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反序列化器。

7.8. 反序列化丟失字段值

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,有待後續版本中解決。如果要反序列化這種類,可以考慮添加默認構造方法或自定義反序列化器。

7.9. 自定義反序列化器

對應一些類對象,需要把特殊格式文本反序列化爲對象,就必須自定義反序列化器。比如:Geometry序列化文本,通常採用WKT(Well-known text)表示,便於用戶快速閱讀理解。

7.9.1. 全局配置反序列化器

通過JSON序列化全局配置指定類反序列化器:

String text = ...;
ParserConfig.getGlobalInstance().putDeserializer(Geometry.class, new GeometryDeserializer());
Geometry geometry = JSON.parseObject(text, Geometry.class);

注意:這種方式不支持類繼承,必須指定到具體類。比如要序列化Point對象,就必須配置Point類的反序列化器。

7.9.2. 特定配置反序列化器

通過JSON序列化特定配置指定類反序列化器:

String text = ...;
ParserConfig config = new ParserConfig();
config.putDeserializer(Geometry.class, new GeometryDeserializer());
Geometry geometry = JSON.parseObject(text, Geometry.class, config);

注意:這種方式不支持類繼承,必須指定到具體類。比如要序列化Point對象,就必須配置Point類的反序列化器。

7.9.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資源文件測試,也會把單元測試複雜化。這裏,作者總結了幾個例子以示說明。

8.1. 完全透傳的對象

8.1.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是不是同一個對象即可。

8.1.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對象不需要構造數據,只需要驗證是不是同一個對象即可。

8.2. 完全透傳的屬性

8.2.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對象不需要構造數據,只需要驗證是不是同一個對象即可。

8.2.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));
}

其中,user對象不需要構造數據,只需要驗證是不是同一個對象即可。

8.3. 僅用少數字段的對象

8.3.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屬性字段即可。

8.3.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屬性字段即可。

8.4. 使用new還是mock初始化對象?

在上面案例中,我們都採用new來初始化對象並採用set來模擬屬性值的。有些同學會問,爲什麼不採用mock來初始化對象、用doReturn-when來模擬屬性值?我想說,都是一樣的效果,只是前者顯得更簡潔而已。

關於使用new還是mock初始化對象,這個問題在網上一直有爭論,雙方都各有自己的理由。

這裏,按照作者的個人使用習慣,進行了簡單的歸納總結如下:

使用情形 採用new初始化對象 採用mock初始化對象
實體類 首選 可以
接口類 不可以 首選
虛基類 不可以 首選
簡單方法 首選 可以
複雜方法 可以 首選

9. JSON結合Mockito妙用

上面已經介紹過,JSON序列化在編寫Java單元測試用例時最大的妙用有兩點:

  1. JSON反序列化字符串爲數據對象,大大減少了數據對象的模擬代碼;
  2. JSON序列化數據對象爲字符串,把數據對象驗證簡化爲字符串驗證,大大減少了數據對象的驗證代碼。

除此之外,JSON序列化結合Mockito,往往會起到意想不到的效果,能產生一些非常巧妙有效的用法。

9.1. 模擬方法返回多個值

當一個方法需要多次調用,但返回值跟輸入參數無關,只跟調用順序有關的時,可以用數組來模擬方法返回值。先加載一個列表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();

9.2. 模擬方法返回對應值

當一個方法需要多次調用,但返回值跟調用順序有關,只能調輸入參數有關的時,可以用映射來模擬方法返回值。先加載一個映射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);

9.3. 驗證多次方法調用參數

當驗證一個方法調用參數時,需要用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()));

當然,二者結合的妙用不僅限於此,還有更多作者沒有總結到的。

後記

閒暇之餘,填詞一首自娛自樂,藉以致敬廣大的碼農們:

《卜算子·致碼農》
業務要精通,(仄仄仄平平)
眼界需開闊。(仄仄平平
談笑風生略幾回,(平仄平平仄仄平)
草案心頭過。(仄仄平平
編寫守規則,(平仄仄平平)
測試知結果。(仄仄平平
奮筆疾書數萬行,(仄仄平平仄仄平)
代碼無一錯。(仄仄平平

其中,第二段便說明了代碼規範單元測試的重要性——只有做到“編寫守規則,測試知結果”,才能保障“奮筆疾書數萬行,代碼無一錯”。

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章