Foundry 单元测试

Foundry 单元测试

本文将描述如何使用 Foundry 在 Solidity 中创建单元测试。我们将涵盖如何测试智能合约可能发生的所有状态转换,以及 Foundry 提供的一些额外有用的功能。Foundry 具有非常广泛的测试能力,因此我们不再重复文档内容,而是专注于你大多数时间会使用的部分。

本文假设你已经对 Solidity 很熟悉。如果不是,请查看免费 Solidity 学习教程

安装 Foundry

如果你还没有安装 Foundry,请按照此处的说明进行操作:Foundry 安装

Foundry Hello World

只需运行以下命令,它将为你设置环境,创建测试并运行它们。(当然,这假设你已经安装了 Foundry)。

forge init
forge test

Solidity 测试最佳实践

无论使用何种框架,solidity 单元测试的质量取决于三个因素:

  • 行覆盖率
  • 分支覆盖率,以及
  • 完全定义的状态转换。

通过理解每一个因素,我们可以说明为何我们专注于 Foundry API 的某些方面。

当然,不可能为所有可能的输入输出范围编写文档。然而,测试质量通常与行覆盖率、分支覆盖率和定义的状态转换相关。在我们的另一篇文章中,我们已经记录了如何使用 Foundry 测量行和分支覆盖率 。我们将在此解释这三个度量指标的重要性:

1. 行覆盖率

行覆盖率就是它字面上的意思。如果代码行在测试中未被执行,则行覆盖率不是 100%。如果代码行从未被执行过,你无法确定它是否按预期工作或会抛出异常。在智能合约中没有不实现 100%行覆盖率的理由。如果你在编写代码,这意味着你期望它在未来的某个时候被执行,那么为什么不对其进行测试呢?

2. 分支覆盖率

即使每一行都被执行了,也不意味着每一种智能合约业务逻辑的变化都被测试了。

考虑以下函数:

function changeOwner(address newOwner) external {
    require(msg.sender == owner, "onlyOwner");
    owner = newOwner;
}

如果通过调用此地址的所有者来测试它,你将获得 100%的行覆盖率,但不会获得 100%的分支覆盖率。这是因为 require 语句和所有者分配都被执行了,但 require 抛出异常的情况没有被测试。

这里是一个更微妙的例子。

// @notice anyone can pay off someone else's loan
// @param debtor the person who's loan the sender is making a payment for
function payDownLoan(address debtor) external payable {
    uint256 loanAmount = loanAmounts[debtor];
    require(loanAmount > 0, "no such loan");

    if (msg.value >= debtAmount) {
        loanAmounts[debtor] = 0;
        emit LoanFullyRepaid(debtor);
    } else {
        emit LoanPayment(debtor, debtAmount, msg.value);
        loanAmount -= msg.value;
    }

    if (msg.value > loanAmount) {
        msg.sender.call{value: msg.value - loanAmount}("");
    }
}

在这种情况下有多少个分支需要测试?

  1. 贷款为零的情况
  2. 某人支付少于贷款金额的情况
  3. 某人支付正好等于贷款金额的情况
  4. 某人支付超过贷款金额的情况

通过发送多于或少于贷款金额的以太币,可以在此测试中获得 100%的行覆盖率。这将执行 if else 语句的两个分支以及最后的 if 语句但这不会测试贷款正好付清为零的 else 语句。

你的函数分支越多,单元测试它们就越困难。技术术语为圈复杂度

3. 完全定义的状态转换

高质量的 solidity 单元测试尽可能详细地记录状态转换。状态转换包括:

  • 存储变量的改变
  • 合约的部署或自毁
  • 以太余额的变化
  • 事件的触发,带有某些消息
  • 交易的回退,带有某些错误消息

如果函数执行了这些动作,修改状态的确切方式应该在单元测试中被捕获,任何偏差都应导致回退。这样,任何意外的修改,无论多么微小,都会自动被捕捉。回到之前的例子,应测试哪些状态转换?

  • 合约中的 Ether 增加了借款人偿还贷款的等量
  • 跟踪贷款金额的存储变量按预期金额减少
  • 当发送者为不存在的贷款付款时,出现预期的错误消息
  • 触发相应的事件和相关消息

