2. 参数化测试
在深入属性测试之前,我们先快速回顾下参数化测试。参数化测试允许我们编写单个测试函数,然后用不同参数多次调用它。例如:
@ParameterizedTest
@CsvSource({"4,2,2", "6,2,3", "6,3,2"})
void testIntegerDivision(int x, int y, int expected) {
int answer = calculator.divide(x, y);
assertEquals(expected, answer);
}
这让我们能相对轻松地测试多组输入。但挑战在于如何确定这些测试用例。显然,我们不可能测试所有可能的值——这不现实。我们需要挑选出"有趣"的用例进行测试。
对于除法测试,我们可能会考虑:
- 几个常规用例
- 数字除以自身——结果总是"1"
- 数字除以1——结果总是原数字
- 正数除以负数——结果总是负数
但前提是我们能想到所有情况。如果漏掉了某个场景呢?比如除以0的情况?我们没测试,就不知道会发生什么。
3. 属性测试
如果我们能通过程序自动生成测试输入呢?
最直接的想法是用循环生成参数的参数化测试:
@ParameterizedTest
@MethodSource("provideIntegerInputs")
void testDivisionBySelf(int input) {
int answer = calculator.divide(input, input);
assertEquals(answer, 1);
}
private static Stream<Arguments> provideIntegerInputs() {
return IntStream.rangeClosed(Integer.MIN_VALUE, Integer.MAX_VALUE)
.mapToObj(i -> Arguments.of(i));
}
这确实能找到所有边界情况,但代价巨大——它要测试 4,294,967,296 个用例!即使每个测试只需1毫秒,也要跑近50天。
属性测试是这个思想的优化版:不生成所有测试用例,而是根据我们定义的属性生成"有趣"的测试用例。
比如除法测试,我们可以只测试 -20 到 +20 之间的数字。假设异常情况都在这个范围内,这样既能保证代表性又易于管理:
private static Stream<Arguments> provideIntegerInputs() {
return IntStream.rangeClosed(-20, +20).mapToObj(i -> Arguments.of(i));
}
4. jqwik 快速入门
jqwik 是一个实现属性测试的 Java 测试库。它让这种测试变得极其简单高效,包括数据集生成能力,还集成了 JUnit 5。
4.1 添加依赖
使用 jqwik 前需要先添加依赖:
<dependency>
<groupId>net.jqwik</groupId>
<artifactId>jqwik</artifactId>
<version>1.7.4</version>
<scope>test</scope>
</dependency>
最新版本可在 Maven 中央仓库 查找。
⚠️ 确保项目已正确配置 JUnit 5,否则无法运行 jqwik 测试。
4.2 第一个测试
配置完成后,我们来写个测试。还是测试数字除以自身结果为1:
@Property
public void divideBySelf(@ForAll int value) {
int result = divide(value, value);
assertEquals(result, 1);
}
就这么简单!关键点:
- 用
@Property
替代@Test
,告诉 JUnit 用 jqwik 运行器 - 参数用
@ForAll
注解,指示 jqwik 生成"有趣"的值
运行结果:
jqwik 在第13次尝试时发现了失败用例:输入"0"导致 ArithmeticException
。
极少量测试代码就立即暴露了代码问题,这就是属性测试的威力。
5. 定义属性
我们已了解如何用 jqwik 生成测试值。上例中我们生成了无约束的整数。
jqwik 支持多种标准类型生成:字符串、数字、布尔值、枚举、集合等。只需在参数上添加 @ForAll
注解:
@Property
public void additionIsCommutative(@ForAll int a, @ForAll int b) {
assertEquals(a + b, b + a);
}
甚至可以混合不同类型。
5.1 约束属性
但通常我们需要约束参数范围。比如除法测试中,分母不能为零。
虽然可以在测试中检测并跳过,但这样仍会计入测试次数。jqwik 默认只运行有限次数的测试,跳过太多会降低测试有效性。
更好的方式是直接约束属性范围。例如只测试正数:
@Property
public void dividePositiveBySelf(@ForAll @Positive int value) {
int result = divide(value, value);
assertEquals(result, 1);
}
@Positive
注解会约束生成值 >0。jqwik 提供多种内置约束:
- 是否包含
null
- 数值的最小/最大值
- 字符串/集合的最小/最大长度
- 字符串允许的字符集
- 等等
5.2 自定义约束
当内置约束不够时,我们可以自定义生成函数。比如除法测试中,我们想生成除零外的所有整数:
@Property
public void divideNonZeroBySelf(@ForAll("nonZeroNumbers") int value) {
int result = divide(value, value);
assertEquals(result, 1);
}
@Provide
Arbitrary<Integer> nonZeroNumbers() {
return Arbitraries.integers().filter(v -> v != 0);
}
关键点:
- 用
@Provide
注解生成方法,返回Arbitrary<Integer>
- 在
@ForAll
中指定方法名"nonZeroNumbers"
运行时会生成各种正负整数,但绝不会出现0——因为我们显式过滤了它。
5.3 假设(Assumptions)
有时需要多个属性之间存在约束关系。例如测试"大数除以小数结果≥1"时,我们无法用注解表达"a > b"的关系。
这时可以用假设(Assumptions):
@Property
public void divideLargeBySmall(@ForAll @Positive int a, @ForAll @Positive int b) {
Assume.that(a > b);
int result = divide(a, b);
assertTrue(result >= 1);
}
运行输出:
执行了1000次尝试,但只验证了498次——因为502次不满足 a > b
被跳过。
⚠️ jqwik 有可配置的"最大丢弃率"(默认5)。如果假设条件丢弃的用例超过这个比例,测试会失败——因为有效数据不足。后续会介绍如何调整这个设置。
6. 结果收缩(Shrinking)
jqwik 能高效找到失败用例,但有时生成的失败用例很复杂。
jqwik 会尝试将失败用例"收缩"到最小可复现案例,帮我们精确定位边界问题。
看个"简单"例子:数字平方应该≥原数。
@Property
public void square(@ForAll @Positive int a) {
int result = a * a;
assertTrue(result >= a);
}
但测试失败了:
关键信息:
- 原始样本:首次失败时生成的值
- 收缩样本:jqwik 简化后仍失败的用例
为什么失败?计算下:46,341² = 2,147,488,281,超过了 Integer.MAX_VALUE
(2,147,483,647)。而46,340²=2,147,395,600 在范围内。
我们意外发现了一个边界情况:46,341的平方无法用int存储!
7. 重跑测试
测试失败后修复代码重跑时,随机生成可能导致无法复现问题。
jqwik 提供了两种解决方案:
- 默认模式:从上次失败的用例开始,然后生成新用例
- 固定种子模式:使用相同随机种子完全复现之前的测试序列
这些模式依赖本地存储的 .jqwik-database
文件。但CI环境中这个文件会丢失,导致无法复现。
替代方案是显式指定固定种子:
@Property(seed = "12345678")
测试输出会显示使用的种子,复制它就能精确复现测试。
8. 配置测试
jqwik 有合理的默认配置(如默认尝试1000次),但我们可以调整。
直接在 @Property
注解中配置:
@Property(tries = 5000, shrinking = ShrinkingMode.OFF, generation = GenerationMode.RANDOMIZED)
这会:
- 运行5000次尝试(默认1000)
- 关闭结果收缩
- 使用随机生成模式(而非穷举)
其他可配置项:
- 假设的最大丢弃率
- 失败测试的重跑策略
- 随机生成细节
还可以在测试类上用 @PropertyDefaults
注解配置整个类,或在 src/test/resources/junit-platform.properties
中配置整个项目。
9. 总结
本文介绍了 jqwik 库的基础用法。这只是 jqwik 功能的冰山一角,但足以展示属性测试为项目带来的强大能力。
本文所有代码可在 GitHub 获取。