如何编写测试

测试函数的剖析

测试是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');
    }
}

示例9-1:一个测试模块和其函数

现在,让我们忽略最上面的两行,专注于这个函数。注意#[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;

示例9-2:运行测试后的输出

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');
    }

}

示例9-3:添加第二个测试(会失败的测试)

$ 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

示例9-4:一个测试通过,一个测试失败时的测试结果

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
    }
}
}

示例9-5:使用第五章中的 Rectangle结构及其can_hold方法

返回值为boolcan_hold方法是assert函数的一个完美用例。在示例9-6中,我们写了一个测试,通过创建一个宽度为8、高度为7Rectangle实例,并断言它可以容纳另一个宽度为5、高度为1Rectangle实例,来测试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');
    }
}


}

示例 9-6: can_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.width8smaller.width5can_hold中的宽度比较现在返回false:因为8不比5小。

should_panic检查panic情况

除了检查返回值之外,检查我们的代码是否按照我们所期望的那样处理错误条件也很重要。例如,考虑示例9-8中的Guess类型。其他使用Guess的代码依赖于保证Guess实例只包含1100之间的值。我们可以写一个测试,以确保试图创建的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);
    }
}
}

示例9-8:测试一个条件是否会导致panic

我们把#[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);
    }
}


}

示例9-9:用包含错误信息字符串的恐慌信息来测试panic

这个测试将通过,因为我们放在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’);
    }
}
}

示例9-10:两个不同名称的测试

我们可以将任何测试函数的名称传递给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。

Last change: 2023-09-22, commit: 724b90c