参数化测试

参数化测试入门

仓颉 unittest 框架支持参数化测试,格式为数据 DSL ,测试中框架自动插入输入参数。

如下为复杂代码示例,用函数对数组排序:

package example

func sort(array: Array<Int64>): Unit {
    for (i in 0..array.size) {
        var minIndex = i
        for (j in i..array.size) {
            if (array[j] < array[minIndex]) {
                minIndex = j
            }
        }
        (array[i], array[minIndex]) = (array[minIndex], array[i])
    }
}

这个函数不是最优最高效的排序实现,但仍可以达成目的。 来测试一下。

package example

@Test
func testSort() {
    let arr = [45, 5, -1, 0, 1, 2]
    sort(arr)
    @Expect(arr, [-1, 0, 1, 2, 5, 45])
}

测试结果显示,函数在单个输入的情况下可用。 接下来测试,如果数组只包含等值元素,排序函数还是否可用。

@Test
func testAllEqual() {
    let arr = [0, 0, 0, 0]
    let expected = [0, 0, 0, 0]
    sort(arr)
    @Expect(expected, arr)
}

测试结果显示函数可用,但还是不能确认是否适用于所有大小的数组。 接下来测试参数化数组的大小。

@Test[size in [0, 1, 50, 100, 1000]]
func testAllEqual(size: Int64) {
    let arr = Array(size, repeat: 0)
    let expected = Array(size, repeat: 0)
    sort(arr)
    @Expect(expected, arr)
}

至此,参数化测试已完成。 这种是最简单的参数化测试,即值驱动测试,直接在代码中列出测试运行的值。 参数化测试的参数可以不止一个。 不仅可以指定排序函数的大小,还可以指定待测试的元素。

@Test[
    size in [0, 1, 50, 100, 1000],
    item in (-1..20)
]
func testAllEqual(size: Int64, item: Int64) {
    let arr = Array(size, repeat: item)
    let expected = Array(size, repeat: item)
    sort(arr)
    @Expect(expected, arr)
}

注意,item 的取值范围为 -1..20 ,不是一个数组。 那么,运行这个测试,结果会如何? 参数 sizeitem 的取值会进行组合再测试和返回结果。 因此,测试函数时,不要配置过多参数;否则组合数量过大,导致测试变慢。 上述例子中,size 参数值有5个,item 参数值有21个,共有21×5=105种组合。

注意,值驱动测试不限于整型或内置类型。 也可以和任何仓颉类型一起使用。 参考如下测试:

@Test[
    array in [
        [],
        [1, 2, 3],
        [1, 2, 3, 4, 5],
        [5, 4, 3, 2, 1],
        [-1, 0],
        [-20]
    ]
]
func testDifferentArrays(array: Array<Int64>) {
    //测试数组是否已排序
    for (i in 0..(array.size - 1)) {
        @Expect(array[i] <= array[i + 1])
    }
}

这里,直接以参数的形式提供测试的数据。

当然,用数组来测试代码可能过于笨重。 有时,对于这样的泛型函数,随机生成数据可能会更容易些。 接下来看一种更高级的参数化测试:随机测试。 通过调用函数 unittest.random<T>() 替换值驱动测试的数组或范围来创建随机测试:

@Test[
    array in random()
]
func testRandomArrays(array: Array<Int64>) {
    //测试数组是否已排序
    for (i in 0..(array.size - 1)) {
        @Expect(array[i] <= array[i + 1])
    }
}

这个测试本质上是生成大量完全随机的数值(默认为200个),用这些值来测试代码。 数值并不完全随机,偏向于边界值,如特定类型的零、最大值和最小值、空集合等。

特别注明:通常建议随机化测试与手工编写的测试混用,实践表明可以互为补充。

为了更好地描述随机测试,先把排序函数放一边,编写下面这样一个测试:

@Test[
    array in random()
]
func testNonsense(array: Array<Int64>) {
    if (array.size < 2) { return }
    @Expect(array[0] <= array[1] + 500)
}

运行后,会生成如下类似输出:

