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

就这么简单!关键点:

  1. @Property 替代 @Test,告诉 JUnit 用 jqwik 运行器
  2. 参数用 @ForAll 注解,指示 jqwik 生成"有趣"的值

运行结果:

jqwik run

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

关键点:

  1. @Provide 注解生成方法,返回 Arbitrary<Integer>
  2. @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);
}

运行输出:

test output

执行了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);
}

但测试失败了:

test fail

关键信息:

  • 原始样本:首次失败时生成的值
  • 收缩样本: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 提供了两种解决方案:

  1. 默认模式:从上次失败的用例开始,然后生成新用例
  2. 固定种子模式:使用相同随机种子完全复现之前的测试序列

这些模式依赖本地存储的 .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 获取。


原始标题:Property-Based Testing with jqwik