欢迎来到我们的系列文章「Cairo学习专题」第九讲!上一讲我们开始部署 Starknet 合约,今天我们开始测试合约。
像往常一样,如果你是中途加入,建议从头开始看我们的文章。
单元测试
单元测试不仅作为软件工程中广泛使用的术语,同样适用于智能合约开发中。因此在学习前,先通过几句话了解什么是单元测试。
单元测试是一种对软件的单个单元或组件进行测试。单元测试一般在软件应用的开发阶段进行,确保某个应用所有部分都按预期运行。它们通常用于软件开发的各个领域,但在编写智能合约时有更重要的作用。
当编写大量代码时,很有可能会存在现有功能错误,或者与预期执行不相符。经常会出现智能合约通过了编译但仍然存在代码错误的情况。
虽然大多数开发人员都不爱写测试,或者写覆盖面小的测试,但是制作测试有利于:
单元测试有助于在应用开发早期修复错误,避免日后被攻击造成亏损。
有助于开发人员理解测试代码库,以便做出修改。
高质量的单元测试可以作为项目(指南)文档。
明白了写测试的重要性后,让我们深入了解一下如何为 Cairo 合约写测试吧!
Protostar 测试
类似于 Foundry 让 Solidity 开发者在 Solidity 中编写单元测试,感谢 Protostar 团队的努力让 Cairo 开发者在 Cairo 中编写单元测试更容易!
基本语法
Protostar 的测试实例:
@external func test_increase_balance{syscall_ptr: felt*, range_check_ptr, pedersen_ptr: HashBuiltin*}() { let (result_before) = balance.read(); assert result_before = 0; increase_balance(42); let (result_after) = balance.read(); assert result_after = 42; return ();}
如上述,有了 Protostar 就可以用 Cairo 写测试。从这段代码中,你可以发现关于编写单元测试:
所有的测试用例都是外部函数,并以 test_ 为前缀。
在这里没有给函数传递参数,因为我们手动提供了所有需要的测试参数。
可以使用 assert 关键字更容易进行比较。
注意:在 Cairo 中使用 assert 关键字,如果左边的变量还没有设置,就会自动把右边的变量分配给左边的变量,因此安全的做法是确保我们要比较的常数总是在左边。
为了进一步解释这个问题,假设我们有一个常数。
const NUMBER = 30;
我们想获得一个函数的返回值并检查它是否等于常数,首先确保常数在左边,如果函数返回一个空参数,我们不想让 Cairo 分配常数。
所以我们需要改写:
let (num) = get_number();assert NUMBER = num;
设置钩子
在测试用例之前需要进行某些操作,比如部署一个合约并记录其地址,设置一些重要变量等。
类似于在 mocha 和 chai 中使用的 before 钩子 (Hook),我们可以在 protostar 中使用 setup 钩子预先在名叫 context 的存储变量中设置一些变量,并将它们从一个函数传递到另一个函数。
例如,我们可以使用设置钩子来部署我们在上一篇文章中的 starknet 合约,并将合约地址存储在上下文中,然后传递给其他测试案例:
@externalfunc __setup__{syscall_ptr: felt, pedersen_ptr: HashBuiltin, range_check_ptr}() {%{context.address = deploy_contract("./src/starknet.cairo", [ids.NAME]).contract_address %}return ();}
在开始写测试时会进一步说明。
常见的作弊代码
引用 protostar 官方文档中的话「大多数时候,不能只用断言来测试智能合约。一些测试案例需要操作区块链的状态,以及检查还原和事件。为此,Protostar 提供了一套作弊代码。」
还需要注意的是,这些作弊代码只能通过提示来访问,而不应该明确地写在你的 Cairo 合约中!
你可以在这里找到全部的,但为了控制篇幅,我们只介绍今天用到的四个:
deploy_contract
expect_revert
expect_events
start_prank
deploy_contract
这个作弊代码部署一个合同,输入合同的相对路径和构造函数参数(如果有的话)。
要使用这个作弊代码,我们要传入合同代码的相对路径,以及构造函数的参数:
%{ deploy_contract("./src/starknet.cairo", [322918500091226412576622]) %}
由于部署合约的过程通常很慢,建议你在设置钩子中使用这个作弊代码,这样你只需要执行一次这个动作。deploy_contract 作弊代码还可以访问已部署合同的合同地址,可以访问并存储在一个上下文变量中,以便从测试案例中访问。
%{context.address = deploy_contract("./src/starknet.cairo", [ids.NAME]).contract_address %}
expect_reverts
这个作弊代码是用来检查它下面的某个操作是否以指定的错误恢复,如果没有,则测试失败。换句话说,你可以用这个测试来确认合约回滚情况是否按预期工作。
例如,如果我们通过 main.cairo(由 protostar 初始化时创建的默认合约)的测试,我们会发现下面这段代码,它测试函数 increase_balance 会在输入为负数时回滚。
%{ expect_revert("TRANSACTION_FAILED", "Amount must be positive") %}increase_balance(-42);
可以看到,expect_revert 执行了它下面的函数调用,并检查错误的类型是否为 "TRANSACTION_FAILED",以及是否符合 "Amount must be positive",如果不符合则测试失败。
expect_events
这个作弊代码帮助你检查从你的 Starknet 合约中发出的事件是否与一些预期的事件相匹配。
与 expect_revert 不同,你可以在函数测试案例中的任何地方使用这个作弊代码,因为 Protostar 在测试案例完成后会检查发出的事件:
%{ expect_events({"name": "stored_name", "data" : ids.CALLER, [ids.NAME]}) %}
start_prank
这个作弊代码在编写单元测试时是非常重要的。你可以用它在编写单元测试时将 caller_address 改为选定的任何地址。使用这个代码比相对麻烦,因为使用时必须初始化一个持有新地址的可调用程序(像一个状态),然后在完成后取消初始化它。
也可以初始化不止一个来进行不同地址的测试:
%{ stop_prank = start_prank(0x00A596deDe49d268d6aD089B5aBdD089BE92D089B191e48) %}
// Your test logic goes here.%{ stop_prank() %}
我们使用 start_prank 开始一个 prank,并同时初始化一个可调用的 stop_prank。我们可以通过调用 stop_prank() 来结束 prank,在 start_prank 和 stop_prank() 之间的任何函数调用将使用指定地址作为调用者地址。
编写我们的第一个测试
哇,我们已经讲了很多了。现在是时候实践知识了,为我们上一篇文章中的 Starknet 合约写一个测试。
你也可以查看合约代码。
测试分为五个部分检测我们到目前为止所学的所有知识。
指明必要的导入。
指明整个测试所需的一些常量。
使用钩子部署我们的合约。
测试 store_name 函数。
测试 get_name 函数。
指明必要的导入
对于这个测试,我们将导入 HashBuiltin 库函数,以及我们想在 Starknet 合约中运行测试的所有函数(store_name 和 get_name 函数)。
%lang starknet
from starkware.cairo.common.cairo_builtins import HashBuiltin
from src.starknet import store_name, get_name
指明整个测试所需的一些常量
在这个测试中,我们需要两个常量:我们打算用来开始测试的呼叫地址,以及我们想作为参数提供给 store_name 函数的名称(用 felts 表示)。
const CALLER = 0x00A596deDe49d268d6aD089B56CC76598af3E949183a8ed10aBdE924de191e48;const NAME = 322918500091226412576622;
使用钩子部署我们的合约
如何用钩子部署合约:
@external
func __setup__{syscall_ptr: felt, pedersen_ptr: HashBuiltin, range_check_ptr}() {%{context.address = deploy_contract("./src/starknet.cairo", [ids.NAME]).contract_address %}return ();}
从上面的代码中,首先通过使用函数名 setup 来指定我们正在使用一个设置钩子。然后使用 deploy_contract 作弊代码来部署我们的合约,提供我们的合约代码的路径,以及一个参数 NAME。
注意我们使用 ids.NAME,而不是仅仅使用 NAME,这就是我们在 hint 中访问 Cairo 常量的方法。
测试 store_name 函数
@external
func test_store_name{syscall_ptr: felt, pedersen_ptr: HashBuiltin, range_check_ptr}() {
%{ stop_prank = start_prank(ids.CALLER) %}
store_name(NAME);
%{ expect_events({"name": "stored_name", "data" : [ids.CALLER, ids.NAME]}) %}
%{ stop_prank() %}
return ();}
测试可以帮助你理解一个函数的行为方式,从我们的函数中,你会注意到我们得到了 caller_address,然后我们用它作为一个键来存储我们的 name 参数。
在 Protostar 中,caller_address 默认为 0,但可以使用 start_prank 来改变这个。因此,你可以从上述代码中看到,首先需要启动一个 prank 来改变来呼叫地址。
接下来我们调用 store_name 函数,提供前面的常量 NAME 作为参数。
最后,我们检查 Starknet 的状态中发出的事件,以确保它与我们提供的参数 (CALLER 和 NAME) 相匹配,最后才停止 prank。
测试 get_name 函数
@external
func test_get_name{syscall_ptr: felt, pedersen_ptr: HashBuiltin, range_check_ptr}() {
%{ stop_prank = start_prank(ids.CALLER) %}
store_name(NAME);
let (name) = get_name(CALLER);
assert NAME = name;
%{ stop_prank() %}
return ();}
这个测试非常简单。我们再次重复前面的过程,因为我们需要存储一个名字然后获取这个名字。
所以我们从 prank 开始,存储一个名字,然后调用 get_name 函数,提供常数 CALLER 作为参数。
需要注意这一行:
assert NAME = name;
正如你所看到的,我们遵守了前面的规则,把常数 NAME 放在左手边,这样 Cairo 就不会进行赋值而是比较。
我们的完整代码:
%lang starknet
from starkware.cairo.common.cairo_builtins import HashBuiltin
from src.starknet import store_name, get_nameconst CALLER = 0x00A596deDe49d268d6aD089B56CC76598af3E949183a8ed10aBdE924de191e48;const NAME = 322918500091226412576622;@external
func __setup__{syscall_ptr: felt, pedersen_ptr: HashBuiltin, range_check_ptr}() {
%{context.address = deploy_contract("./src/starknet.cairo", [ids.NAME]).contract_address %}
return ();}@external
func test_store_name{syscall_ptr: felt, pedersen_ptr: HashBuiltin, range_check_ptr}() {
%{ stop_prank = start_prank(ids.CALLER) %}
store_name(NAME);
%{ expect_events({"name": "stored_name", "data" : [ids.CALLER, ids.NAME]}) %}
%{ stop_prank() %}
return ();}@external
func test_get_name{syscall_ptr: felt, pedersen_ptr: HashBuiltin, range_check_ptr}() {
%{ stop_prank = start_prank(ids.CALLER) %}
store_name(NAME);
let (name) = get_name(CALLER);
assert NAME = name;
%{ stop_prank() %}
return ();}
最后
今天我们学习了如何用 Protostar 写测试合约,以及其他的作弊代码,它们在编写测试时可能非常有用。你也可以在这里找到 OnlyDust 的深度测试脚本,它实现了 Protostar 的大部分作弊代码。
我们将在下节课深入研究 Empiric 的预言机。如果觉得本教程对你有帮助,转发分享给其他人吧~
本文由 wzabing 创作,采用 知识共享署名4.0 国际许可协议进行许可。
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名。