[ FAILED ] CASE: testNonsense (1159229 ns)
    REASON: After 4 generation steps and 200 reduction steps:
        array = [0, -453923686263, 0]
    with randomSeed = 1702373121372171563

    Expect Failed: `(array [ 0 ] <= array [ 1 ] + 500 == true)`
       left: false
      right: true

结果可见,数组[0, -453923686263, 0]测试失败。 再运行一次:

[ FAILED ] CASE: testNonsense (1904718 ns)
    REASON: After 5 generation steps and 200 reduction steps:
        array = [0, -1196768422]
    with randomSeed = 1702373320782980551

    Expect Failed: `(array [ 0 ] <= array [ 1 ] + 500 == true)`
       left: false
      right: true

同样,测试失败,但值都不同。 为什么会这样呢? 因为随机测试本质上是随机的,所以每次都会生成新的随机值。 在各种不同的数据上测试函数,可能很好用;但是对于某些测试,这意味着多次运行中,有时成功,有时失败,不利于共享测试结果。 随机测试是一个强大的工具,但在使用时也需要了解这些弊端。

测试结果每次都不一样,如何向其他开发者展示结果呢? 答案很简单,就在运行测试时得到的输出中:

with randomSeed = 1702373320782980551

以上提供了一个随机种子,可以在测试中用作配置项。 改写测试如下:

@Test[
    array in random()
]
@Configure[randomSeed: 1702373320782980551]
func testNonsense(array: Array<Int64>) {
    if (array.size < 2) { return }
    @Expect(array[0] <= array[1] + 500)
}

运行结果如下:

[ FAILED ] CASE: testNonsense (1910109 ns)
    REASON: After 5 generation steps and 200 reduction steps:
        array = [0, -1196768422]
    with randomSeed = 1702373320782980551

    Expect Failed: `(array [ 0 ] <= array [ 1 ] + 500 == true)`
       left: false
      right: true

注意,此次运行生成的值、生成值所用的步骤和随机种子与上次运行完全相同。 这种机制让随机测试可重复,可以保存在测试套件中与其他开发人员共享。 您也可以只从随机测试中获取数据(如本例中的数组值[0, -1196768422]),用这些值编写新测试。

快速看看失败的测试生成的输出。

    REASON: After 5 generation steps and 200 reduction steps:
        array = [0, -1196768422]
    with randomSeed = 1702373320782980551

输出中包含几个重要信息:

  • 测试失败前数据生成的步骤数:即测试失败前运行的迭代次数。
  • 数据减量的步骤数:随机测试有缩减测试数据的机制,数据量减小后更方便操作,且提升了可读性。
  • 导致测试失败的实际数据:按顺序列出所有参数(在本例中,只有一个参数 array )以及它们实际导致测试失败的值。其中参数应实现 ToString 接口,否则将只打印占位符。
  • 以及前面提到的随机种子,用于重现随机测试。

有些测试比较棘手,需要调整随机生成的步骤数。 可以通过如下两个配置项控制:generationStepsreductionSteps 。 随机测试一个最大的优点是,只要设置了足够多的步骤,它可以运行很长时间,这样就可以检查数百万个值。

为了不使测试太耗时,默认 generationStepsreductionSteps 的最大值都是200。 通过这两个配置参数,就可以设置框架的最大步骤数。 例如,对于上面的这个小测试,参数设置为一个大的数值可能没有多大意义。之前的运行已经表明,通常不到10步,测试就会失败。

类型参数化测试

虽然当前排序实现仅针对整型数组排序,但本质上也可以对其他任何类型排序。 可以改写 sort 函数变成泛型函数,允许它对任意类型进行排序。 注意,元素必须是可比较的,这样才能排序。

package example

func sort<T>(array: Array<T>): Unit where T <: Comparable<T> {
    for (i in 0..array.size) {
        var minIndex = i
        for (j in i..array.size) {
            if (array[j] < array[minIndex]) {
                minIndex = j
            }
        }
        (array[i], array[minIndex]) = (array[minIndex], array[i])
    }
}

所有测试均继续正常运行,可知排序函数已广泛适用。 但是,是否真的适用于整型以外的类型呢?

用示例中的 testDifferentArrays 编写一个新的测试,对其他类型(如字符串)进行测试:

