Diamond

發布於 2022-11-01到 Mirror 閱讀

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 的预言机。如果觉得本教程对你有帮助,转发分享给其他人吧~