Cairo 之旅 IX:用 Protostar 编写测试合约

作者:Darington Nnam
原文:Journey Through Cairo IX— Ultimate Guide To Testing Your Contracts With Protostar
翻译:Louis Wang
校对:「StarkNet 中文社区」

欢迎来到我们的系列文章「Cairo之旅」第九讲!上一讲我们开始部署 Starknet 合约,今天我们开始测试合约。

像往常一样,如果你是中途加入,建议从头开始看我们的文章。

单元测试

单元测试不仅作为软件工程中广泛使用的术语,同样适用于智能合约开发中。因此在学习前,先通过几句话了解什么是单元测试。

单元测试是一种对软件的单个单元或组件进行测试。单元测试一般在软件应用的开发阶段进行,确保某个应用所有部分都按预期运行。它们通常用于软件开发的各个领域,但在编写智能合约时有更重要的作用。

当编写大量代码时,很有可能会存在现有功能错误,或者与预期执行不相符。经常会出现智能合约通过了编译但仍然存在代码错误的情况。

虽然大多数开发人员都不爱写测试,或者写覆盖面小的测试,但是制作测试有利于:

  1. 单元测试有助于在应用开发早期修复错误,避免日后被攻击造成亏损。

  2. 有助于开发人员理解测试代码库,以便做出修改。

  3. 高质量的单元测试可以作为项目(指南)文档。

明白了写测试的重要性后,让我们深入了解一下如何为 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 写测试。从这段代码中,你可以发现关于编写单元测试:

  1. 所有的测试用例都是外部函数,并以 test_ 为前缀。

  2. 在这里没有给函数传递参数,因为我们手动提供了所有需要的测试参数。

  3. 可以使用 assert 关键字更容易进行比较。

注意:在 Cairo 中使用 assert 关键字,如果左边的变量还没有设置,就会自动把右边的变量分配给左边的变量,因此安全的做法是确保我们要比较的常数总是在左边。

为了进一步解释这个问题,假设我们有一个常数。

const NUMBER = 30;

我们想获得一个函数的返回值并检查它是否等于常数,首先确保常数在左边,如果函数返回一个空参数,我们不想让 Cairo 分配常数。

所以我们需要改写:

let (num) = get_number();
assert NUMBER = num;

设置钩子

在测试用例之前需要进行某些操作,比如部署一个合约并记录其地址,设置一些重要变量等。

类似于在 mochachai 中使用的 before 钩子 (Hook),我们可以在 protostar 中使用 setup 钩子预先在名叫 context 的存储变量中设置一些变量,并将它们从一个函数传递到另一个函数。

例如,我们可以使用设置钩子来部署我们在上一篇文章中的 starknet 合约,并将合约地址存储在上下文中,然后传递给其他测试案例:

@external
func __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 合约中!

你可以在这里找到全部的,但为了控制篇幅,我们只介绍今天用到的四个:

  1. deploy_contract

  2. expect_revert

  3. expect_events

  4. start_prank

deploy_contract

这个作弊代码部署一个合同,输入合同的相对路径和构造函数参数(如果有的话)。

要使用这个作弊代码,我们要传入合同代码的相对路径,以及构造函数的参数:

%{ deploy_contract("./src/starknet.cairo", [322918500091226412576622]) %}

由于部署合约的过程通常很慢,建议你在设置钩子中使用这个作弊代码,这样你只需要执行一次这个动作。deploy_contract 作弊代码还可以访问已部署合同的合同地址,可以访问并存储在一个上下文变量中,以便从测试案例中访问。

%{context.address = deploy_contract("./src/starknet.cairo", [[ids.NAME](http://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](http://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_prankstop_prank() 之间的任何函数调用将使用指定地址作为调用者地址。

编写我们的第一个测试

哇,我们已经讲了很多了。现在是时候实践知识了,为我们上一篇文章中的 Starknet 合约写一个测试。

你也可以查看合约代码

测试分为五个部分检测我们到目前为止所学的所有知识。

  1. 指明必要的导入。

  2. 指明整个测试所需的一些常量。

  3. 使用钩子部署我们的合约。

  4. 测试 store_name 函数。

  5. 测试 get_name 函数。

指明必要的导入

对于这个测试,我们将导入 HashBuiltin 库函数,以及我们想在 Starknet 合约中运行测试的所有函数(store_nameget_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_name
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 ();
}
@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 的预言机。如果觉得本教程对你有帮助,转发分享给其他人吧~

Subscribe to Starknet 中文
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.