参数化测试
参数化测试入门
仓颉 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
,不是一个数组。
那么,运行这个测试,结果会如何?
参数 size
和 item
的取值会进行组合再测试和返回结果。
因此,测试函数时,不要配置过多参数;否则组合数量过大,导致测试变慢。
上述例子中,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 接口,否则将只打印占位符。 - 以及前面提到的随机种子,用于重现随机测试。
有些测试比较棘手,需要调整随机生成的步骤数。
可以通过如下两个配置项控制:generationSteps
和 reductionSteps
。
随机测试一个最大的优点是,只要设置了足够多的步骤,它可以运行很长时间,这样就可以检查数百万个值。
为了不使测试太耗时,默认 generationSteps
和 reductionSteps
的最大值都是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
宏设置测试,这样就可以运行测试 Int64
、 Float64
和 String
这些类型了。
@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 个,但是,由于总是随机测试,结果可能会有所不同),但一般来说,建议至少将测试的生成步骤配置为数十万步。因此用例的执行时间可能会很长。