写一个JAVA快照测试工具

发布时间:

最后更新:

快照测试,指的是在测试中首次调用断言方法时,将对象以可读的序列化形式保存起来,然后人工查看一下结果是否正确,如果不正确则重新生成。再次运行测试断言时将对象与保存的快照文件做对比,以确保结果一致,符合预期。

快照测试最大的优点是能轻松地断言复杂的内容,它经常被用于前端项目,因为前端的渲染出来的 HTML、CSS 都挺复杂,用正则或是其它的工具去断言很费劲,不如直接检查整个结果。

快照测试的缺点是只能比较相等,无法执行大于小于之类的逻辑判断;还有要求对象必须能序列化。

最近撸了一个快照测试工具,感觉挺有用的,便写此文记录和分享一下编写思路。

需求场景 #

在重构本博客的后端服务时,发现单测里一些结果比较复杂,一条条字段挨个 assert 很是烦人,于是便想到能不能把前端的快照测试搬过来。一番查找只找到两个库 java-snapshot-matcherjava-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,它的测试结构有三层:

  1. 项目里有多个测试类。
  2. 每个测试类里有多个测试方法。
  3. 如果是参数化测试,则对于每个参数测试方法都会运行一次。

再加上每个测试方法里可能多次调用快照断言,所以一共有四个层次:class / method / index / calls,这四项即可定位到断言方法的调用。类和方法直接用名字即可,一个项目里不太可能出现两个同名的测试类;参数的类型各种各样没法统一所以只能使用索引index;然后是测试里调用断言的次序calls

最终的路径模板是src/test/resources/snapshots/<class>/<method>-<index>-<calls>.json,类名被作为一个目录是因为从意义上讲,类是测试的集合,而测试方法和断言属于用例,用例是原子的不应再拆。有个例外是参数化测试,这种情况下方法则是集合而参数是用例,不过由于 JUnit5 没有提供很方便的方法去判断,所以就忽略,认为非参数测试的参数索引为0。

当然像 Jest 那样一个文件保存多个测试也行。

模板想好了接下来就是实现:

代码实现 #

实现可以见本博客的源码 SnapshotAssertion.java,这里也贴上一个精简版,与博客里的相比删除了对各种测试工具(Mockito、MockMvc)的适配代码和一些注释。

java
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);
		}
	}
}

用它写一个快照测试:

java
// 需要添加一个扩展
@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保存被序列化的对象:

json
{
	"field1" : 0,
	"field2" : "text"
}

之后再次运行测试则会将result与保存的快照对比,如果不同则断言失败:

测试结果测试结果

借助 IDE 的对比功能,很容易找到不同的属性。如果项目使用 Spring,则可以把SnapshotAssertion做成自动注入的 bean,这样就不用每次都去new它了。唯一的不爽就是要加个@ExtendWith(...)多写一行,没有 Jest 那样原生支持的顺滑。

评论加载中