@Test[
    array in [
        [],
        ["1","2","3"],
        ["1","2","3","4","5"],
        ["5","4","3","2","1"]
    ]
]
func testDifferentArraysString(array: Array<String>) {
    //测试数组是否已排序
    let sorted = array.clone()
    sort(sorted)
    for (i in 0..(sorted.size - 1)) {
        @Expect(sorted[i] <= sorted[i + 1])
    }
}

运行测试可知,测试正常。 注意,两个测试的主体和断言完全相同。 试想,如果泛型函数的测试也可以泛型,不是更方便吗?

回到之前的随机测试示例:

@Test[array in random()]
func testRandomArrays(array: Array<Int64>) {
    let sorted = array.clone()
    sort(sorted)
    for (i in 0..(sorted.size - 1)) {
        @Expect(sorted[i] <= sorted[i + 1])
    }
}

测试是广泛适用的,可以看到元素类型不限于 Int64 ,也可以是任何类型 T ,例如:

  • 可以比较:需要实现 Comparable<T> ;
  • 可以随机生成实例:需要实现 Arbitrary<T> ;

将这个测试改写为一个泛型函数:

@Test[array in random()]
func testRandomArrays<T>(array: Array<T>) where T <: Comparable<T> & Arbitrary<T> {
    let sorted = array.clone()
    sort(sorted)
    for (i in 0..(sorted.size - 1)) {
        @Expect(sorted[i] <= sorted[i + 1])
    }
}

编译运行后,遇到了一个问题:

An exception has occurred:
MacroException: Generic function testRandomArrays requires a @Types macro to set up generic arguments

当然,要运行测试某些类型,需要先有这些类型。 用 @Types 宏设置测试,这样就可以运行测试 Int64Float64String 这些类型了。

@Test[array in random()]
@Types[T in <Int64, Float64, String>]
func testRandomArrays<T>(array: Array<T>) where T <: Comparable<T> & Arbitrary<T> {
    let sorted = array.clone()
    sort(sorted)
    for (i in 0..(sorted.size - 1)) {
        @Expect(sorted[i] <= sorted[i + 1])
    }
}

现在,运行测试,将编译并生成以下输出:

    TCS: TestCase_testRandomArrays, time elapsed: 2491737752 ns, RESULT:
    [ PASSED ] CASE: testRandomArrays<Int64> (208846446 ns)
    [ PASSED ] CASE: testRandomArrays<Float64> (172845910 ns)
    [ PASSED ] CASE: testRandomArrays<String> (2110037787 ns)

注意,每个类型,测试都是单独运行的,因为行为可能大不相同。 @Types 宏可以用于任何参数化的测试用例,包括测试函数和测试类。

重用、组合和映射参数策略

现在以 HashSet 为例。从测试 contains 开始。

@Test[data in random()]
func testHashSetContains(data: Array<Int64>) {
    let hashSet = HashSet(len)
    hashSet.putAll(data)
 
    for (element in data){
        @Assert(hashSet.contains(element))
    }
}

效果很好。现在尝试测试 remove

@Test[data in random()]
func testHashSetRemove(data: Array<Int64>) {
    let hashSet = HashSet(len)
    hashSet.putAll(data)
 
    for (element in data){
        @Assert(hashSet.remove(element))
    }
}

表面上看应该可以正常工作。然而很快就可以注意到它无法正常工作,因为随机生成的数组可能包含重复项,这会导致第二次删除调用失败。所以这里想要的是生成一些没有重复的数组。

var counter = 0
@OverflowWrapping
func generateUniqArray(len: Int64, start: Int64){
    let rng = Random(start)
    let step = Int64.Max / len
    counter = 0
    Array(len, { _ => 
        counter += rng.nextInt64()%step 
        counter
    } )
}

@Test[len in random(), start in random()]
func testHashSetRemove(len: Int64, start: Int64) {
    let data = generateUniqArray(len,start)
    let hashSet = HashSet(len)
    hashSet.putAll(data)
 
    for (element in data){
        @Assert(hashSet.remove(element))
    }
}

