1. 概述

在本教程中,我们将学习属性测试(Property-Based Testing)的概念,并通过与传统示例测试(Example-Based Testing)的对比来深入理解这一方法。

随后,我们将使用 Jqwik 这个专为属性测试设计的 Kotlin 单元测试库,探索其 API 并编写第一个属性测试。

最后,我们还会讨论一些实用功能,帮助我们通过大量输入参数验证代码逻辑,覆盖各种边界情况。


2. 示例测试 vs 属性测试

传统单元测试通常采用“示例测试”的方式,即为函数提供一组明确的输入值,并验证其输出是否符合预期。这些示例本质上就是输入参数和期望输出的配对

而属性测试则基于系统应遵循的通用规则进行验证。为了验证这些规则,我们使用大量随机生成的输入来测试系统行为,尽可能覆盖所有边界情况。

我们可以通过 JUnit5 参数化测试 来对比这两种测试方式的区别。

2.1 示例测试

示例测试的核心思想是使用预定义的输入值验证函数的输出是否正确。这些输入值(或称“示例”)可以单独定义在每个测试中,也可以通过参数化测试集中处理

以一个将整数转换为罗马数字的类为例,我们可以使用 JUnit5 的 @ParameterizedTest@MethodSource 注解来编写测试:

class JUnit5_ExampleVsPropertyBasedUnitTest {
    val converter = RomanNumeralsConverter()

    companion object {
        @JvmStatic
        fun intsToRomanNumerals() = listOf(
            Arguments.of(3, "III"),
            Arguments.of(4, "IV"),
            Arguments.of(58, "LVIII"),
            Arguments.of(1234, "MMMCMXCIX")
        );
    }
   
    @ParameterizedTest 
    @MethodSource("intsToRomanNumerals") 
    fun `should converted integer to roman numeral`(integer: Int, roman: String) { 
        assertEquals(roman, converter.intToRoman(integer))
    } 
}

同样的示例也可以用于反向函数 romanNumeralToInt(),只需调整断言:

@ParameterizedTest
@MethodSource("intsToRomanNumerals")
fun `should converted roman numeral to integer`(integer: Int, roman: String) {
    assertEquals(integer, converter.romanToInt(roman))
}

⚠️ 但问题也很明显:测试仅覆盖了四个输入输出组合,无法保证在其他输入或边界情况下仍能正确运行。例如,没有一个示例包含代表 500 的字符 “D”。


2.2 属性测试

属性测试通过大量随机生成的输入来验证组件的行为。在编写测试时,我们并不知道具体的输入值,因此不能直接断言某个特定输出,而是需要定义一些通用规则(属性)

以之前的 RomanNumeralsConverter 为例,我们可以定义一个属性:将整数转换为罗马数字后再转换回来,结果应该等于原始整数。

我们可以先生成 300 个 0 到 4000 之间的随机整数:

companion object {
    @JvmStatic
    fun randomIntsInRangeInRangeZeroTo4k() = (0..<4000).shuffled().take(300)
}

然后用这些数据编写参数化测试:

@ParameterizedTest
@MethodSource("randomIntsInRangeInRangeZeroTo4k")
fun `should converted integer to roman numeral and back`(integer: Int) {
    val roman = converter.intToRoman(integer)
    assertEquals(integer, converter.romanToInt(roman))
}

✅ 这种方式可以覆盖更多路径,可能发现一些隐藏的边界问题。

❌ 但也有明显缺点:

  • 无法重复使用相同输入验证问题是否修复
  • 无法确保覆盖特殊值(如极值)
  • 失败时无法清晰描述问题原因
  • 为多种数据类型构造参数源可能很繁琐

幸运的是,Jqwik 这类库正是为了解决这些问题而生。它不仅提供了声明式 API,还能自动处理大量输入生成和失败重试。


3. 开始使用 Jqwik

首先,我们需要在 pom.xml 中添加 Jqwik 的依赖项:

<dependency>
    <groupId>net.jqwik</groupId>
    <artifactId>jqwik</artifactId>
    <version>1.8.5</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>net.jqwik</groupId>
    <artifactId>jqwik-kotlin</artifactId>
    <version>1.8.5</version>
    <scope>test</scope>
