写一个JAVA快照测试工具
发布时间:
最后更新:
快照测试,指的是在测试中首次调用断言方法时,将对象以可读的序列化形式保存起来,然后人工查看一下结果是否正确,如果不正确则重新生成。再次运行测试断言时将对象与保存的快照文件做对比,以确保结果一致,符合预期。
快照测试最大的优点是能轻松地断言复杂的内容,它经常被用于前端项目,因为前端的渲染出来的 HTML、CSS 都挺复杂,用正则或是其它的工具去断言很费劲,不如直接检查整个结果。
快照测试的缺点是只能比较相等,无法执行大于小于之类的逻辑判断;还有要求对象必须能序列化。
最近撸了一个快照测试工具,感觉挺有用的,便写此文记录和分享一下编写思路。
需求场景 #
在重构本博客的后端服务时,发现单测里一些结果比较复杂,一条条字段挨个 assert 很是烦人,于是便想到能不能把前端的快照测试搬过来。一番查找只找到两个库 java-snapshot-matcher 和 java-snapshot-testing,考察后发现它们不太符合我的需求:
-
java-snapshot-matcher 的快照文件名有问题,仅使用类和方法名,不支持一个方法内多次断言;另外它从调用栈查找测试方法,这在某些情况下是不准确的。
-
java-snapshot-testing 我不知道他是怎么写出这么多代码的,太复杂了;而且它似乎使用了内置的序列化器,我的项目里自定义了一些类型的序列化方式,必须要求序列化器能够扩展。
嘛总之第三方库都有些问题,还得自己造轮子。我简单地找了一下相关的文章,发现 JAVA 方面快照测试的文章寥寥无几,中文的更是一个没有,我感觉挺好用的一个东西似乎在 JAVA 的世界里不太流行啊。
不过没有教程也无所谓,快照测试的逻辑也不复杂,事实上实现起来相当简单,依靠与现有的系统(AssertJ、IDE)集成,核心功能只要 100 来行代码就搞定了(不算注释)。
基本思路 #
基本的流程就是这么简单,首先看看第一步序列化对象,因为要把快照保存为文件,序列化是必不可少的,而且序列化后的格式必须是人类可读的。这里还是选择最常用的 JSON 格式,不得不说 JSON 很是牛B,覆盖了大部分数据类型的同时还有着不错的可读性。博客项目后端使用 Spring 全家桶,自带 JSON 序列化工具 Jackson,用它即可。
第二步更新快照的判断,如果快照文件不存在肯定是要新建,另外像 Jest 一样也支持设置参数updateSnaoshot
来强制更新,就这两种情况。
最后一个操作是比较,这个直接判断序列化后的字符串是否相等即可。现代的 IDE 比如 Intellj IDEA 支持在失败的测试信息里显示字符串的差异,这功能也不需要自己再做了。
快照的路径 #
还有一个关键的问题就是快照文件放哪,快照文件是断言逻辑的一部分,属于源码,如果有 VCS 是要提交上去的,所以要跟源码放在一起。因为我的项目使用 maven,所以放在src/test/resources/snapshots
下是最好的。
另外快照的文件名也是有要求的,因为它要对应到具体的断言调用,本博客使用了 JUnit5,它的测试结构有三层:
- 项目里有多个测试类。
- 每个测试类里有多个测试方法。
- 如果是参数化测试,则对于每个参数测试方法都会运行一次。
再加上每个测试方法里可能多次调用快照断言,所以一共有四个层次:class / method / index / calls
,这四项即可定位到断言方法的调用。类和方法直接用名字即可,一个项目里不太可能出现两个同名的测试类;参数的类型各种各样没法统一所以只能使用索引index
;然后是测试里调用断言的次序calls
。
最终的路径模板是src/test/resources/snapshots/<class>/<method>-<index>-<calls>.json
,类名被作为一个目录是因为从意义上讲,类是测试的集合,而测试方法和断言属于用例,用例是原子的不应再拆。有个例外是参数化测试,这种情况下方法则是集合而参数是用例,不过由于 JUnit5 没有提供很方便的方法去判断,所以就忽略,认为非参数测试的参数索引为0。
当然像 Jest 那样一个文件保存多个测试也行。
模板想好了接下来就是实现:
-
测试方法和类的获取,可以通过 JUnit5 的扩展来做,实现了
BeforeEachCallback
接口的扩展将在每次测试前调用,从参数ExtensionContext
中可以得到当前测试的方法,从方法能得到类。 -
因为 JUnit5 的参数化测试是连续运行的,所以如果测试方法未改变,则表明运行到下一个参数,此时递增参数索引
index
,否则重置。 -
最后的调用次序
calls
在回调中重置,调用断言时递增即可。
代码实现 #
实现可以见本博客的源码 SnapshotAssertion.java,这里也贴上一个精简版,与博客里的相比删除了对各种测试工具(Mockito、MockMvc)的适配代码和一些注释。
import com.fasterxml.jackson.core.util.DefaultIndenter;
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.assertj.core.api.Assertions.assertThat;
public final class SnapshotAssertion {
private static Method method;
private static int index;
private static int calls;
private final ObjectMapper objectMapper;
private final boolean forceUpdate;
public SnapshotAssertion(ObjectMapper objectMapper) {
// 美化输出的 JSON
var indenter = new DefaultIndenter().withIndent("\t");
var printer = new DefaultPrettyPrinter()
.withArrayIndenter(indenter)
.withObjectIndenter(indenter);
this.objectMapper = objectMapper.copy()
.enable(SerializationFeature.INDENT_OUTPUT)
.setDefaultPrettyPrinter(printer)
// 对属性排序,确保结果稳定
.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
.enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY);
this.forceUpdate = System.getProperty("updateSnapshot") != null;
}
// 断言对象与快照里保存的相等
public void assertMatch(Object object) {
createOrLoad(object).assertEquals();
}
// 加载快照,如果快照不存在则创建
@SneakyThrows
private Matching createOrLoad(Object object) {
var actual = objectMapper.writeValueAsString(object);
var expect = actual;
var path = getSnapshotPath();
if (!Files.exists(path) || forceUpdate) {
Files.createDirectories(path.getParent());
Files.writeString(path, actual);
} else {
// 重新格式化,避免手动美化快照文件造成的影响。
var tree = objectMapper.readTree(Files.newInputStream(path));
expect = objectMapper.writeValueAsString(tree);
}
return new Matching(expect, actual);
}
// 获取当前断言对应的快照文件路径
private Path getSnapshotPath() {
if (method == null) {
throw new Error("必须把 ContextHolder 注册到 JUnit 扩展");
}
var template = "src/test/resources/snapshots/%s/%s-%d-%d.json";
var c = method.getDeclaringClass().getSimpleName();
var m = method.getName();
return Paths.get(String.format(template, c, m, index, calls++));
}
// 用于获取当前测试方法的 JUnit5 扩展
public static final class ContextHolder implements BeforeEachCallback {
@Override
public void beforeEach(ExtensionContext context) {
var latestMethod = method;
method = context.getRequiredTestMethod();
calls = 0;
index = method == latestMethod ? index + 1 : 0;
}
}
@RequiredArgsConstructor
private static final class Matching {
private final String expect;
private final String actual;
public void assertEquals() {
assertThat(actual).isEqualTo(expect);
}
}
}
用它写一个快照测试:
// 需要添加一个扩展
@ExtendWith(SnapshotAssertion.ContextHolder.class)
final class SnapshotTestExample {
private final ObjectMapper objectMapper = new ObjectMapper()
.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL); // 可以自定义序列化器
private final SnapshotAssertion snapshot = new SnapshotAssertion(objectMapper);
@Test
void test() {
// 测试运行的结果,实际中可以是复杂的对象
var result = Map.of("field1", 0, "field2", "text");
snapshot.assertMatch(result);
}
}
首次运行会创建快照文件src/test/resources/snapshots/SnapshotTestExample/test-0-0.json
保存被序列化的对象:
{
"field1" : 0,
"field2" : "text"
}
之后再次运行测试则会将result
与保存的快照对比,如果不同则断言失败:
借助 IDE 的对比功能,很容易找到不同的属性。如果项目使用 Spring,则可以把SnapshotAssertion
做成自动注入的 bean,这样就不用每次都去new
它了。唯一的不爽就是要加个@ExtendWith(...)
多写一行,没有 Jest 那样原生支持的顺滑。