如何编写测试
测试函数的剖析
测试是Cairo函数,用于验证非测试代码是否以预期方式运行。测试函数的主体通常执行这三个动作:
- 设置任何需要的数据或状态。
- 运行你想测试的代码。
- 断言结果与你期望的一样。
让我们看看Cairo专门为编写执行这些动作的测试所提供的功能,其中包括test
属性、assert
函数和should_panic
属性。
一个测试函数的剖析
最简单的Cairo中的测试是一个带有test
属性注释的函数。属性是关于Cairo代码片段的元数据;一个例子是我们在第5章中对结构体使用的derive属性。要把一个函数变成测试函数,在 fn
前的一行加上 #[test]
。当你用cairo-test
命令运行你的测试时,Cairo会建立一个测试运行器的二进制文件,运行被标注了的函数,并报告每个测试函数的通过或失败。
让我们创建一个名为 adder
的将两个数字相加的新项目,用scarb new adder
”命令:
adder
├── Scarb.toml
└── src
└── lib.cairo
在 lib.cairo 中,让我们添加第一个测试,如示例9-1所示。
文件名: src/lib.cairo
#![allow(unused)] fn main() { #[test] fn it_works() { let result = 2 + 2; assert(result == 4, 'result is not 4'); } }
现在,让我们忽略最上面的两行,专注于这个函数。注意#[test]
标注:这个属性表明这是一个测试函数,所以测试运行器知道要把这个函数当作一个测试。我们可能在测试模块中也有非测试函数,以帮助设置常见的场景或执行常见的操作,所以我们总是需要指出哪些函数是测试的。
这个例子的函数体使用了assert
函数,它包含了2和2相加的结果,等于4。这个断言是一个典型测试格式范例。让我们运行它,看看这个测试是否通过。
用scarb cairo-test
命令运行我们项目中的所有测试,如示例9-2所示。
$ scarb cairo-test
testing adder...
running 1 tests
test adder::lib::tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;
scarb cairo-test
编译并运行了测试。我们看到一行running 1 tests
。下一行显示了生成的测试函数的名称,叫做it_works
,运行该测试的结果是ok
。总体摘要test result: ok.
意味着所有的测试都通过了,1 passed; 0 failed
的部分展示了通过或失败的测试的总数。
我们可以把一个测试标记为忽略,这样它就不会在一个特定的实例中运行;我们将在本章后面的忽略一些测试,除非特别要求一节中介绍。因为我们在这里没有这样做,所以摘要中显示 0 ignored
。我们也可以给cairo-test
命令传递一个参数,只运行名称与某个字符串相匹配的测试;这叫做过滤,我们将在运行单个测试一节中介绍。我们也没有对正在运行的测试进行过滤,所以总结的最后显示0 filtered out
。
让我们开始根据我们自己的需要定制测试。首先将it_works
函数的名称改为不同的名称,例如exploration
,像这样:
文件名: src/lib.cairo
#![allow(unused)] fn main() { #[test] fn exploration() { let result = 2 + 2; assert(result == 4, 'result is not 4'); } }
然后再次运行scarb cairo-test
。现在输出显示的是 exploration
而不是it_works
:
$ scarb cairo-test
running 1 tests
test adder::lib::tests::exploration … ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;
现在我们将添加另一个测试,但这次我们要做一个失败的测试! 当测试函数中的某些东西发生panic时,测试就会失败。每个测试都在一个新的线程中运行,当主线程看到一个测试线程死亡时,该测试被标记为失败。将新的测试作为一个名为another
的函数输入,因此你的 src/lib.cairo 文件看起来像示例9-3里一样。
#![allow(unused)] fn main() { #[test] fn another() { let result = 2 + 2; assert(result == 6, 'Make this test fail'); } }
$ scarb cairo-test
running 2 tests
test adder::lib::tests::exploration … ok
test adder::lib::tests::another … fail
failures:
adder::lib::tests::another - panicked with [1725643816656041371866211894343434536761780588 (‘Make this test fail’), ].
Error: test result: FAILED. 1 passed; 1 failed; 0 ignored
adder::lib::test::another
这一行没有显示ok
,而是显示fail
。在单个结果和摘要之间出现了一个新的部分。它显示了每个测试失败的详细原因。在这个例子中,我们得到的细节在是 _src/lib.cairo_文件中another
失败了,因为它发生了panic [1725643816656041371866211894343434536761780588 (‘Make this test fail’), ]
。
摘要行显示在最后:总的来说,我们的测试结果是FAILED
。我们有一个测试通过,一个测试失败。
现在你已经看到了不同场景下的测试结果,让我们看看一些在测试中有用的函数。
用断言函数检查结果
Cairo提供的assert
函数,在你想确保测试中的某些条件一定为true
时非常有用。我们给assert
函数的第一个参数是一个布尔值。如果该值为true
,则不会发生任何事情,测试通过。如果值是 false
,assert函数调用 panic()
,导致测试失败,我们定义的信息是 assert
函数的第二个参数。使用assert
函数可以帮助我们检查我们的代码是否按照我们的意图运行。
在第5章,示例5-15中,我们使用了一个Rectangle
结构和一个can_hold
方法,在示例9-5中重复了这些。让我们把这段代码放在_src/lib.cairo_文件中,然后用assert
函数为它写一些测试。
文件名: src/lib.cairo
#![allow(unused)] fn main() { trait RectangleTrait { fn area(self: @Rectangle) -> u64; fn can_hold(self: @Rectangle, other: @Rectangle) -> bool; } impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { *self.width * *self.height } fn can_hold(self: @Rectangle, other: @Rectangle) -> bool { *self.width > *other.width && *self.height > *other.height } } }
返回值为bool
的can_hold
方法是assert函数的一个完美用例。在示例9-6中,我们写了一个测试,通过创建一个宽度为8
、高度为7
的Rectangle
实例,并断言它可以容纳另一个宽度为5
、高度为1
的Rectangle
实例,来测试can_hold
方法。
文件名: src/lib.cairo
#![allow(unused)] fn main() { use debug::PrintTrait; #[derive(Copy, Drop)] struct Rectangle { width: u64, height: u64, } trait RectangleTrait { fn area(self: @Rectangle) -> u64; fn can_hold(self: @Rectangle, other: @Rectangle) -> bool; } impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { *self.width * *self.height } fn can_hold(self: @Rectangle, other: @Rectangle) -> bool { *self.width > *other.width && *self.height > *other.height } } #[cfg(test)] mod tests { use super::Rectangle; use super::RectangleTrait; #[test] fn larger_can_hold_smaller() { let larger = Rectangle { height: 7, width: 8, }; let smaller = Rectangle { height: 1, width: 5, }; assert(larger.can_hold(@smaller), 'rectangle cannot hold'); } #[test] fn smaller_cannot_hold_larger() { let larger = Rectangle { height: 7, width: 8, }; let smaller = Rectangle { height: 1, width: 5, }; assert(!smaller.can_hold(@larger), 'rectangle cannot hold'); } } }
注意,我们在测试模块中加入了两行新的内容:use super::Rectangle;
和use super::RectangleTrait;
。测试模块是一个常规模块,遵循通常的可见性规则。因为测试模块是一个内部模块,我们需要将外部模块中的被测代码引入内部模块的范围。
我们将我们的测试命名为larger_can_hold_smaller
,并且创建了我们需要的两个Rectangle
实例。然后我们调用了assert函数,并将调用larger.can_hold(@smaller)
的结果传给它。这个表达式应该返回 true
,所以我们的测试应该通过。让我们拭目以待吧!
$ scarb cairo-test
running 1 tests
test adder::lib::tests::larger_can_hold_smaller ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;
它确实通过了!让我们再增加���个测试,这次是断言一个较小的矩形不能容纳一个较大的矩形:
文件名: src/lib.cairo
#![allow(unused)] fn main() { use debug::PrintTrait; #[derive(Copy, Drop)] struct Rectangle { width: u64, height: u64, } trait RectangleTrait { fn area(self: @Rectangle) -> u64; fn can_hold(self: @Rectangle, other: @Rectangle) -> bool; } impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { *self.width * *self.height } fn can_hold(self: @Rectangle, other: @Rectangle) -> bool { *self.width > *other.width && *self.height > *other.height } } #[cfg(test)] mod tests { use super::Rectangle; use super::RectangleTrait; #[test] fn larger_can_hold_smaller() { let larger = Rectangle { height: 7, width: 8, }; let smaller = Rectangle { height: 1, width: 5, }; assert(larger.can_hold(@smaller), 'rectangle cannot hold'); } #[test] fn smaller_cannot_hold_larger() { let larger = Rectangle { height: 7, width: 8, }; let smaller = Rectangle { height: 1, width: 5, }; assert(!smaller.can_hold(@larger), 'rectangle cannot hold'); } } }
因为在这种情况下,can_hold
函数的正确结果是false
,我们需要在传递给assert函数之前否定这个结果。因此,如果can_hold
返回false,我们的测试将通过:
$ scarb cairo-test
running 2 tests
test adder::lib::tests::smaller_cannot_hold_larger … ok
test adder::lib::tests::larger_can_hold_smaller … ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 filtered out;
两个测试都通过了!现在让我们看看当我们在代码中引入一个错误时,我们的测试结果会怎样。我们将改变can_hold
方法的实现,当它比较宽度时,将大于号替换为小于号:
#![allow(unused)] fn main() { impl RectangleImpl of RectangleTrait { fn area(self: @Rectangle) -> u64 { *self.width * *self.height } fn can_hold(self: @Rectangle, other: @Rectangle) -> bool { *self.width < *other.width && *self.height > *other.height } } }
现在运行测试产生以下结果:
$ scarb cairo-test
running 2 tests
test adder::lib::tests::smaller_cannot_hold_larger … ok
test adder::lib::tests::larger_can_hold_smaller … fail
failures:
adder::lib::tests::larger_can_hold_smaller - panicked with [167190012635530104759003347567405866263038433127524 (‘rectangle cannot hold’), ].
Error: test result: FAILED. 1 passed; 1 failed; 0 ignored
我们的测试发现了这个错误! 因为larger.width
是8
,smaller.width
是5
,can_hold
中的宽度比较现在返回false
:因为8
不比5
小。
用should_panic
检查panic情况
除了检查返回值之外,检查我们的代码是否按照我们所期望的那样处理错误条件也很重要。例如,考虑示例9-8中的Guess类型。其他使用Guess
的代码依赖于保证Guess
实例只包含1
和100
之间的值。我们可以写一个测试,以确保试图创建的Guess
实例的值不在这个范围内时,会发生panic。
我们通过在我们的测试函数中添加属性should_panic
来做到这一点。如果函数中的代码出现panic,则测试通过;如果函数中的代码没有出现panic,则测试失败。
示例9-8显示了一个测试,检查GuessTrait::new
的错误条件是否在我们期望的时候发生。
文件名: src/lib.cairo
#![allow(unused)] fn main() { #[derive(Copy, Drop)] struct Guess { value: u64, } trait GuessTrait { fn new(value: u64) -> Guess; } impl GuessImpl of GuessTrait { fn new(value: u64) -> Guess { if value < 1 || value > 100 { let mut data = ArrayTrait::new(); data.append('Guess must be >= 1 and <= 100'); panic(data); } Guess { value } } } #[cfg(test)] mod tests { use super::Guess; use super::GuessTrait; #[test] #[should_panic] fn greater_than_100() { GuessTrait::new(200); } } }
我们把#[should_panic]
属性放在#[test]
属性之后和它适用的测试函数之前。让我们看一下这个测试通过后的结果:
$ scarb cairo-test
running 1 tests
test adder::lib::tests::greater_than_100 … ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;
看起来不错! 现在让我们在代码中引入一个错误,删除新函数在值大于100
时将发生panic的条件:
#![allow(unused)] fn main() { #[derive(Copy, Drop)] struct Guess { value: u64, } trait GuessTrait { fn new(value: u64) -> Guess; } impl GuessImpl of GuessTrait { fn new(value: u64) -> Guess { if value < 1 { let mut data = ArrayTrait::new(); data.append('Guess must be >= 1 and <= 100'); panic(data); } Guess { value, } } } }
当我们运行示例9-8中的测试时,它将失败:
$ scarb cairo-test
running 1 tests
test adder::lib::tests::greater_than_100 ... fail
failures:
adder::lib::tests::greater_than_100 - expected panic but finished successfully.
Error: test result: FAILED. 0 passed; 1 failed; 0 ignored
在这种情况下,我们没有得到一个非常有用的消息,但是当我们看测试函数时,我们看到它被注解为#[should_panic]
。我们得到的失败意味着测试函数中的代码并没有引起panic。
使用should_panic
的测试可能是不精确的。即使测试的panic原因与我们所期望的不同, 但只要发生了panic,一个should_panic
测试就一定会通过。为了使should_panic
测试更加精确,我们可以在should_panic
属性中添加一个可选的预期参数。该测试限制将确保故障信息包含所提供的文本。例如,考虑示例9-9中Guess
的修改后的代码,新函数根据数值过小或过大的情况,以不同的消息进行恐慌。
文件名: src/lib.cairo
#![allow(unused)] fn main() { #[derive(Copy, Drop)] struct Guess { value: u64, } trait GuessTrait { fn new(value: u64) -> Guess; } impl GuessImpl of GuessTrait { fn new(value: u64) -> Guess { if value < 1 { panic_with_felt252('Guess must be >= 1'); } else if value > 100 { panic_with_felt252('Guess must be <= 100'); } Guess { value, } } } #[cfg(test)] mod tests { use super::Guess; use super::GuessTrait; #[test] #[should_panic(expected: ('Guess must be <= 100',))] fn greater_than_100() { GuessTrait::new(200); } } }
这个测试将通过,因为我们放在should_panic
属性的预期参数中的值是Guess::new
函数panic信息的字符串阵列。我们需要指定我们期望的整个panic信息。
为了看看当一个带有预期信息的 should_panic
测试失败时会发生什么,让我们再次把if value < 1
和else if value > 100
块的主体互换,从而在我们的代码中引入一个错误:
#![allow(unused)] fn main() { impl GuessImpl of GuessTrait { fn new(value: u64) -> Guess { if value < 1 { let mut data = ArrayTrait::new(); data.append('Guess must be >= 1'); panic(data); } else if value > 100 { let mut data = ArrayTrait::new(); data.append('Guess must be <= 100'); panic(data); } Guess { value, } } } #[cfg(test)] mod tests { use super::Guess; use super::GuessTrait; #[test] #[should_panic(expected: ('Guess must be <= 100',))] fn greater_than_100() { GuessTrait::new(200); } } }
这一次,当我们运行should_panic
测试时,它将失败:
$ scarb cairo-test
running 1 tests
test adder::lib::tests::greater_than_100 … fail
failures:
adder::lib::tests::greater_than_100 - panicked with [6224920189561486601619856539731839409791025 (‘Guess must be >= 1’), ].
Error: test result: FAILED. 0 passed; 1 failed; 0 ignored
失败信息表明,这个测试确实像我们预期的那样发生了panic,但是panic信息不包括预期的字符串。在这种情况下,我们得到的panic信息是 Guess must be >= 1
。现在我们可以开始找出我们的错误所在了!
运行单一测试
有时,运行一个完整的测试套件可能需要很长的时间。如果你正在处理某个特定领域的代码,你可能只想运行与该代码有关的测试。你可以通过传递scarb cairo-test
以及-f
(“filter”)你想运行的测试名称作为参数来选择运行哪些测试。
为了演示如何运行一个测试,我们将首先创建两个测试函数,如示例9-10所示,并选择运行哪一个。
文件名: src/lib.cairo
#![allow(unused)] fn main() { #[cfg(test)] mod tests { #[test] fn add_two_and_two() { let result = 2 + 2; assert(result == 4, ‘result is not 4’); } #[test] fn add_three_and_two() { let result = 3 + 2; assert(result == 5, ‘result is not 5’); } } }
我们可以将任何测试函数的名称传递给cairo-test
,以使用-f
标志只运行该测试:
$ scarb cairo-test -f add_two_and_two
running 1 tests
test adder::lib::tests::add_two_and_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 1 filtered out;
只有名称为add_two_and_two
的测试被运行了,其他的测试不符合这个名称。测试输出最后显示了1个测试被过滤掉,让我们知道我们还有一个测试没有运行。
我们还可以指定测试名称的一部分,任何名称包含该值的测试都将被运行。
在非特别指定时,忽略一些测试
有时一些特定的测试执行起来非常耗时,所以你可能想在大多数scarb cairo-test
的运行中排除它们。与其将所有你想运行的测试列为参数,不如使用ignore
属性对耗时的测试进行注释,将其排除在外,如图所示:
文件名: src/lib.cairo
#![allow(unused)] fn main() { #[cfg(test)] mod tests { #[test] fn it_works() { let result = 2 + 2; assert(result == 4, 'result is not 4'); } #[test] #[ignore] fn expensive_test() { // code that takes an hour to run } } }
对于想要排除的测试,我们在 #[test]
之后,添加了 #[ignore]
行。现在,当我们运行我们的测试时,it_works
会运行,但expensive_test
不会:
$ scarb cairo-test
running 2 tests
test adder::lib::tests::expensive_test ... ignored
test adder::lib::tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 1 ignored; 0 filtered out;
expensive_test
函数被列为ignored
。
当你到了需要检查被忽略的测试结果的时候,而且你有时间等待测试结果,你可以运行scarb cairo-test --include-ignored
来运行所有的测试,无论它们是否被标记忽略。
测试递归函数或循环
在测试递归函数或循环时,必须为测试提供可消耗的最大gas。这样可以防止运行无限循环或消耗过多gas,还可以帮助对您的代码实现效率进行基准测试。为此,必须在测试函数中添加 #[available_gas(<Number>)]
属性。下面的示例展示了如何使用该属性:
文件名: src/lib.cairo
#![allow(unused)] fn main() { fn sum_n(n: usize) -> usize { let mut i = 0; let mut sum = 0; loop { if i == n { sum += i; break; }; sum += i; i += 1; }; sum } #[cfg(test)] mod test { use super::sum_n; #[test] #[available_gas(2000000)] fn test_sum_n() { let result = sum_n(10); assert(result == 55, 'result is not 55'); } } }
测定特定操作的gas使用量
如果要对特定操作的gas用量进行基准测试,可以在测试函数中使用以下模式。
#![allow(unused)] fn main() { let initial = testing::get_available_gas(); gas::withdraw_gas().unwrap(); /// code we want to bench. (testing::get_available_gas() - x).print(); }
下面的示例展示了如何使用它来测试上述 sum_n
函数的gas用量。
#![allow(unused)] fn main() { fn sum_n(n: usize) -> usize { let mut i = 0; let mut sum = 0; loop { if i == n { sum += i; break; }; sum += i; i += 1; }; sum } #[cfg(test)] mod test { use super::sum_n; use debug::PrintTrait; #[test] #[available_gas(2000000)] fn benchmark_sum_n_gas() { let initial = testing::get_available_gas(); gas::withdraw_gas().unwrap(); /// code we want to bench. let result = sum_n(10); (initial - testing::get_available_gas()).print(); } } }
运行 "scarb cairo-test "时打印的值是基准运行所消耗的gas。
$ scarb cairo-test
testing no_listing_09_benchmark_gas ...
running 1 tests
[DEBUG] (raw: 0x179f8
test no_listing_09_benchmark_gas::benchmark_sum_n_gas ... ok (gas usage est.: 98030)
test result: ok. 1 passed; 0 failed; 0 ignored; 0 filtered out;
这里,"sum_n "函数的gas用量为 96760(十六进制数的十进制表示法)。由于运行整个测试功能需要一些额外步骤,因此测试消耗的gas总量略高,为 98030。