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

By [Starknet 中文](https://paragraph.com/@starknet-zh) · 2022-11-02

---

> 作者：[Darington Nnam](https://twitter.com/0xdarlington) 原文：[Journey Through Cairo IX— Ultimate Guide To Testing Your Contracts With Protostar](https://medium.com/@darlingtonnnam/journey-through-cairo-ix-ultimate-guide-to-testing-your-contracts-with-protostar-859bfff3d467) 翻译：[Louis Wang](https://twitter.com/lviswang) 校对：[「StarkNet 中文社区」](https://twitter.com/StarkNet_ZH)

欢迎来到我们的系列文章「Cairo之旅」第九讲！[上一讲](https://mirror.xyz/starknet-zh.eth/oL4RWsgkz-Rbv9CESheqtFjS8zMm7K59hT-_9SVXd2o)我们开始部署 Starknet 合约，今天我们开始测试合约。

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

### 单元测试

![](https://storage.googleapis.com/papyrus_images/ed896cc7c1ff00d37a7d26d40916f7d68266bfdedb897e87c0df9ffa640452fd.png)

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

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

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

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

1.  单元测试有助于在应用开发早期修复错误，避免日后被攻击造成亏损。
    
2.  有助于开发人员理解测试代码库，以便做出修改。
    
3.  高质量的单元测试可以作为项目（指南）文档。
    

![](https://storage.googleapis.com/papyrus_images/b42fbfce49e4ffdde491d5958c70d4e491ec1d2b2f067fd256c4086efef90d9c.png)

明白了写测试的重要性后，让我们深入了解一下如何为 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;
    

### 设置钩子

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

类似于在 **mocha** 和 **chai** 中使用的 **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 合约中！

你可以在[这里](https://docs.swmansion.com/protostar/docs/tutorials/testing/cheatcodes)找到全部的，但为了控制篇幅，我们只介绍今天用到的四个：

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**](https://github.com/Darlington02/blog-first-starknet-contract/blob/master/tests/test_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\_prank** 和 **stop\_prank()** 之间的任何函数调用将使用指定地址作为调用者地址。

### 编写我们的第一个测试

哇，我们已经讲了很多了。现在是时候实践知识了，为我们[上一篇文章](https://medium.com/@darlingtonnnam/journey-through-cairo-viii-writing-and-deploying-your-first-starknet-contract-with-protostar-620ff76062fa)中的 Starknet 合约写一个测试。

你也可以查看[合约代码](https://github.com/Darlington02/blog-first-starknet-contract/blob/master/src/starknet.cairo)。

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

1.  指明必要的导入。
    
2.  指明整个测试所需的一些常量。
    
3.  使用钩子部署我们的合约。
    
4.  测试 **store\_name** 函数。
    
5.  测试 **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**](http://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 写测试合约，以及其他的作弊代码，它们在编写测试时可能非常有用。你也可以在[这里](https://github.com/onlydustxyz/starkonquest/blob/main/contracts/tournament/test_tournament.cairo)找到 OnlyDust 的深度测试脚本，它实现了 Protostar 的大部分作弊代码。

我们将在下节课深入研究 Empiric 的预言机。如果觉得本教程对你有帮助，转发分享给其他人吧~

---

*Originally published on [Starknet 中文](https://paragraph.com/@starknet-zh/cairo-ix-protostar)*