如果智能合约的业务逻辑发生变化,测试应失败。在其他领域,这通常被认为是一个“脆弱”的单元测试。它可能会减慢源代码的迭代速度。但 Solidity 代码通常是一次编写且永不更改,因此这对智能合约测试来说不是问题。

4. 单元测试最佳实践结论

在记录 Foundry 单元测试工作原理之前,为什么我们要覆盖所有这些?因为这可以帮助我们隔离大多数情况下会使用的高影响测试工具。Foundry 的功能非常广泛,但多数测试用例中只会用到一小部分。

Foundry 断言

为了确保状态转换确实发生,你将需要断言。让我们从你调用forge init后 Foundry 提供的默认测试文件开始。

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/Counter.sol";

contract CounterTest is Test {
    Counter public counter;

    function setUp() public {
        counter = new Counter();
        counter.setNumber(0);
    }

    function testIncrement() public {
        counter.increment();
        assertEq(counter.number(), 1);
    }

    function testSetNumber(uint256 x) public {
        counter.setNumber(x);
        assertEq(counter.number(), x);
    }
}

setUp() 函数部署你正在测试的合约(以及生态系统中的其他合约)。

任何以test开头的函数将被执行为单元测试。不以test开头的函数将不会被执行,除非它们被testsetUp函数调用。

这里 可以找到你可以使用的断言。

你最常用的有:

  • assertEq,断言相等
  • assertLt,断言小于
  • assertLe,断言小于或等于
  • assertGt,断言大于
  • assertGe,断言大于或等于
  • assertTrue,断言为真

前两个传递给 assert 的参数是比较内容,但你也可以添加一个作为第三个参数的帮助错误信息,你应该总是这样做(尽管默认示例没有显示)。以下是推荐的写断言的方式:

function testIncrement() public {
        counter.increment();
        assertEq(counter.number(), 1, "expect x to equal to 1");
}

function testSetNumber(uint256 x) public {
        counter.setNumber(x);
        assertEq(counter.number(), x, "x should be setNumber");
}

使用 Foundry vm.prank 修改 msg.sender

Foundry 更有趣的方法来改变发送者(账户或钱包)是 vm.prank API(Foundry 称之为作弊码)。

这是一个最小的示例

function testChangeOwner() public {
    vm.prank(owner);
    contractToTest.changeOwner(newOwner);
    assertEq(contractToTest.owner(), newOwner);
}

vm.prank 仅对紧随其后的事务有效。如果你想使用同一个地址进行一系列交易,请使用 vm.startPrank 并在结束后使用 vm.stopPrank

function testMultipleTransactions() public {
    vm.startPrank(owner);
    // 表现为所有者
    vm.stopPrank();
}

在 Foundry 中定义账户和地址

上面的 owner 变量可以用几种方式定义:

// 将十进制转换为地址创建的地址
address owner = address(1234);

// vitalik 的地址
address owner = 0x0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045;

// 从已知私钥创建一个地址;
address owner = vm.addr(privateKey);

// 创建一个攻击者
address hacker = 0x00baddad;

msg.sender 和 tx.origin 的恶作剧

在上述示例中,msg.sender 被更改。如果你想同时控制 tx.originmsg.sendervm.prankvm.startPrank 都可以选择性地接受两个参数,其中第二个参数是 tx.origin

vm.prank(msgSender, txOrigin);

依赖于 tx.origin 通常是一个坏习惯,所以你很少需要使用带两个参数版本的 vm.prank

检查余额

当你转移以太币时,你应该测量余额是否按预期变化。值得庆幸的是,在 Foundry 中检查余额很容易,因为它是用 Solidity 编写的。

考虑这个合约:

contract Deposit {

    event Deposited(address indexed);

    function buyerDeposit() external payable {
        require(msg.value == 1 ether, "incorrect amount");
        emit Deposited(msg.sender);
    }

    // 逻辑的其他部分
}

测试函数如下所示。

