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) 的两个参数 ab ,检查他们的参数值是否相等;假设 a 的类型为 Ab 的类型为 BA 必须实现了 Equatable<B> 。
  • 布尔断言 @Assert(c)@Expect(c) ,其参数 cBool 类型,参数值 truefalse

断言的第二种形式 @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 或类似宏的函数上。

如果多个函数被标记为相同的生命周期步骤,则可以按照它们在代码中声明的顺序(从上到下)执行。

测试框架确保:

  1. 标记为 Before all 的步骤在所有测试用例运行之前,至少运行一次。
  2. 对于测试类中的每个测试用例 TC : 1) 标记为 Before each 的步骤在 TC 之前运行一次。 2) 运行 TC 。 3) 标记为 After each 的步骤在 TC 之后运行一次。
  3. 在测试类中的所有测试用例之后运行标记为 After all 的步骤。

注意:

如果没有运行测试用例,上述并不适用。

简单场景中,标识为 before allafter 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 eachafter each 的步骤仅在执行测试用例或基准之前或之后为其所有参数执行一次。也就是说,从生命周期的角度看,使用不同参数执行多次的测试主体会被视为单个测试用例。

如果参数化测试的每个参数都需要单独创建清理,需要将相应的代码配置在测试用例主体本身中。此外,还可以访问参数本身。

测试配置

单元测试框架中其他更高级的功能可能需要额外配置。 参考如下三种方式配置测试:

  • 使用 @Configure
  • 直接在运行测试时或在 cjpm test 测试中使用命令行参数
  • 使用 cjpm 配置文件

运行配置

使用方法

运行 cjc 编译的可执行文件 test ,添加参数选项

./test --bench --filter MyTest.*Test,-stringTest

--bench

默认情况下,只有被 @TestCase 修饰的函数会被执行。在使用 --bench 的情况下只执行 @Bench 宏修饰的用例。

--filter

如果您希望以测试类和测试用例过滤出测试的子集,可以使用 --filter=测试类名.测试用例名 的形式来筛选匹配的用例,例如:

  1. --filter=* 匹配所有测试类
  2. --filter=*.* 匹配所有测试类所有测试用例(结果和*相同)
  3. --filter=*.*Test,*.*case* 匹配所有测试类中以 Test 结尾的用例,或者所有测试类中名字中带有 case 的测试用例
  4. --filter=MyTest*.*Test,*.*case*,-*.*myTest 匹配所有 MyTest 开头测试类中以 Test 结尾的用例,或者名字中带有 case的用例,或者名字中不带有 myTest 的测试用例

另外,--filter 后有 = 和无 = 均支持。

--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> 可为 truefalse ,指定为 true 时,测试类可被并行运行,并行进程个数将受运行系统上的CPU核数控制。另外,--parallel 可省略 =true
  • --parallel=nCores 指定了并行的测试进程个数应该等于可用的 CPU 核数。
  • --parallel=NUMBER 指定了并行的测试进程个数值。该数值应该为正整数。
  • --parallel=NUMBERnCores 指定了并行的测试进程个数值为可用的 CPU 核数的指定数值倍。该数值应该为正数(支持浮点数或整数)。

--option=value

--option=value 形式提供的任何非上述的选项均按以下规则处理并转换为配置参数(类似于 @Configure 宏处理的参数),并按顺序应用:

optionvalue 为任意自定义的运行配置选项键值对,option 可以为任意通过 - 连接的英文字符,转换到 @Configure 时将转换为小驼峰格式。value 的格式规则如下:

注:当前未检查 optionvalue 的合法性,并且选项的优先级低于代码中 @Configure 的优先级。

  • 如果省略 =value 部分,则该选项被视为 Booltrue , 例如:--no-color 生成配置条目 noColor = true
  • 如果 value 严格为 truefalse ,则该选项被视为具有相应含义的 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