Unittest 基础概念及用法
测试及测试用例
测试是用 @Test
宏标记的实体,会在测试过程中执行。
仓颉 unittest 框架中有两种测试:测试类和测试函数。
测试函数相对简单,每个函数包含全部测试运行的代码。
测试类适用于为测试引入更深层次的结构,或者覆盖测试生命周期行为的场景。
每个测试类由若干个测试用例组成,每个测试用例用 @TestCase
宏标记。
每个测试用例都是测试类内部的一个函数。
上一节中的示例,相同的测试可以改写为如下所示的测试类:
func add(a:Int64, b:Int64) {
a + b
}
@Test
class AddTests {
@TestCase
func addTest() {
@Expect(add(2, 3), 5)
}
@TestCase
func addZero() {
@Expect(add(2, 0), 2)
}
}
测试函数即函数中包含单个测试用例的测试。这种情况下不需要使用 @TestCase
宏。
cjpm test
中运行这个新的测试类会生成如下类似输出:
--------------------------------------------------------------------------------------------------
TP: example/example, time elapsed: 67369 ns, Result:
TCS: AddTests, time elapsed: 31828 ns, RESULT:
[ PASSED ] CASE: addTest (25650 ns)
[ PASSED ] CASE: addZero (4312 ns)
Summary: TOTAL: 2
PASSED: 2, SKIPPED: 0, ERROR: 0
FAILED: 0
--------------------------------------------------------------------------------------------------
cjpm test success
断言
断言是在测试用例函数体内执行的单个条件检查,用以判断代码是否正常运行。
断言有两种:@Expect
和 @Assert
。
创建一个错误的测试来说明两者的区别:
func add(a:Int64, b:Int64) {
a + b
}
@Test
func testAddIncorrect() {
@Expect(add(3, 3), 5)
}
运行测试失败,并生成以下结果(仅展示与此测试相关的部分):
TCS: TestCase_testAddIncorrect, time elapsed: 4236 ns, RESULT:
[ FAILED ] CASE: testAddIncorrect (3491 ns)
Expect Failed: `(add ( 3 , 3 ) == 5)`
left: 6
right: 5
在这个例子中,用 @Assert
替换 @Expect
不会有什么变化。添加一个检查项后,再次运行:
func add(a:Int64, b:Int64) {
a + b
}
@Test
func testAddIncorrect() {
@Expect(add(3, 3), 5)
@Expect(add(5, 3), 9)
}
运行测试失败,并生成以下结果(仅展示与此测试相关的部分):
TCS: TestCase_testAddIncorrect, time elapsed: 5058 ns, RESULT:
[ FAILED ] CASE: testAddIncorrect (4212 ns)
Expect Failed: `(add ( 3 , 3 ) == 5)`
left: 6
right: 5
Expect Failed: `(add ( 5 , 3 ) == 9)`
left: 8
right: 9
可以在输出中看到这两个检查的结果。
但是,如果用 @Assert
替换 @Expect
:
func add(a:Int64, b:Int64) {
a + b
}
@Test
func testAddIncorrectAssert() {
@Assert(add(3, 3), 5)
@Assert(add(5, 3), 9)
}
可以得到以下输出:
TCS: TestCase_testAddIncorrectAssert, time elapsed: 31653 ns, RESULT:
[ FAILED ] CASE: testAddIncorrectAssert (30893 ns)
Assert Failed: `(add ( 3 , 3 ) == 5)`
left: 6
right: 5
可以看到,只有第一个 @Assert
检查失败了,其余的测试甚至都没有运行。
这是因为 @Assert
宏采用的是快速失败(fail-fast)机制:一旦首次断言失败,整个测试用例都失败,后续的断言不再检查。
在涉及大量断言的大型测试中,这一点非常重要,尤其是在循环中使用断言时。 并不需要等到全部失败,首次失败后用户即可感知。
在测试中选择 @Assert
还是 @Expect
取决于测试场景的复杂程度,以及是否需要采用快速失败机制。
使用 unittest
提供的两种断言宏时,可采用如下方式:
- 相等断言,其中
@Assert(a, b)
或@Expect(a, b)
的两个参数a
和b
,检查他们的参数值是否相等;假设a
的类型为A
,b
的类型为B
,A
必须实现了 Equatable<B> 。 - 布尔断言
@Assert(c)
或@Expect(c)
,其参数c
为Bool
类型,参数值true
或false
。
断言的第二种形式 @Assert(c)
可以视为 @Assert(c, true)
的简写形式。
失败断言
失败断言可以让测试用例运行失败。@Fail
采用快速失败机制,运行至此断言则整个测试用例失败,后续断言都不再检查。@FailExpect
运行至此断言会使得用例失败,但后续断言依然会被检查。
前述宏的参数为描述失败原因的字符串,@Fail
的返回值类型为 Nothing
, @FailExpect
的类型为 Unit
。
举例来说:
@Test
func validate_even_number_generator() {
let even = generateRandomEven()
if (even % 2 == 1) {
@Fail("Not even number was generated: ${even}")
}
}
会输出如下错误信息:
[ FAILED ] CASE: validate_even_number_generator (54313 ns)
Assert Failed: `(Not even number was generated: 111)`
预期异常的断言
运行至此断言当预期抛出的异常类型未被抛出时,用例将失败,@AssertThrows
将不再进行后续检查,而 @ExpectThrows
将继续检查。
前述宏的属性入参为预期抛出的异常类型列表,通过 |
隔离不同异常类型,当没有属性入参时预期抛出异常基类 Exception
。入参为预期抛出异常的表达式或代码块。
举例来说:
// No.1
@AssertThrows(throw Exception())
// 语义上与 No.1 相同
@AssertThrows[Exception](throw Exception())
@AssertThrows[IllegalStateException | NoneValueException](random.seed = 42u64)
@ExpectThrows[OutOfMemoryError](foo())
@ExpectThrows({
foo()
boo()
})
@ExpectThrows[OutOfMemoryError]({
for (i in list) {
foo(i)
}
})
@AssertThrows
的返回类型
如果指定的异常不超过一个,则返回类型与预期抛出的异常类型相同:
let e: NoneValueException = @AssertThrows[NoneValueException](foo())
如果指定的异常超过一个,则返回类型为预期抛出的异常类型的最小公共超类型:
// A <: C
// B <: C
let e: C = @AssertThrows[A | B](foo())
@ExpectThrows
的返回类型
@ExpectThrows
检查失败后也会继续执行,在指定的异常不超过一个时,它返回的类型是 Option<T>,其中 T
是预期的异常类型:
let e: ?NoneValueException = @ExpectThrows[NoneValueException](foo())
在指定的异常超过一个时,返回的类型是 ?Exception :
let e: ?Exception = @ExpectThrows[NoneValueException | IllegalMemoryException](foo())
测试生命周期
测试用例之间有时可以共享创建或清理代码。测试框架支持4个生命周期步骤,分别通过相应的宏来设置。只能为 @Test
测试类指定生命周期步骤,不能为 @Test
顶层函数指定生命周期步骤。
宏 | 生命周期 |
---|---|
@BeforeAll | 在所有测试用例之前运行 |
@BeforeEach | 在每个测试用例之前运行一次 |
@AfterEach | 在每个测试用例之后运行一次 |
@AfterAll | 在所有测试用例完成后运行 |
这些宏必须配置在 @Test
测试类的成员或静态函数上。@BeforeAll
和 @AfterAll
函数不能声明任何参数。 @BeforeEach
和 @AfterEach
函数可以声明一个 String
类型的参数(或者不声明参数)。
@Test
class FooTest {
@BeforeAll
func setup() {
//在测试执行前运行这段代码。
}
}
每个宏可以应用于单个测试类内的多个函数。可以在单个函数上配置多个生命周期宏。生命周期宏不能配置在标有 @TestCase
或类似宏的函数上。
如果多个函数被标记为相同的生命周期步骤,则可以按照它们在代码中声明的顺序(从上到下)执行。
测试框架确保:
- 标记为 Before all 的步骤在所有测试用例运行之前,至少运行一次。
- 对于测试类中的每个测试用例
TC
: 1) 标记为 Before each 的步骤在TC
之前运行一次。 2) 运行TC
。 3) 标记为 After each 的步骤在TC
之后运行一次。 - 在测试类中的所有测试用例之后运行标记为 After all 的步骤。
注意:
如果没有运行测试用例,上述并不适用。
简单场景中,标识为 before all 和 after all 的步骤只运行一次。但也有例外:
- 对于类型参数化测试,标识为 before/after all 的步骤运行的数量为每个类型参数的组合数。
- 如果多个测试用例在不同的进程中并行执行,则每个进程中标识为 before/after all 的步骤都将运行一次。
@BeforeEach
和 @AfterEach
可以访问正在创建或删除的测试用例,只需要在相应的函数中指定一个 String
类型的参数即可。
@Test
class Foo {
@BeforeEach
func prepareData(testCaseName: String) {
//测试用例函数的名称作为参数
//本例中的"bar"
}
@AfterEach
func cleanup() {
//不指定参数也可以
}
@TestCase
func bar() {}
}
为参数化测试或参数化性能测试配置生命周期时,注意标识为 before each 或 after each 的步骤仅在执行测试用例或基准之前或之后为其所有参数执行一次。也就是说,从生命周期的角度看,使用不同参数执行多次的测试主体会被视为单个测试用例。
如果参数化测试的每个参数都需要单独创建清理,需要将相应的代码配置在测试用例主体本身中。此外,还可以访问参数本身。
测试配置
单元测试框架中其他更高级的功能可能需要额外配置。 参考如下三种方式配置测试:
- 使用
@Configure
宏 - 直接在运行测试时或在
cjpm test
测试中使用命令行参数 - 使用
cjpm
配置文件
运行配置
使用方法
运行 cjc 编译的可执行文件 test ,添加参数选项
./test --bench --filter MyTest.*Test,-stringTest
--bench
默认情况下,只有被 @TestCase
修饰的函数会被执行。在使用 --bench
的情况下只执行 @Bench
宏修饰的用例。
--filter
如果您希望以测试类和测试用例过滤出测试的子集,可以使用 --filter=测试类名.测试用例名
的形式来筛选匹配的用例,例如:
--filter=*
匹配所有测试类--filter=*.*
匹配所有测试类所有测试用例(结果和*相同)--filter=*.*Test,*.*case*
匹配所有测试类中以 Test 结尾的用例,或者所有测试类中名字中带有 case 的测试用例--filter=MyTest*.*Test,*.*case*,-*.*myTest
匹配所有 MyTest 开头测试类中以 Test 结尾的用例,或者名字中带有 case的用例,或者名字中不带有 myTest 的测试用例
另外,--filter
后有 =
和无 =
均支持。
--include-tags
若需按 @Tag
宏中指定的类别选择测试的子集,则可使用 --include-tags
或 --exclude-tags
运行选项。例如:
--include-tags=Unittest
运行所有的带有@Tag[Unittest]
的测试用例。--include-tags=Unittest,Smoke
运行所有的带有@Tag[Unittest]
和/或@Tag[Smoke]
的测试用例。--include-tags=Unittest+Smoke
运行所有的带有@Tag[Unittest]
和@Tag[Smoke]
的测试用例。--include-tags=Unittest+Smoke+JiraTask3271,Backend
运行所有的带有@Tag[Backend]
和/或@Tag[Unittest, Smoke, JiraTask3271]
的测试用例。
** 注意 ** 如果没有符合指定标签类别的测试用例。框架将不运行任何内容。 可以与
exclude-tags
结合。详见--exclude-tags
。
--exclude-tags
若需按 @Tag
宏中指定的类别选择测试的子集,则可使用 --include-tags
或 --exclude-tags
运行选项。例如:
--exclude-tags=Unittest
运行所有的未带有@Tag[Unittest]
的测试用例。--exclude-tags=Unittest,Smoke
运行所有的未带有@Tag[Unittest]
和/或@Tag[Smoke]
的测试用例。--exclude-tags=Unittest+Smoke
运行所有的未同时带有@Tag[Unittest]
、@Tag[Smoke]
的测试用例。--include-tags=Unittest --exclude-tags=Smoke
运行所有带有@Tag[Unittest]
但不带有@Tag[Smoke]
的测试用例。
** 注意 **
exclude-tags
的优先级高于include-tags
,如果用例被排除,则必定不会被执行,例如--include-tags=Unittest+Smoke --exclude-tags=Smoke
则带有@Tag[Smoke]
的用例不会被执行。
--timeout-each=timeout
使用 --timeout-each=timeout
选项等同于对所有的测试类使用 @Timeout[timeout]
修饰。若代码中已有 @Timeout[timeout]
,则将被代码中的超时时间覆盖,即选项的超时时间配置优先级低于代码中超时时间配置。
timeout
的值应符合以下语法:
number ('millis' | 's' | 'm' | 'h')
例如: 10s
, 9millis
等。
- millis : 毫秒
- s : 秒
- m : 分钟
- h : 小时
--parallel
打开 --parallel
选项将使测试框架在单独的多个进程中并行执行不同的测试类。
测试类之间应该是相互独立的,不依赖于共享的可变的状态值。
程序静态初始化可能会发生多次。
不允许与 --bench
同时使用。由于性能用例对底层资源敏感,用例是否并行执行,将影响性能用例的结果,因此禁止与 --bench
同时使用。
--parallel=<BOOL>
<BOOL>
可为true
或false
,指定为true
时,测试类可被并行运行,并行进程个数将受运行系统上的CPU核数控制。另外,--parallel
可省略=true
。--parallel=nCores
指定了并行的测试进程个数应该等于可用的 CPU 核数。--parallel=NUMBER
指定了并行的测试进程个数值。该数值应该为正整数。--parallel=NUMBERnCores
指定了并行的测试进程个数值为可用的 CPU 核数的指定数值倍。该数值应该为正数(支持浮点数或整数)。
--option=value
以 --option=value
形式提供的任何非上述的选项均按以下规则处理并转换为配置参数(类似于 @Configure
宏处理的参数),并按顺序应用:
option
与 value
为任意自定义的运行配置选项键值对,option
可以为任意通过 -
连接的英文字符,转换到 @Configure
时将转换为小驼峰格式。value
的格式规则如下:
注:当前未检查 option
与 value
的合法性,并且选项的优先级低于代码中 @Configure
的优先级。
- 如果省略
=value
部分,则该选项被视为Bool
值true
, 例如:--no-color
生成配置条目noColor = true
。 - 如果
value
严格为true
或false
,则该选项被视为具有相应含义的Bool
值:--no-color=false
生成配置条目noColor = false
。 - 如果
value
是有效的十进制整数,则该选项被视为Int64
值 , 例如:--random-seed=42
生成配置条目randomSeed = 42
。 - 如果
value
是有效的十进制小数,则该选项被视为Float64
值 , 例如:--angle=42.0
生成配置条目angle = 42
。 - 如果
value
是带引号的字符串文字(被"
符号包围),则该选项将被视为String
类型,并且该值是通过解码"
符号之间的字符串值来生成的,并处理转义符号,例如\n
、\t
、\"
作为对应的字符值。例如,选项--mode="ABC \"2\""
生成配置条目mode = "ABC \"2\""
; - 除上述情况外
value
值将被视为String
类型,该值从所提供的选项中逐字获取。例如,--mode=ABC23[1,2,3]
生成配置条目mode = "ABC23[1,2,3]"
。
--report-path=path
该选项用于指定执行后生成测试报告的目录路径。默认情况下,如果不明确指定选项,将不会生成报告。
--report-format=value
该选项用于指定测试执行后生成的报告的格式。
目前,单元测试仅支持默认的 xml 格式。
基准测试支持:
csv
:csv 报告中有统计数据。csv-raw
: csv-raw 报告中只有批次的原始测量值。
基准测试的默认使用的格式为:
csv
--capture-output
此选项使能测试用例的打印输出捕获。
默认情况下,在 cjpm test
执行时使能捕获,否则禁用捕获。
当测试用例打印输出捕获未使能时,打印输出将立即传播到单元测试输出。否则,单元测试将收集和处理测试用例的打印输出。
需要捕获打印输出的场景如下:
- 使用
--parallel
执行时防止测试输出交错。 - 隐藏已通过测试的输出以使单元测试报告更加清晰。
- 将每个测试用例的输出分开,以查看哪个测试用例打印了什么。
--no-capture-output
此选项禁用测试用例打印输出捕获。
默认情况下,在 cjpm test
执行中启用捕获,否则禁用捕获。
使测试用例打印输出立即传播到单元测试输出。
--show-all-output
单元测试框架将打印报告中的所有输出,包括通过的测试用例的输出。 如果禁用测试输出捕获,则此选项失效。
--coverage-guided
单元测试框架将使能覆盖率引导的随机参数化测试。
--baseline-path=path
此选项指定用于比较的性能报告所在的路径。默认情况下,使用 '--report-path' 的值。