function testBuyerDeposit() public {
    uint256 balanceBefore = address(depositContract).balance;
    depositContract.buyerDeposit{value: 1 ether}();
    uint256 balanceAfter = address(depositContract).balance;

    assertEq(balanceAfter - balanceBefore, 1 ether, "expect increase of 1 ether");
}

请注意,我们没有测试买家发送的金额不是 1 以太币的情况,这会导致回滚。我们将在下一节讨论测试回滚。

使用 vm.expectRevert 预计回滚

当前形式的上述测试的问题在于,你可以删除 require 语句,测试仍然会通过。让我们改进测试,以使删除 require 语句会导致测试失败。

function testBuyerDepositWrongPrice() public {
    vm.expectRevert("incorrect amount");
    depositContract.deposit{value: 1 ether + 1 wei}();

    vm.expectRevert("incorrect amount");
    depositContract.deposit{value: 1 ether - 1 wei}();
}

请注意,必须在我们预计要回滚的函数调用之前立即调用 vm.expectRevert。现在,如果我们删除 require 语句,它将回滚,因此我们更好地模拟了智能合约的预期功能。

测试自定义错误

如果我们使用自定义错误而不是 require 语句,测试回滚的方法如下:

contract CustomErrorContract {
    error SomeError(uint256);

    function revertError(uint256 x) public pure {
        revert SomeError(x);
    }
}

测试文件如下所示:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/RevertCustomError.sol";

contract CounterTest is Test {
    CustomErrorContract public customErrorContract;
    error SomeError(uint256);

    function setUp() public {
        customErrorContract = new CustomErrorContract();
    }

    function testRevert() public {
        // 5 是一个任意示例
        vm.expectRevert(abi.encodeWithSelector(SomeError.selector, 5));
        customErrorContract.revertError(5);
    }
}

在我们的示例中,我们创建了一个参数化的自定义错误。为了使测试通过,参数需要等于在回滚期间实际使用的参数。

使用 vm.expectEvent 测试日志和事件

虽然 solidity 事件 不会改变智能合约的功能,但错误地实现它们会破坏读取智能合约状态的客户端应用程序。为了确保我们的事件按预期工作,我们可以使用 vm.expectEmit。这个 API 的行为相当反直觉,因为你必须在测试中发出事件,以确保它在智能合约中工作。

这是一个最小的示例。

function testBuyerDepositEvent() public {
    vm.expectEmit();
    emit Deposited(buyer);

    depositContract.deposit{value: 1 ether}();
}

使用 vm.warp 调整 block.timestamp

现在让我们考虑一个时间锁定的提现。卖方可以在 3 天后提现付款。

contract Deposit {

    address public seller;
    mapping(address => uint256) public depositTime;

    event Deposited(address indexed);
    event SellerWithdraw(address indexed, uint256 indexed);

    constructor(address _seller) {
        seller = _seller;
    }

    function buyerDeposit() external payable {
        require(msg.value == 1 ether, "incorrect amount");
        uint256 _depositTime = depositTime[msg.sender];
        require(_depositTime == 0, "already deposited");
        depositTime[msg.sender] = block.timestamp;

        emit Deposited(msg.sender);
    }

    function sellerWithdraw(address buyer) external {
        require(msg.sender == seller, "not the seller");
        uint256 _depositTime = depositTime[buyer];
        require(_depositTime != 0, "buyer did not deposit");
        require(block.timestamp - _depositTime > 3 days, "refund period not passed");
        delete depositTime[buyer];

        emit SellerWithdraw(buyer, block.timestamp);
        (bool ok, ) = msg.sender.call{value: 1 ether}("");
        require(ok, "seller did not withdraw");
    }
}

我们添加了许多需要测试的功能,但现在让我们重点放在时间方面。

我们想测试卖方在存款后的 3 天内不能提取资金。(显然缺少一个买方在该时间窗口前提取的函数,但我们稍后会讨论)。

请注意,block.timestamp 默认从 1 开始。这不是一个实际的测试数字,因此我们应该首先转换到当前日期。

可以使用 vm.warp(x) 来实现这个功能,但我们可以更讲究地使用修饰符。

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/Deposit.sol";