</dependency>

此外,建议添加一些编译器选项以方便调试:

<plugin>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-maven-plugin</artifactId>
    <version>1.9.4</version>
    <configuration>
        <args>
            <arg>-java-parameters</arg> <!-- 便于 jqwik 报告中显示正确参数名 -->
            <arg>-Xjsr305=strict</arg> <!-- 对 jqwik API 中的可空性注解启用严格模式 -->
            <arg>-Xemit-jvm-type-annotations</arg> <!-- 启用类型变量上的注解 -->
        </args>
    </configuration>
</plugin>

接下来,创建一个测试类,使用 Jqwik 的 API。你会发现测试方法使用 @Property 注解,而参数使用 @ForAll 表示需要生成随机值:

@Property
fun `should generate input values`(@ForAll arg: Int) {
    println("$arg")
    assertTrue { arg is Int }
}

执行后,你会看到控制台输出了 1000 个输入值,并附带一份报告:

timestamp = 2024-06-25T11:54:56.857321, Jqwik PropertyBasedUnitTest:should generate input values = 
                              |-----------------------jqwik-----------------------
tries = 1000                  | # of calls to property
checks = 1000                 | # of not rejected calls
generation = RANDOMIZED       | parameters are randomly generated
after-failure = SAMPLE_FIRST  | try previously failed sample, then previous seed
when-fixed-seed = ALLOW       | fixing the random seed is allowed
edge-cases#mode = MIXIN       | edge cases are mixed in
edge-cases#total = 9          | # of all combined edge cases
edge-cases#tried = 9          | # of edge cases tried in current run
seed = -1329617974163688825   | random seed to reproduce generated values

你也可以自定义生成参数,例如只运行 100 次并指定种子:

@Property(tries = 100, seed = "-1329617974163688825")
fun `should generate input values for a given seed`(@ForAll arg: Int): Boolean {
    println("$arg")
    return arg is Int
}

4. 使用注解生成参数

现在我们已经掌握了基本用法,可以开始用 Jqwik 测试 RomanNumeralsConverter

@Property
fun `should convert integer to roman and back`(@ForAll originalInt: Int): Boolean {
    val roman = converter.intToRoman(originalInt)
    val finalInt = converter.romanToInt(roman)
    return originalInt == finalInt
}

⚠️ 当前测试会失败,因为我们的实现只支持 0 到 3999 之间的正整数。我们可以通过注解限制输入范围:

@Property
fun `should convert integer to roman and back`(
  @ForAll @Positive @IntRange(max = 3999) originalInt: Int
): Boolean { /* ... */ }

也可以用 @IntRange(min = 0, max = 3999) 替代 @Positive,效果相同。

✅ 当输入范围小于配置的尝试次数时,Jqwik 会进行穷举测试(exhaustive testing),验证所有可能值。

例如,将尝试次数设为 4000:

@Property(tries = 4000)
fun `should convert integer to roman and back for all valid inputs`(
  @ForAll @IntRange(min = 0, max = 3999) originalInt: Int,
): Boolean { /* ... */ }

此时报告中的 generation 字段会从 RANDOMIZED 变为 EXHAUSTIVE,表示所有可能值都被验证过。


4.1 生成字符串

只需用 @ForAll 标记字符串参数,即可生成随机字符串。为了调试,可以使用 @Report(Reporting.GENERATED) 注解来记录每次生成的参数

@Report(Reporting.GENERATED)
@Property(tries = 10)
fun `should generate Strings`(
  @ForAll firstName: String,
  @ForAll lastName: String
): Boolean { /* ... */ }

输出示例:

timestamp = 2024-06-25T15:11:08.981301700, generated = 
  arg0: "捧뺢跿澌㨴ް뽳"
  arg1: "ë果"

默认情况下,Jqwik 会尝试生成各种边界情况,例如空字符串或不可打印字符。我们也可以使用以下注解来约束字符串生成:

  • @AlphaChars:仅使用字母数字
  • @UniqueChars:避免重复字符
  • @NotEmpty:避免空字符串
  • @NotBlank:避免纯空格字符串
  • @Whitespace:允许空格
  • @CharRange:指定字符范围
  • @StringLength:指定字符串长度

