本文会对 Java 项目中,使用 Mock 测试的方式、效果进行演示说明。
单元测试
在探讨单元测试怎么个搞法之前,我们也应该理解为什么要搞单元测试。
本节内容引用自《代码整洁之道》,书中对于单元测试的意义和原则的解释有独到的见解。作者对单元测试、TDD 方法有深入的看法。
意义
正是单元测试让代码可拓展、可维护、可复用。
因为有单元测试,你不用担心对代码的修改。没有测试,每次修改都可能带来缺陷。无论架构多有扩展性,无论设计划分得有多好,没有了测试,你很难做改动,因为你会担忧改动会引入不可预知的缺陷。
有了测试,愁云一扫而空。测试覆盖率越高,你就越不担心。哪怕是那种架构并不优秀、设计晦涩的代码,你也能近乎没有后患地做修改。实际上,你可以毫无顾虑地改进架构和设计。
原则
- 整洁:测试代码和生产代码一样重要,它应该像生产代码一样保持整洁。如果测试代码混乱,改动自己代码的能力就会受牵绊,你会丢失改进代码结构的能力。测试越脏乱,代码也会变得越脏乱。最终,你放弃了测试,代码开始腐败。
- 快速:运行缓慢的测试,没有人会想频繁执行它。如果不频繁执行测试,就不能尽早发现问题并修正。从而无法轻易改动、清理代码,导致代码腐坏。
- 独立:测试应该相互独立。某个测试不应为下一个测试设定条件。每个测试可以独立运行、并可以按照任何顺序运行。测试相互依赖时,一个错误会导致一连串的失败,使问题难以定位。
- 可重复:测试应当可以在任何环境中重复通过。无论是生产环境、测试环境。也能够在没有网络的列车上用笔记本电脑运行。如果测试做不到能在任意环境中重复,环境就会成为我们失败的借口,以及阻碍测试的绊脚石。
- 自我验证:测试应该能自我判断是否正确,你不应该通过查看日志、手动比对文本来确认测试是否通过。如果测试做不到自我验证,对失败的判断就会存在主观因素,而且验证也会需要更久的手工操作时间。
- 及时:测试编写应该解释。单元测试应该恰好在使其通过的生产代码之前编写。如果编写生产代码之后编写测试,你会发现生产代码难以测试。进而认为生产代码本身难以测试,导致你可能不会去设计可测试的代码。
补充
书中包含更多深入的见解,这里仅对部分内容进行补充
- 没有通过的 case 具有启发性的意义,在确定摘掉这个 case 前,可以更多思考一下潜在的问题。
- 针对新发现的 BUG,应该马上补充一个这个这个 BUG 的测试 case。
Mock 测试
为什么 Mock
Mock 测试就是在测试过程中,对于某些不容易构造(如 HttpServletRequest 必须在Servlet 容器中才能构造出来)或者不容易获取比较复杂的对象(如 JDBC 中的ResultSet 对象),用一个虚拟的对象(Mock 对象)来创建以便测试的测试方法。Mock 最大的功能是帮你把单元测试的耦合分解开。——摘抄自 Mockito 详解
使用 Mock 测试时,我们不关注复杂的上下游、环境、网络状态,只关注当下代码。如果我们确实需要关注上游异常、查询为空、意外结果的场景,那么我们把这种情况 Mock 出来即可,上下游的正确性不在当前单元测试的关注范围内。我们保证当前代码正确,上下游也按照同样的规范保证即可。
补充:初次接触 Mock 测试概念时,会遇到一个 Stub
的概念,不是很容易理解(通常翻译为"打桩"),实际上为记录 Mock 行为的过程,比如我们假定调用对象 obj
的 method1()
方法时,永远返回 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
更多时候,我们面临的场景更加复杂,许多对象需要在特定的环境中才能使用。
比如以下的场景,StrengthenWeapon
是 JavaPlugin
的子类,我们希望在 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