contract DepositTest is Test {
    Deposit public deposit;
    Deposit public faildeposit;
    address constant SELLER = address(0x5E11E7);
    //address constant Rejector = address(RejectTransaction);
    RejectTransaction private rejector;

    event Deposited(address indexed);
    event SellerWithdraw(address indexed, uint256 indexed);

    function setUp() public {
        deposit = new Deposit(SELLER);
        rejector = new RejectTransaction();
        faildeposit = new Deposit(address(rejector));
    }

    modifier startAtPresentDay() {
        vm.warp(1680616584);
        _;
    }

    address public buyer = address(this); // DepositTest 合约即为“买家”
    address public buyer2 = address(0x5E11E1); // 随机地址
    address public FakeSELLER = address(0x5E1222); // 随机地址

    function testDepositAmount() public startAtPresentDay {
        // 此测试检查买家只能存入 1 ether
        vm.startPrank(buyer);
        vm.expectRevert();
        deposit.buyerDeposit{value: 1.5 ether}();
        vm.expectRevert();
        deposit.buyerDeposit{value: 2.5 ether}();
        vm.stopPrank();
    }
}

使用 vm.roll 调整 block.number

如果你想在 Foundry 中调整区块号 (block.number),使用

vm.roll(blockNumber)

来改变区块号。要向前移动一定数量的区块,请执行以下操作

vm.roll(block.number() + numberOfBlocks)

添加额外的测试

为了完整性,让我们为其余的功能编写单元测试。一些额外的功能需要为存款功能进行测试:

  • 公共变量 depositTime 与交易时间匹配
  • 用户不能重复存款

以及卖家功能的测试:

  • 卖家不能为不存在的地址提款
  • 买家的条目被删除(这允许买家重新购买)
  • 触发 SellerWithdraw 事件
  • 合约的余额减少 1 ether
  • 不是卖家的地址调用 sellerWithdraw 会被回滚
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/Deposit.sol";

