本文会对 Java 项目中,使用 Mock 测试的方式、效果进行演示说明。

单元测试

在探讨单元测试怎么个搞法之前,我们也应该理解为什么要搞单元测试。

本节内容引用自《代码整洁之道》,书中对于单元测试的意义和原则的解释有独到的见解。作者对单元测试、TDD 方法有深入的看法。

意义

正是单元测试让代码可拓展、可维护、可复用。

因为有单元测试,你不用担心对代码的修改。没有测试,每次修改都可能带来缺陷。无论架构多有扩展性,无论设计划分得有多好,没有了测试,你很难做改动,因为你会担忧改动会引入不可预知的缺陷。

有了测试,愁云一扫而空。测试覆盖率越高,你就越不担心。哪怕是那种架构并不优秀、设计晦涩的代码,你也能近乎没有后患地做修改。实际上,你可以毫无顾虑地改进架构和设计。

原则

  • 整洁:测试代码和生产代码一样重要,它应该像生产代码一样保持整洁。如果测试代码混乱,改动自己代码的能力就会受牵绊,你会丢失改进代码结构的能力。测试越脏乱,代码也会变得越脏乱。最终,你放弃了测试,代码开始腐败。
  • 快速:运行缓慢的测试,没有人会想频繁执行它。如果不频繁执行测试,就不能尽早发现问题并修正。从而无法轻易改动、清理代码,导致代码腐坏。
  • 独立:测试应该相互独立。某个测试不应为下一个测试设定条件。每个测试可以独立运行、并可以按照任何顺序运行。测试相互依赖时,一个错误会导致一连串的失败,使问题难以定位。
  • 可重复:测试应当可以在任何环境中重复通过。无论是生产环境、测试环境。也能够在没有网络的列车上用笔记本电脑运行。如果测试做不到能在任意环境中重复,环境就会成为我们失败的借口,以及阻碍测试的绊脚石。
  • 自我验证:测试应该能自我判断是否正确,你不应该通过查看日志、手动比对文本来确认测试是否通过。如果测试做不到自我验证,对失败的判断就会存在主观因素,而且验证也会需要更久的手工操作时间。
  • 及时:测试编写应该解释。单元测试应该恰好在使其通过的生产代码之前编写。如果编写生产代码之后编写测试,你会发现生产代码难以测试。进而认为生产代码本身难以测试,导致你可能不会去设计可测试的代码。

补充

书中包含更多深入的见解,这里仅对部分内容进行补充

  • 没有通过的 case 具有启发性的意义,在确定摘掉这个 case 前,可以更多思考一下潜在的问题。
  • 针对新发现的 BUG,应该马上补充一个这个这个 BUG 的测试 case。

Mock 测试

为什么 Mock

Mock 测试就是在测试过程中,对于某些不容易构造(如 HttpServletRequest 必须在Servlet 容器中才能构造出来)或者不容易获取比较复杂的对象(如 JDBC 中的ResultSet 对象),用一个虚拟的对象(Mock 对象)来创建以便测试的测试方法。Mock 最大的功能是帮你把单元测试的耦合分解开。——摘抄自 Mockito 详解

使用 Mock 测试时,我们不关注复杂的上下游、环境、网络状态,只关注当下代码。如果我们确实需要关注上游异常、查询为空、意外结果的场景,那么我们把这种情况 Mock 出来即可,上下游的正确性不在当前单元测试的关注范围内。我们保证当前代码正确,上下游也按照同样的规范保证即可。

补充:初次接触 Mock 测试概念时,会遇到一个 Stub 的概念,不是很容易理解(通常翻译为"打桩"),实际上为记录 Mock 行为的过程,比如我们假定调用对象 objmethod1() 方法时,永远返回 0,记录下这个规则的过程就叫 Stub

Mockito

Mockito 是最流行的Java mock框架之一。

