本文将介绍如何使用 Foundry 在 Solidity 中创建单元测试。我们将涵盖如何测试智能合约中可能发生的所有状态转换(state transitions),以及 Foundry 提供的一些其他有用特性。Foundry 具有非常广泛的测试功能,因此我们不会重复文档中的内容,而是重点关注你大部分时间会用到的部分。
本文假设你已经对 Solidity 相当熟悉。如果还不熟悉,请查阅我们免费的 Solidity 学习教程。
安装 Foundry
如果你尚未安装 Foundry,请按照此处的说明进行操作:https://book.getfoundry.sh/getting-started/installation
作者
本文由 RareSkills 的研究实习生 Aymeric Taylor(LinkedIn,Twitter)共同撰写。
Foundry 的 Hello World
只需运行以下命令,它就会为你设置环境、创建测试并运行它们。(当然,前提是你已经安装了 Foundry)。
forge init
forge test
Solidity 测试最佳实践
无论使用什么框架,Solidity 单元测试的质量取决于三个因素:
- 行覆盖率(Line coverage)
- 分支覆盖率(branch coverage)
- 完全定义的状态转换(completely defined state transitions)
通过理解其中的每一个因素,我们就能明白为什么我们要重点关注 Foundry API 的某些方面。
当然,不可能记录下每种输入范围对应的所有可能输出。然而,测试质量通常与行覆盖率、分支覆盖率以及状态转换的定义息息相关。在我们的另一篇文章中,我们记录了如何使用 Foundry 测量行覆盖率和分支覆盖率。我们将在下面解释这三个指标的意义:
1. 行覆盖率
行覆盖率顾名思义。如果在测试期间有一行代码没有被执行,那么行覆盖率就不是 100%。如果一行代码从未被执行过,你就无法确定它是否会按预期工作或发生 revert。对于智能合约来说,没有充分的理由不追求 100% 的行覆盖率。既然你写了这段代码,就意味着你期望它在未来的某个时刻被执行,那为什么不测试它呢?
2. 分支覆盖率
即使每一行代码都被执行了,也不意味着智能合约业务逻辑的每一种变化都被测试到了。
考虑以下函数:
function changeOwner(address newOwner) external {
require(msg.sender == owner, "onlyOwner");
owner = newOwner;
}
如果你通过 owner 调用来测试这个地址,你将获得 100% 的行覆盖率,但得不到 100% 的分支覆盖率。这是因为 require 语句和 owner 赋值都执行了,但是 require 发生 revert 的情况却没有被测试到。
这里有一个更微妙的例子。
// @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}("");
}
}
在这种情况下,有多少个分支需要测试?
- 贷款为零的情况
- 某人支付金额小于贷款规模的情况
- 某人支付金额恰好等于贷款规模的情况
- 某人支付金额大于贷款规模的情况
在这个测试中,如果你发送比贷款规模更多的 ether 和比贷款规模更少的 ether,是有可能获得 100% 行覆盖率的。这会执行 if else 的两个分支,以及最后面的 if 语句。但这并没有测试到贷款被精确偿还至零时的 else 语句。
你的函数拥有的分支越多,对它们进行单元测试的难度呈指数级增加。这在技术上的专有名词叫做圈复杂度(cyclomatic complexity)。
3. 完全定义的状态转换
优质的 Solidity 单元测试会尽可能详尽地记录状态转换。状态转换包括:
- storage 变量的改变
- 合约被部署或自毁(self-destructed)
- ether 余额的变化
- 包含特定消息的事件(events)被触发
- 交易发生 revert,并伴随特定的错误消息
如果一个函数执行了上述任何操作,它修改状态的确切方式应该被捕获在单元测试中,任何偏差都应导致 revert。这样一来,任何意外的修改(无论多么微小)都会被自动捕获。
回到前面的例子,应该测量哪些状态转换?
- 合约中的 Ether 增加的金额等于借款人偿还贷款的金额
- 追踪贷款规模的 storage 变量按预期金额减少
- 当发送者为不存在的贷款付款时,发生 revert 并显示预期的错误消息
- 触发相应的事件(events)和相关消息
如果智能合约中的业务逻辑发生改变,测试应该会失败。通常情况下,这在其他领域被认为是“脆弱的”单元测试,它可能会影响源代码迭代的速度。但是 Solidity 代码的设计初衷就是编写一次,永不更改,因此对于智能合约测试来说,这不是问题。
4. 单元测试最佳实践总结
为什么我们在记录 Foundry 单元测试如何工作之前要介绍所有这些内容?因为这有助于我们分离出你大部分时间都会使用的高影响力测试工具。Foundry 的功能非常庞大,但在绝大多数测试用例中,只会用到其中的一小部分。
Foundry 断言(Asserts)
为了确保状态转换确实发生,你需要断言(asserts)。让我们从调用 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 开头的函数将不会被执行,除非有 test 或 setUp 函数调用它们。
这里 列出了你可以使用的断言。
你最常使用的断言是:
assertEq,断言相等assertLt,断言小于assertLe,断言小于或等于assertGt,断言大于assertGe,断言大于或等于assertTrue,断言为真
断言的前两个参数用于比较,但你也可以添加一条有用的错误消息作为第三个参数,你应该始终这样做(尽管默认示例没有展示这一点)。这是建议的断言写法:
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 称之为作弊码 cheatcode)。
这是一个最小化示例:
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);
// behave as owner
vm.stopPrank();
}
在 Foundry 中定义账户和地址
上面的 owner 变量可以通过几种方式定义:
// an address created by casting a decimal to an address
address owner = address(1234);
// vitalik's addresss
address owner = 0x0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045;
// create an address from a known private key;
address owner = vm.addr(privateKey);
// create an attacker
address hacker = 0x00baddad
msg.sender 和 tx.origin 伪造 (prank)
在上面的示例中,msg.sender 被更改了。如果你想同时专门控制 tx.origin 和 msg.sender,vm.prank 和 vm.startPrank 均可选择接收两个参数,其中第二个参数就是 tx.origin。
vm.prank(msgSender, txOrigin);
依赖 tx.origin 通常是一种不良的实践,所以你很少需要使用带两个参数版本的 vm.prank。
检查余额
当你转账 ether 时,你应该测量余额是否按预期发生了变化。幸运的是,在 Foundry 中检查余额很容易,因为它是用 Solidity 编写的。
考虑这个合约:
contract Deposit {
event Deposited(address indexed);
function buyerDeposit() external payable {
require(msg.value == 1 ether, "incorrect amount");
emit Deposited(msg.sender);
}
// rest of the logic
}
测试函数会是这样的:
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 ether 的情况(这会导致 revert)。我们将在下一节讨论如何测试 revert。
使用 vm.expectRevert 预期 revert
上述测试当前形式的问题在于,你即使删除 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 必须在执行预期会发生 revert 的函数之前紧接着调用。现在如果我们删除 require 语句,测试将会 revert,这样我们就能更好地模拟智能合约的预期功能。
测试自定义错误
如果我们使用自定义错误(custom errors)代替 require 语句,测试 revert 的方法将如下所示:
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 is an arbitrary example
vm.expectRevert(abi.encodeWithSelector(SomeError.selector, 5));
customErrorContract.revertError(5);
}
}
在我们的例子中,我们创建了一个参数化的自定义错误。要让测试通过,该参数必须等于在发生 revert 时实际使用的那个参数。
使用 vm.expectEvent 测试日志和事件
尽管 Solidity 事件(events) 不会改变智能合约的功能,但错误地实现它们可能会破坏读取智能合约状态的客户端应用程序。为了确保我们的事件按预期工作,我们可以使用 vm.expectEmit。这个 API 的行为相当违反直觉,因为你必须在测试中触发(emit)该事件,以确保它在智能合约中正常执行了。
这是一个最小化示例:
function testBuyerDepositEvent() public {
vm.expectEmit();
emit Deposited(buyer);
depositContract.deposit{value: 1 ether}();
}
使用 vm.warp 调整 block.timestamp
现在让我们考虑一个时间锁定的提款(time locked withdrawal)。卖家可以在 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 开始。这并不是一个用于测试的现实数字,因此我们应该首先调整(warp)到当前的日期。
这可以通过 vm.warp(x) 来实现,但为了更巧妙一点,我们来使用一个 modifier。
// 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); // the DepositTest contract is the "buyer"
address public buyer2 = address(0x5E11E1); // random address
address public FakeSELLER = address(0x5E1222); // random address
function testDepositAmount() public startAtPresentDay {
// this test checks that the buyer can only deposit 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)
添加额外的测试
为了保证完整性,让我们编写其余函数的单元测试。
deposit 函数还有一些额外的特性需要被测试:
- 公开(public)变量
depositTime与交易时间相匹配 - 用户不能存款两次
对于卖家函数:
- 卖家不能为不存在的地址提款
- 买家的记录被删除(这允许买家再次购买)
- 触发了
SellerWithdraw事件 - 合约余额减少了 1 ether
- 非卖家地址调用
sellerWithdraw会发生 revert
// 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); // the DepositTest contract is the "buyer"
address public buyer2 = address(0x5E11E1); // random address
address public FakeSELLER = address(0x5E1222); // random address
function testDepositAmount() public startAtPresentDay {
// this test checks that the buyer can only deposit 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 {
// This test checks that the seller is able to withdraw 3 days after the buyer deposits
// buyer deposits 1 ether
vm.startPrank(buyer); // msg.sender == buyer
deposit.buyerDeposit{value: 1 ether}();
assertEq(address(deposit).balance, 1 ether, "Contract balance did not increase"); // checks to see if the contract balance increases
vm.stopPrank();
// after three days the seller withdraws
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"); // checks to see if the contract balance decreases
}
function testBuyerDepositSellerWithdrawBefore3days() public startAtPresentDay {
// This test checks that the seller is able to withdraw 3 days after the buyer deposits
// buyer deposits 1 ether
vm.startPrank(buyer); // msg.sender == buyer
deposit.buyerDeposit{value: 1 ether}();
assertEq(address(deposit).balance, 1 ether, "Contract balance did not increase"); // checks to see if the contract balance increases
vm.stopPrank();
// before three days the seller withdraws
vm.startPrank(SELLER); // msg.sender == SELLER
vm.warp(1680616584 + 2 days);
vm.expectRevert(); // expects a revert
deposit.sellerWithdraw(address(this));
}
function testdepositTimeMatchesTimeofTransaction() public startAtPresentDay {
// This test checks that the public variable depositTime matches the time of the transaction
vm.startPrank(buyer); // msg.sender == buyer
deposit.buyerDeposit{value: 1 ether}();
// check that it deposits at the right time
assertEq(
deposit.depositTime(buyer),
1680616584, // time of startAtPresentDay
"Time of Deposit Doesnt Match"
);
vm.stopPrank();
}
function testUserDepositTwice() public startAtPresentDay {
// This test checks that a user cannot deposit twice
vm.startPrank(buyer); // msg.sender == buyer
deposit.buyerDeposit{value: 1 ether}();
vm.warp(1680616584 + 1 days); // one day later...
vm.expectRevert();
deposit.buyerDeposit{value: 1 ether}(); // should revert since it hasn't been 3 days
}
function testNonExistantContract() public startAtPresentDay {
// This test checks that the seller cannot withdraw for non-existent addresses
vm.startPrank(SELLER); // msg.sender == SELLER
vm.expectRevert();
deposit.sellerWithdraw(buyer);
}
function testBuyerBuysAgain() public startAtPresentDay {
// This test checks that the entry for the buyer is deleted (this allows the buyer to buy again)
vm.startPrank(buyer); // msg.sender == buyer
deposit.buyerDeposit{value: 1 ether}();
vm.stopPrank();
// seller withdraws
vm.warp(1680616584 + 3 days + 1 seconds);
vm.startPrank(SELLER); // msg.sender == SELLER
deposit.sellerWithdraw(buyer);
vm.stopPrank();
// checks depostitime[buyer] == 0
assertEq(deposit.depositTime(buyer), 0, "entry for buyer is not deleted");
// buyer deposits again
vm.startPrank(buyer); // msg.sender == buyer
vm.expectEmit();
emit Deposited(buyer);
deposit.buyerDeposit{value: 1 ether}();
vm.stopPrank();
}
function testSellerWithdrawEmitted() public startAtPresentDay {
// this test checks that the SellerWithdraw event is emitted
//buyer2 deposits
vm.deal(buyer2, 1 ether); // msg.sender == buyer2
vm.startPrank(buyer2);
vm.expectEmit(); // Deposited Emitter checked
emit Deposited(buyer2);
deposit.buyerDeposit{value: 1 ether}();
vm.stopPrank();
vm.warp(1680616584 + 3 days + 1 seconds);// 3 day and 1 second later...
// seller withdraws + checks SellerWithdraw event emmited or not
vm.startPrank(SELLER); // msg.sender == SELLER
vm.expectEmit(); // expects SellerWithdraw Emitterd
emit SellerWithdraw(buyer2, block.timestamp);
deposit.sellerWithdraw(buyer2);
vm.stopPrank();
}
function testFakeSeller2Withdraw() public startAtPresentDay {
// buyer deposits
vm.startPrank(buyer);
vm.deal(buyer, 2 ether); // this contract's address is the buyer
deposit.buyerDeposit{value: 1 ether}();
vm.stopPrank();
assertEq(address(deposit).balance, 1 ether, "Ether deposited somehow failed");
vm.warp(1680616584 + 3 days + 1 seconds); // 3 day and 1 second later...
vm.startPrank(FakeSELLER); // msg.sender == FakeSELLER
vm.expectRevert();
deposit.sellerWithdraw(buyer);
vm.stopPrank();
}
function testRejectedWithdrawl() public startAtPresentDay {
// This test checks that the entry for the buyer is deleted (this allows the buyer to buy again)
vm.startPrank(buyer); // msg.sender == buyer
faildeposit.buyerDeposit{value: 1 ether}();
vm.stopPrank();
assertEq(address(faildeposit).balance, 1 ether, "assertion failed");
vm.warp(1680616584 + 3 days + 1 seconds); // 3 days and 1 second later...
vm.startPrank(address(rejector)); // msg.sender == rejector
vm.expectRevert();
faildeposit.sellerWithdraw(buyer);
vm.stopPrank();
}
}
测试失败的 ether 转账
测试买家提款需要一个额外的技巧才能获得完整的行覆盖率。下面是我们正在测试的代码片段,随后我们将解释上面代码中的 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);
// this is the branch we are testing
(bool ok,) = msg.sender.call{value: 1 ether}("");
require(ok, "Failed to withdraw");
}
为了测试 require(ok…) 的失败条件,我们需要让 Ether 转账失败。测试实现这一点的方法是创建一个智能合约,该合约调用 buyerWithdraw 函数,但将其 receive 函数设置为 revert。
Foundry 模糊测试
尽管我们可以指定一个任意的非卖家地址来测试未授权地址提款时的 revert,但尝试大量不同的值在心理上会更让人放心。
如果我们为测试函数提供一个参数,Foundry 将尝试为该参数赋予一系列不同的值。为了防止它使用不适用于该测试用例的参数(例如当该地址被授权时的情况),我们会使用 vm.assume。下面是我们如何测试未授权卖家进行卖家提款的方法。
// notSeller will be chosen randomly
function testInvalidSellerAddress(address notSeller) public {
vm.assume(notSeller != seller);
vm.expectRevert("not the seller");
depositContract.sellerWithdraw(notSeller);
}
以下是所有的状态转换:
- 合约的
balance减少 1 ether - 触发了
BuyerRefunded事件 - 买家可以在三天前退款
以下是需要被测试的分支:
- 买家在 3 天后无法撤回提款
- 如果买家从未存过款,则无法提款
在 Foundry 中使用 Console.log
要在 Foundry 中进行 console.log,请引入以下内容:
import "forge-std/console.sol";
并使用以下命令运行测试:
forge test -vv
测试签名
请参考我们关于在 Foundry 中进行 Solidity 签名验证 的教程,因此我们推荐你查阅那篇文章。
测试 Solidity 内部函数 (internal functions)
请参考我们关于 测试 Solidity 内部函数 的教程。
使用 vm.deal 和 vm.hoax 设置地址余额
作弊码 vm.hoax 允许你在伪造(prank)一个地址的同时设置其余额。
vm.hoax(addressToPrank, balanceToGive);
// next call is a prank for addressToPrank
vm.deal(alice, balanceToGive);
Foundry 中的一些常见错误
接收 Ether 时没有 fallback 函数
如果你正在测试从合约中提取 Ether,它将被发送到运行测试的合约中。Foundry 测试本身也是一个智能合约,如果你将 Ether 发送到一个没有 fallback 或 receive 函数的智能合约,那么交易将会失败。请确保在合约中包含了 fallback 或 receive 函数。
接收代币时没有 onERC…Received 钩子
同样地(双关语意),当将代币发送到没有合适转移钩子函数(transfer hook function)的智能合约时,ERC-721 的 safeTransferFrom 和 ERC-1155 的 transferFrom 会发生 revert。如果你想测试将 NFT(或类似 ERC777 的代币)转移给你自己,你需要将该钩子函数添加到你的测试中。
总结
- 目标是实现 100% 的行覆盖率和分支覆盖率
- 完全定义预期的状态转换
- 在你的断言(asserts)中使用错误消息
学习更多测试知识
要学习超越单元测试和基础模糊测试的高级 Solidity 测试,请查阅我们的高级 Solidity Bootcamp。
原文发布于 2023 年 4 月 11 日