contract DepositTest is Test {
    Deposit public deposit;
    Deposit public faildeposit;
    address constant SELLER = address(0x5E11E7);
    //address constant Rejector = address(RejectTransaction);
    RejectTransaction private rejector;

    event Deposited(address indexed);
    event SellerWithdraw(address indexed, uint256 indexed);

    function setUp() public {
        deposit = new Deposit(SELLER);
        rejector = new RejectTransaction();
        faildeposit = new Deposit(address(rejector));
    }

    modifier startAtPresentDay() {
        vm.warp(1680616584);
        _;
    }

    address public buyer = address(this); // DepositTest 合约即为“买家”
    address public buyer2 = address(0x5E11E1); // 随机地址
    address public FakeSELLER = address(0x5E1222); // 随机地址

    function testDepositAmount() public startAtPresentDay {
        // 此测试检查买家只能存入 1 ether
        vm.startPrank(buyer);
        vm.expectRevert();
        deposit.buyerDeposit{value: 1.5 ether}();
        vm.expectRevert();
        deposit.buyerDeposit{value: 2.5 ether}();
        vm.stopPrank();
    }

    function testBuyerDepositSellerWithdrawAfter3days() public startAtPresentDay {
        // 此测试检查买家存款后 3 天,卖家能够提款

        // 买家存款 1 ether
        vm.startPrank(buyer); // msg.sender == buyer
        deposit.buyerDeposit{value: 1 ether}();
        assertEq(address(deposit).balance, 1 ether, "Contract balance did not increase"); // 检查合约的余额是否增加
        vm.stopPrank();

        // 三天后卖家提款
        vm.startPrank(SELLER); // msg.sender == SELLER
        vm.warp(1680616584 + 3 days + 1 seconds);
        deposit.sellerWithdraw(address(this));
        assertEq(address(deposit).balance, 0 ether, "Contract balance did not decrease"); // 检查合约的余额是否减少
    }

    function testBuyerDepositSellerWithdrawBefore3days() public startAtPresentDay {
        // 此测试检查买家存款后 3 天,卖家能够提款

        // 买家存款 1 ether
        vm.startPrank(buyer); // msg.sender == buyer
        deposit.buyerDeposit{value: 1 ether}();
        assertEq(address(deposit).balance, 1 ether, "Contract balance did not increase"); // 检查合约的余额是否增加
        vm.stopPrank();

        // 三天前卖家提款
        vm.startPrank(SELLER); // msg.sender == SELLER
        vm.warp(1680616584 + 2 days);
        vm.expectRevert(); // 预期会回滚
        deposit.sellerWithdraw(address(this));
    }

    function testdepositTimeMatchesTimeofTransaction() public startAtPresentDay {
        // 此测试检查公共变量 depositTime 是否与交易时间匹配

        vm.startPrank(buyer); // msg.sender == buyer
        deposit.buyerDeposit{value: 1 ether}();
        // 检查它是否存入于正确的时间
        assertEq(
            deposit.depositTime(buyer),
            1680616584, // startAtPresentDay 的时间
            "Time of Deposit Doesnt Match"
        );
        vm.stopPrank();
    }

    function testUserDepositTwice() public startAtPresentDay {
        // 此测试检查用户不能重复存款 

        vm.startPrank(buyer); // msg.sender == buyer
        deposit.buyerDeposit{value: 1 ether}();

        vm.warp(1680616584 + 1 days); // 一天后...
        vm.expectRevert();
        deposit.buyerDeposit{value: 1 ether}(); // 应该回滚因为未到 3 天
    }

    function testNonExistantContract() public startAtPresentDay {
        // 此测试检查卖家不能为不存在的地址提款

        vm.startPrank(SELLER); // msg.sender == SELLER
        vm.expectRevert();
        deposit.sellerWithdraw(buyer); 
    }

    function testBuyerBuysAgain() public startAtPresentDay {
        // 此测试检查买家的条目是否被删除(这允许买家重新购买)

        vm.startPrank(buyer); // msg.sender == buyer
        deposit.buyerDeposit{value: 1 ether}();
        vm.stopPrank();

        // 卖家提款
        vm.warp(1680616584 + 3 days + 1 seconds);
        vm.startPrank(SELLER); // msg.sender == SELLER
        deposit.sellerWithdraw(buyer);
        vm.stopPrank();
// 检查 depositTime[buyer] == 0
assertEq(deposit.depositTime(buyer), 0, "买家的条目未被删除");

// 买家再次存款
vm.startPrank(buyer); // msg.sender == buyer
vm.expectEmit();
emit Deposited(buyer);
deposit.buyerDeposit{value: 1 ether}();
vm.stopPrank();
}

function testSellerWithdrawEmitted() public startAtPresentDay {
// 此测试检查 SellerWithdraw 事件是否被触发

// buyer2 存款
vm.deal(buyer2, 1 ether); // msg.sender == buyer2
vm.startPrank(buyer2);
vm.expectEmit(); // 检查 Deposited 事件
emit Deposited(buyer2);
deposit.buyerDeposit{value: 1 ether}();
vm.stopPrank();

vm.warp(1680616584 + 3 days + 1 seconds); // 3 天 1 秒后...

// 卖家提款 + 检查 SellerWithdraw 事件是否被触发
vm.startPrank(SELLER); // msg.sender == SELLER
vm.expectEmit(); // 期望 SellerWithdraw 事件被触发
emit SellerWithdraw(buyer2, block.timestamp);
deposit.sellerWithdraw(buyer2);
vm.stopPrank();
}

function testFakeSeller2Withdraw() public startAtPresentDay {
// 买家存款
vm.startPrank(buyer);
vm.deal(buyer, 2 ether); // 该合约地址是买家
deposit.buyerDeposit{value: 1 ether}();
vm.stopPrank();
assertEq(address(deposit).balance, 1 ether, "以太存款失败");

vm.warp(1680616584 + 3 days + 1 seconds); // 3 天 1 秒后...

vm.startPrank(FakeSELLER); // msg.sender == FakeSELLER
vm.expectRevert();
deposit.sellerWithdraw(buyer);
vm.stopPrank();
}

function testRejectedWithdrawl() public startAtPresentDay {
// 此测试检查买家的条目是否被删除(这允许买家再次购买)

vm.startPrank(buyer); // msg.sender == buyer
faildeposit.buyerDeposit{value: 1 ether}();
vm.stopPrank();
assertEq(address(faildeposit).balance, 1 ether, "断言失败");

vm.warp(1680616584 + 3 days + 1 seconds); // 3 天 1 秒后...

vm.startPrank(address(rejector)); // msg.sender == rejector
vm.expectRevert();
faildeposit.sellerWithdraw(buyer);
vm.stopPrank();
}

测试失败的以太转账

测试买家提款需要额外的技巧来获得完整的行覆盖率。以下是我们正在测试的代码片段,我们将在上面的代码中解释 Rejector 合约。

function buyerWithdraw() external {
    uint256 _depositTime = depositTime[msg.sender];
    require(_depositTime != 0, "sender did not deposit");
    require(block.timestamp - _depositTime <= 3 days);

    emit BuyerRefunded(msg.sender, block.timestamp);

    // 这是我们正在测试的分支
    (bool ok,) = msg.sender.call{value: 1 ether}("");
    require(ok, "Failed to withdraw");
}

为了测试 require(ok…) 的失败条件,我们需要让以太转账失败。测试通过创建一个调用 buyerWithdraw 函数的智能合约来实现这一点,但其 receive 函数设置为 revert

Foundry 模糊测试

虽然我们可以指定一个不是卖家的任意地址来测试未授权地址提款的 revert,但尝试许多不同的值更令人放心。

如果我们为测试函数提供参数,Foundry 将尝试许多不同的参数值。为了防止它使用不适用于测试用例的参数(例如当地址被授权时),我们将使用 vm.assume。以下是如何测试未授权卖家的卖家提款。

// notSeller 将被随机选择
function testInvalidSellerAddress(address notSeller) public {
    vm.assume(notSeller != seller);

    vm.expectRevert("not the seller");
    depositContract.sellerWithdraw(notSeller);
}

以下是所有的状态转换

  • 合约的 balance 减少了 1 ether
  • BuyerRefunded 事件被触发
  • 买家可以在三天内退款

以下是需要测试的分支

  • 买家不能在 3 天后提款
  • 买家如果从未存款则不能提款

Console.log Foundry

要在 Foundry 中使用 console.log,请导入以下内容

import "forge-std/console.sol";

并使用以下命令运行测试

forge test -vv

测试签名

请参阅我们关于 solidity 签名验证 的教程,因此我们建议你参考该教程。

Solidity 测试内部函数

请参阅我们关于 solidity 测试内部函数 的教程。

使用 vm.deal 和 vm.hoax 设置地址余额

作弊码 vm.hoax 允许你同时恶作剧一个地址并设置其余额。

vm.hoax(addressToPrank, balanceToGive);
// 下一个调用是 addressToPrank 的恶作剧

vm.deal(alice, balanceToGive);

Foundry 的一些常见错误

在接收以太时没有回退函数

如果你正在测试从合约中提取以太,它将被发送到运行测试的合约。Foundry 测试本身是一个智能合约,如果你将以太发送到没有 fallbackreceive 函数的智能合约,则交易将失败。确保在合约中有一个 fallbackreceive 函数。

在接收代币时没有 onERC…Received

同样,ERC-721 safeTransferFrom 和 ERC-1155 transferFrom 在将代币发送到没有适当传输钩子函数的智能合约时会回滚。如果你想测试将 NFT(或类似 ERC777 的代币)转移给自己,你需要将其添加到测试中。

总结

  • 目标是 100% 的行和分支覆盖率
  • 完全定义预期的状态转换
  • 在断言中使用错误消息

了解更多

要了解超越单元测试和基本模糊测试的高级 solidity 测试,请参阅高级 solidity bootcamp

中文开发者,欢迎学习 区块链技术集训营

作者

本文由 Aymeric Taylor(LinkedIn, Twitter),RareSkills 的研究实习生共同撰写。

最初发布于 2023 年 4 月 11 日

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/