然而,即使将数据生成转移到一个单独的函数中,如果想在多个不同的测试之间重用它,它仍然会有相当多的重复。此外,在准备代码和测试代码之间的界限非常不明确。因此,为了解决上述问题,测试框架以 @Strategy 宏的形式提供了方便的 API,允许组合和映射现有策略。

var counter = 0
@Strategy[len in random(), start in random()]
@OverflowWrapping
func generateUniqArray(len: Int64, start: Int64): Array<Int64>{
    let rng = Random(start)
    let step = Int64.Max / len
    counter = 0
    Array(len, { _ => 
        counter += rng.nextInt64()%step 
        counter
    } )
}

现在,只需要这样的输入,就可以使用该策略:

@Test[data in generateUniqArray]
func testHashSetRemove(data: Array<Int64>) {
    let hashSet = HashSet()
    hashSet.putAll(data)
 
    for (element in data){
        @Assert(hashSet.remove(element))
    }
}
 
@Test[data in generateUniqArray]
func testHashSetToArray(data: Array<Int64>) {
    let hashSet = HashSet()
    hashSet.putAll(data)
 
    let result = hashSet.toArray()
    result.sort()
    data.sort()
    @Assert(result == data)
}

覆盖率引导的随机参数化测试

如上所述,随机测试对于有能力的人来说是一个强大的工具,但它们也有一些局限性。 例如:

@Test[x in random()]
func testX(x: Int64) {
    @Expect(x != 1234567)
}

虽然这样的测试看起来很简单,但如果运行它,可能不会得到预期的结果:

--------------------------------------------------------------------------------------------------
TP: default, time elapsed: 1393856 ns, RESULT:
    TCS: TestCase_testX, time elapsed: 1393856 ns, RESULT:
    [ PASSED ] CASE: testX (1378439 ns)
Summary: TOTAL: 1
    PASSED: 1, SKIPPED: 0, ERROR: 0
    FAILED: 0
--------------------------------------------------------------------------------------------------

而且即使你将生成步骤的数量增加到几百万,这个测试也可能不会失败,因为随机测试是随机的,生成精确的非常规数字的概率极低。 虽然此测试看起来是自动生成的,并且不能真正代表生产代码,但它显示了随机代码在更现实的场景中可能面临的相同问题,从而大大减少了其在更高级用途中的应用,例如查找代码中的安全缺陷。

为解决这个问题,仓颉单元测试框架引入了一项基于 security-fuzzing 测试技术的高级特性:覆盖率引导的随机参数化单元测试。该特性针对的是那些希望对其代码进行更细致的测试,同时仍然享受随机测试的可用性和易用性的用户。

使用覆盖率引导的单元测试,需要执行以下步骤:

  • 该项目必须在编译器支持 SanitizerCoverage 的情况下进行编译。具体需要以下两个选项:--sanitizer-coverage-trace-pc-guard--sanitizer-coverage-trace-compares
  • 用例可执行文件必须配置 --coverage-guided 选项。
  • 测试的生成步骤参数必须根据代码的复杂性增加,以允许覆盖引导算法继续进行。

如果我们使用编译器选项 --sanitizer-coverage-trace-pc-guard--sanitizer-coverage-trace-compares 编译上面的示例,然后使用 --coverage-guided 选项运行它,我们得到类似于以下内容的结果:

--------------------------------------------------------------------------------------------------
TP: default, time elapsed: 2004749 ns, RESULT:
    TCS: TestCase_testX, time elapsed: 2004749 ns, RESULT:
    [ FAILED ] CASE: testX (17380 ns)
    REASON: After 48 generation steps and 3 reduction steps:
        x = 1234567
    with randomSeed = 1721033700363557083
    Expect Failed: `(x != 1234567 == true)`
       left: false
      right: true

Summary: TOTAL: 1
    PASSED: 0, SKIPPED: 0, ERROR: 0
    FAILED: 1, listed below:
            TCS: TestCase_testX, CASE: testX
--------------------------------------------------------------------------------------------------

正如我们所看到的,测试在极少数生成步骤中失败(本例中为 48 个,但是,由于总是随机测试,结果可能会有所不同),但一般来说,建议至少将测试的生成步骤配置为数十万步。因此用例的执行时间可能会很长。