例如:

@Property
fun `should generate Strings`(
  @ForAll @AlphaChars @UniqueChars @NotBlank foo: String,
  @ForAll @Whitespace @CharRange(from = '!', to = '&') @StringLength(min = 3, max = 5) bar: String,
): Boolean { /* ... */}

4.2 生成其他类型

Jqwik 提供了丰富的注解支持,可以生成各种集合、枚举、布尔值等。

例如,生成一个非空且元素唯一、最大长度为 10 的 List<Double>

@Property(tries = 10)
fun `should generate Lists of Doubles`(
    @ForAll @NotEmpty @Size(max = 10) numbers: @UniqueElements List<Double>
): Boolean { /* ... */ }

对于有限集合类型(如枚举、布尔值),Jqwik 也会进行穷举测试:

@Property
fun `should generate Enums and Booleans`(
    @ForAll month: Month,
    @ForAll isLeapYear: Boolean?,
    @ForAll @IntRange(min = 2000, max = 2024) year: Int,
): Boolean { /* ... */ }

因为组合数为 12 * 3 * 25 = 900,小于默认尝试次数 1000,所以 Jqwik 会进行穷举测试。


5. 程序化生成参数

除了使用注解,我们还可以通过编程方式创建自定义参数生成器,避免重复注解。

例如,以下两个测试都使用了相同的用户名生成逻辑:

@Property
fun `should be able to sign up`(
  @ForAll @AlphaChars @NumericChars @UniqueChars @StringLength(min = 3, max = 8) username: String
): Boolean { /* ... */ }

@Property
fun `should be able to login`(
  @ForAll @AlphaChars @NumericChars @UniqueChars @StringLength(min = 3, max = 8) username: String
): Boolean { /* ... */ }

5.1 自定义参数提供器

Jqwik 提供了 @Provide 注解和 Arbitraries API 来创建可复用的参数生成器:

@Provide
fun usernames(): Arbitrary<String> = String.any()
  .alpha()
  .numeric()
  .uniqueChars()
  .ofLength(3..8)

之后可以在多个测试中通过 @ForAll("usernames") 引用它:

@Property
fun `should be able to sign up`(@ForAll("usernames") username: String): Boolean { /* ... */ }

@Property
fun `should be able to login`(@ForAll("usernames") username: String): Boolean { /* ... */ }

5.2 生成自定义对象

我们可以组合多个 Arbitrary 实例来生成自定义对象。例如,为 Account 类生成随机数据:

data class Account(val id: Long, val username: String, val dateOfBirth: LocalDate)

定义生成器:

@Provide
fun accounts(): Arbitrary<Account> {
    val ids: Arbitrary<Long> = Long.any().greaterOrEqual(100)
    val usernames: Arbitrary<String> = String.any().alpha().numeric().repeatChars(15.0).ofLength(3..8)
    val datesOfBirth: Arbitrary<LocalDate> = Dates.dates().yearBetween(1900, 2000)

    return combine(ids, usernames, datesOfBirth) { id, name, dob -> Account(id, name, dob) }
}

也可以进一步简化,复用之前的 usernames()

@Provide
fun accounts(): Arbitrary<Account> {
    val ids = Long.any().greaterOrEqual(100)
    val datesOfBirth = Dates.dates().yearBetween(1900, 2000)
    return combine(ids, usernames(), datesOfBirth, combinator = ::Account)
}

然后在测试中使用:

@Property
fun `should validate account`(@ForAll("accounts") account: Account): Boolean { /* ... */ }

6. 总结

我们了解了示例测试与属性测试的本质区别,属性测试强调从通用规则出发,验证系统在大量随机输入下的行为

我们还深入学习了 Jqwik 这一 Kotlin 测试库,它通过自动化输入生成和失败重试机制,帮助我们发现隐藏的边界问题。

最后,我们学会了如何使用 Arbitraries API 创建自定义参数生成器,提升测试代码的复用性和可维护性。

完整代码可在 GitHub 上查看。


原始标题:A Guide to Property-Based Testing in Kotlin