参数化测试

参数化测试入门

仓颉 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, item: 0)
    let expected = Array(size, item: 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, item: item)
    let expected = Array(size, item: 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 )以及它们实际导致测试失败的值。
  • 以及前面提到的随机种子,用于重现随机测试。

有些测试比较棘手,需要调整随机生成的步骤数。 可以通过如下两个配置项控制: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 宏可以用于任何参数化的测试用例,包括测试函数和测试类。