Mockito 提供了打桩到验证丰富的特性,在非远古版本中也有提供诸如静态 Mock、接口 Mock 等方法,拓展支持场景,且与 Junit 配合使用效果良好。

使用

本文将通过几个实际场景中的代码段来展示在 Java 中,如何配置、使用 Junit + Mockito 进行测试,以及使用单测对整洁代码编写的帮助。

依赖

本例中,使用了诸如分组测试、静态 Mock、部分断言特性等,需要引入如下依赖:

        <!-- 本例中,使用的各依赖版本,放置于 properties 中 -->
        <junit.platform.version>1.9.0</junit.platform.version>
        <junit.jupiter.version>5.9.0</junit.jupiter.version>
        <mockito.version>4.7.0</mockito.version>

        <!-- Mockito -->
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <version>${mockito.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-inline</artifactId>
            <version>${mockito.version}</version>
            <scope>test</scope>
        </dependency>
        <!-- Junit -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>${junit.jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>${junit.jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
            <version>${junit.jupiter.version}</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-launcher</artifactId>
            <version>${junit.platform.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-runner</artifactId>
            <version>${junit.platform.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-suite-api</artifactId>
            <version>${junit.platform.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.platform</groupId>
            <artifactId>junit-platform-suite-engine</artifactId>
            <version>${junit.platform.version}</version>
            <scope>test</scope>
        </dependency>

示例1,单测的基本使用

NumberUtils 提供了罗马数字和 int 值转换的工具方法,支持 4096 以内数字的互相转换,并提供了一个生成随机数以检验 Enchant 的方法,这个方法不重要,因为在改造过中它被干掉了。原始代码:

@UtilityClass
public final class NumberUtils {

    private static final TreeMap<Integer, String> NUMERALS = new TreeMap<>();

    static {
        NUMERALS.put(1000, "M");
        NUMERALS.put(900, "CM");
        NUMERALS.put(500, "D");
        NUMERALS.put(400, "CD");
        NUMERALS.put(100, "C");
        NUMERALS.put(90, "XC");
        NUMERALS.put(50, "L");
        NUMERALS.put(40, "XL");
        NUMERALS.put(10, "X");
        NUMERALS.put(9, "IX");
        NUMERALS.put(5, "V");
        NUMERALS.put(4, "IV");
        NUMERALS.put(1, "I");
    }
    
    @NotNull
    public static String toNumeral(final int number) {
        if (number >= 1 && number <= 4096) {
            int l = NUMERALS.floorKey(number);
            if (number == l) {
                return NUMERALS.get(number);
            }
            return NUMERALS.get(l) + toNumeral(number - l);
        } else {
            return String.valueOf(number);
        }
    }
    
    public static int fromNumeral(@NotNull final String numeral) {
        if (numeral.isEmpty()) {
            return 0;
        }
        for (Map.Entry<Integer, String> entry : NUMERALS.descendingMap().entrySet()) {
            if (numeral.startsWith(entry.getValue())) {
                return entry.getKey() + fromNumeral(numeral.substring(entry.getValue().length()));
            }
        }
        return 0;
    }

    /**
     * 检验字符串是否能通过本类提供的方法成功转换
     */
    public static boolean isValidNumeral(final String numeral) {
        if (StrUtil.isEmptyIfStr(numeral)) {
            return false;
        }
        int converted = fromNumeral(numeral);
        return toNumeral(converted).equals(numeral);
    }
    
    /**
     * 生成随机数并检查 Enchant 是否能通过
     */
    public static boolean passedChance(@NotNull final AbstractSwEnchantment enchantment,
                                       final int level) {
        return RandomUtil.randomDouble(0, 1.0) < (double) (enchantment.getConfig().getAddition() * level) / 100;
    }
}

在尝试编写单元测试时发现,isValidNumeral 方法并没有被任何业务代码用到,果断删掉它,不要注释掉它,注释掉的代码令人抓狂,没人敢动它。不要担心之后是否会用到,版本管理工具会记得它。

再来看 passedChance 这个方法,要测试它需要 mock 一个 AbstractSwEnchantment 对象,紧接着 mock getConfig() 方法,我们还需要知道 getConfig() 返回值的格式,以及 addition, level 的含义。

不难发现,在 NumberUtils 这个工具类里做这些不太合适。我们把这个方法移到 AbstractSwEnchantment 类中,变成如下的样子,参数变少,且语义更加清晰。

    protected boolean passChance(int level) {
        double chance = getConfig().getAddition() * level / 100.0;
        return RandomUtil.randomDouble(0, 1.0) < chance;
    }

至此,NumberUtils 这个类做的事更加单一了,应该重新命名为 RomanNumberUtils 比较合适。

我们最后得到两个要测试的方法,它们都是静态方法,不需要任何 mock,Junit 就足以胜任。最后的测试代码如下:

public class RomanNumberUtilTest {

    @Test
    void normalRomanNumberTest() {
        for (int i = 1; i <= RomanNumberUtils.MAX_SUPPORT_ROMAN_NUMBER; i++) {
            String converted = RomanNumberUtils.toNumeral(i);
            int recovered = RomanNumberUtils.fromNumeral(converted);
            assertEquals(i, recovered, "转换、恢复后不变");
        }
    }

    @Test
    void malformedNumberTest() {
        assertThrows(NumberFormatException.class, () -> RomanNumberUtils.fromNumeral("XYZ"), "包含非法字符时,报错");
        assertThrows(NumberFormatException.class, () -> RomanNumberUtils.fromNumeral("123"), "纯数字,报错");
        assertEquals(0, RomanNumberUtils.fromNumeral(""), "空串解析为 0");
    }
}

实例2,使用 Mock

更多时候,我们面临的场景更加复杂,许多对象需要在特定的环境中才能使用。

比如以下的场景,StrengthenWeaponJavaPlugin 的子类,我们希望在 onLoad 时,自动生成配置文件,但如果配置文件已经存在,则进行覆盖。

@Slf4j
public class StrengthenWeapon extends JavaPlugin {

    private static StrengthenWeapon instance = null;

    @Override
    public void onLoad() {
        File configYmlFile = new File(this.getDataFolder(), Constants.CONFIG_FILE_NAME);
        if (!configYmlFile.exists()) {
            saveResource(Constants.CONFIG_FILE_NAME, false);
            saveResource(Constants.DEFAULT_ITEM_FILE_NAME, false);
        }
        // Sonar 不推荐在成员方法中直接修改静态变量
        setInstance(this);
    }

    private static void setInstance(StrengthenWeapon instance) {
        StrengthenWeapon.instance = instance;
    }

    /**
     * 获取 plugin 实例
     *
     * @return StrengthenWeapon 对象
     * @throws SwException 如果插件尚未加载完全时抛出
     */
    public static StrengthenWeapon getInstance() {
        if (null == instance) {
            throw new SwException("插件正在加载中");
        }
        return instance;
    }
    
    // ... 其他方法 ...
}

题外话,代码应该比我们来的时候更整洁,当前的代码段存在下列问题:

  • onLoad() 方法明显做了不止一件事,应该把配置文件初始化的部分整理出来,并取一个合适的名字解释行为。
  • 莫名其妙的注释应该干掉
  • SwException 是一个通用异常,应该定义一个新的,更有解释性的异常类。

代码整理后如下所示:

@Slf4j
public class StrengthenWeapon extends JavaPlugin {

    private static StrengthenWeapon instance = null;

    @Override
    public void onLoad() {
        initConfigFile();
        setInstance(this);
    }

    private void initConfigFile() {
        File configYmlFile = new File(this.getDataFolder(), ConfigManager.CONFIG_FILE_NAME);
        if (!configYmlFile.exists()) {
            saveResource(ConfigManager.CONFIG_FILE_NAME, false);
            saveResource(ConfigManager.DEFAULT_ITEM_FILE_NAME, false);
        }
    }

    private static void setInstance(StrengthenWeapon instance) {
        StrengthenWeapon.instance = instance;
    }

    /**
     * 获取 plugin 实例
     *
     * @return StrengthenWeapon 对象
     * @throws SwException 如果插件尚未加载完全时抛出
     */
    public static StrengthenWeapon getInstance() {
        if (null == instance) {
            throw new LifeCycleException("插件正在加载中");
        }
        return instance;
    }
    
    // ... 其他方法 ...
}

现在要对这个类补充单元测试,其中 saveResource 方法为父类提供,不在我们测试范围内,但是我们需要使用其功能(保存文件),所以,我们需要把这个方法的功能 Mock 一下,只实现我们关注的功能即可(保存文件功能)。

我们需要关注测试文件是否生成,测试文件已生成时是否会重复生成,我们使用了 File.exists() 方法来判断,这就要求我们的 Mock 方法确实能够保存文件。是否重复生成也可以通过判断 saveResource 方法的调用次数来验证。至此,已经可以着手实现测试了:

public class StrengthenWeaponTest {

    @Test
    void testOnLoad() {
        clearWorkspace();
        shouldAutoGenerateConfigYmlOnLoad();
        shouldNotChangeConfigYmlIfExistsOnLoad();
        clearWorkspace();
    }

    private void clearWorkspace() {
        String workPath = new File("./").getAbsolutePath() + "/";
        FileUtil.del(workPath + ConfigManager.CONFIG_FILE_NAME);
        FileUtil.del(workPath + ConfigManager.DEFAULT_ITEM_FILE_NAME);
        FileUtil.del(workPath + ITEMS_CONFIG_FOLDER_NAME);
    }

    private void shouldAutoGenerateConfigYmlOnLoad() {
        StrengthenWeapon plugin = mockPluginCallOnLoadMethod();
        plugin.onLoad();
        // 配置文件不存在时,需要生成。
        // 通过 saveResource 方法的调用次数(为1)可以验证
        verify(plugin, times(1)).saveResource(ConfigManager.CONFIG_FILE_NAME, false);
        verify(plugin, times(1)).saveResource(ConfigManager.DEFAULT_ITEM_FILE_NAME, false);
    }

    private void shouldNotChangeConfigYmlIfExistsOnLoad() {
        StrengthenWeapon plugin = mockPluginCallOnLoadMethod();
        plugin.onLoad();
        // 配置文件存在时,不需要修改。
        // 通过 saveResource 方法的调用次数(为0)可以验证
        verify(plugin, times(0)).saveResource(ConfigManager.CONFIG_FILE_NAME, false);
        verify(plugin, times(0)).saveResource(ConfigManager.DEFAULT_ITEM_FILE_NAME, false);
    }

    private StrengthenWeapon mockPluginCallOnLoadMethod() {
        // StrengthenWeapon 类在特定环境下才能实例化,可以使用 Mock 得到它的实例
        StrengthenWeapon plugin = mock(StrengthenWeapon.class);
        
        // Mock saveResouce 方法的行为,用 mockSaveResourceMethod 方法替代
        doAnswer(this::mockSaveResourceMethod).when(plugin).saveResource(ConfigManager.CONFIG_FILE_NAME, false);
        
        // plugin 对象被 Mock,执行要测试的方法(onLoad)时,需要显示声明调用真实方法
        doCallRealMethod().when(plugin).onLoad();
        return plugin;
    }

    private Object mockSaveResourceMethod(InvocationOnMock invocationOnMock) {
        Object pathArg = invocationOnMock.getArgument(0);
        Object replaceFlagArg = invocationOnMock.getArgument(1);

        String filepath = pathArg.toString();
        boolean replaceFlag = (boolean) replaceFlagArg;

        File targetFile = new File(filepath);
        boolean shouldCreate = replaceFlag || !targetFile.exists();
        if (shouldCreate) {
            FileUtil.touch(targetFile);
        }
        return null;
    }
}

实例3,在 Spring 项目中使用

为方便说明,这里以一个涉及 RPC 调用、涉及数据库交互业务流程中,service 层的一个类做解释说明。

getPosterData 方法,需要我们需要查一个用户信息的外部接口,再查询数据库获取一些信息,做一些处理后返回拼接好的 DTO。

假设我们的代码足够松散,我们需要的信息可以调用如下方法查询:

  • 用户信息:封装为 userProxy.getUserName(id) 方法,外部调用的细节由方法内部关心
  • 需要查库的信息,封装为 rewardBusinessService.queryRewardById(id) 方法,具体的查库逻辑由方法本身关注
  • 需要查库的信息,且要查的实体被当前 Service 直接关注,比如查询 posterData 实体,需要由 PosterDataMapper 提供,假设方法名 queryPosterDataByRewardId(rewardId)

则有如下测试代码,该单元测试可以忽略环境运行,无需实际连接哪个数据库,也不关注接口的提供方状态如何,如果关心边界情况,则完全可以在 mock 时进行模拟。

public class PosterDataServiceTest {

    @Mock
    private UserProxy userProxy;
    
    @Mock
    private RewardBusinessServiceImpl rewardBusinessService;
    
    @Mock
    private PosterDataMapper posterDataMapper;

    @Spy
    @InjectMocks
    private PosterDataServiceImpl posterDataService;
    
    @Before
    public void setup() {
        MockitoAnnotations.initMocks(this);
        doStub();
    }
    
    private void doStud() {
        // Mock 方法返回
        when(userProxy.getUserName(mockUid)).thenReturn(mockUserName);
        when(rewardBusinessService.queryRewardById(mockRewardId)).thenReturn(mockRewardInfo1, mockRewardInfo2);
        when(posterDataMapper.queryPosterDataByRewardId(mockRewardId)).thenReturn(mockPosterData);
    }
    
    @Test
    public void getPosterDataTest() {
        PosterDataDTO posterData1 = posterDataService.getPosterData(mockUid);
        PosterDataDTO posterData2 = posterDataService.getPosterData(mockUid);
        
        assertEquals("case描述", "期望值", posterData1.getXxx());
        assertEquals("case描述", "期望值", posterData2.getXxx());
    }
}

拓展

分组测试

最典型的场景,我们可以使用分组测试来运行全部的测试用例。有了这个我们可以随时运行全部测试用例,在编写过程中即时运行全部用例,这有助于我们及时发现问题。

我们需要一个描述测试的 Suite 类,声明当前分组包含哪些用例,可以按照类、包名进行分组,这个类可以作为测试类直接运行,写法如下:

@Suite
@SuiteDisplayName("全部用例")
@SelectClasses({StrengthenWeaponTest.class})
@SelectPackages({"fun.nekomc.sw.common", "fun.nekomc.sw.utils"})
public class AllTestSuite {
}

覆盖率

覆盖率一定程度上可以描述代码的可靠性,覆盖率越高,我们越有底气去优化代码。但在业务代码中我们往往不太关注覆盖率。 有些公司根本不关注这个指标

我们使用的 IDE 工具应该有覆盖率工具,它会明确指明具体哪一行、哪个类没有覆盖到,覆盖率如何以及汇总信息,以 IDEA 为例,其效果如图所示:

覆盖率

本文使用的示例覆盖率非常低。 单元测试应该与业务代码一起编写、维护,如果在业务代码存在时再去编写单元测试,就会感觉过于困难,业务代码太难测试,然后给自己很多接口,不断降低标准,从而进行不下去。就像上图一样,废了好大的力气,达到 8% 的覆盖率,就已经肝不动了。

如果运行后没有显示覆盖率信息,需要修改运行配置如下:

启动项配置


更多实例可以参考 Mockito 文档 - Mockito at Github