Foundry Test 调试

在 Foundry 中进行测试时,如果不指定链,Foundry 会自动启动一个 anvil 链。

每次进行Foundry 测试时,都会首先调用 setUp 函数,并且每次调试都不会保留上次调试的数据。,这就涉及到为什么使用prankmakeAddr出来的账户设置为msg.sender(原因之一),以及将vm.deal(user, balance)放在 setUp 函数里面。

forge test 参数

forge test # 进行所有的测试
forge test -vv # 改变可见性,v越多可见性越高
# 如果我们在测试的时候没有为其提供 RPC URL 时,他会启动一个全新的空白 anvil 链进行测试
fork test --fork-url "url" 
# 上述命令中,anvil 被启动,但他会使用 Sepolia RPC URL 的副本来运行,他会启动一个 anvil 链,并模拟所有的交易,就像它们在 Sepolia 链上运行一样,他会假装部署和读取 Sepolia 链上的内容,而不是一个完全空白的链
--fail-fast 快速失败,第一次失败后停止测试
--gas-report 打印 gas 报告
--match-test <正则表达式>,仅运行与指定正则表达式匹配的测试函数,(别名--mt)
--no-match-test <正则表达式>,仅运行与指定正则表达式不匹配的合约测试,(别名--nmt)
--match-contract <正则表达式>,仅运行与指定正则表达式匹配的合约测试,(别名-mc)
--NO-match-contract <正则表达式>,仅运行与指定正则表达式不匹配的合约测试,(别名-nmc)
-f
--fork-url <URL> 通过远程端点获取状态而不是从一个空的状态
--fork-block-number <> 通过远程从特定块号获取状态端点。
-v EVM 的详细程度。
多次传递以增加详细程度(例如 -v、-vv、-vvv)。
详细程度:
- 2:打印所有测试的日志
- 3:打印失败测试的执行跟踪
- 4:打印所有测试的执行跟踪以及设置跟踪对于失败的测试
- 5:打印所有测试的执行和设置跟踪
forge coverage : 可以查看实际测试套件中实际测试了多少代码

forge snapshot

forge snapshot 的作用:创建每个测试的 Gas 使用快照

简单使用方法:

forge snapshot

以下描述来自 Foundry 官方文档:

创建每个测试的 Gas 使用快照。

结果被写入一个名为 .gas-snapshot 的文件中。你可以通过传递 --snap <PATH> 来改变该文件的名称。

在快照中默认包括模糊测试。它们使用一个静态种子来实现确定性的结果。

快照可以用 --diff--check 来比较。第一个标志将输出一个差异,第二个标志将输出一个差异 同时 如果快照不匹配,则以代码 1 退出。

选项&参数

# 选项
--asc 按所用 Gas 对结果进行排序(升序)。
--desc 按所用 Gas 对结果进行排序(降序)。
--min min_gas 只包括使用了超过给定数量的 Gas 的测试。
--max max_gas 只包括使用了小于给定数量的 Gas 的测试。
--diff path
    输出一个与预先存在的快照的差异。
    默认情况下,比较是通过 .gas-snapshot 完成的。
--check path
    与预先存在的快照进行比较,如果它们不匹配,则以代码 1 退出。
    如果快照不匹配,则输出一个差异。
    默认情况下,比较是通过 .gas-snapshot 完成的。
--snap path
    快照的输出文件。默认:.gas-snapshot。
# 参数

其他参数:https://learnblockchain.cn/docs/foundry/i18n/zh/reference/forge/forge-snapshot.html

forge coverage

forge coverage 的作用:计算当前合约测试的覆盖率并输出。

选项&参数

--report允许您指定要使用的报告类型覆盖范围。该标志可以多次使用。

它具有三个不同的选项,并且默认设置为summary。
summary    输出一个图表,显示测试覆盖了代码的百分比。
lcov    创建一个 lcov.info 文件,其中包含您的覆盖范围数据位于项目目录的根目录中。
debug    输出描述未覆盖代码位置的行。

可以联合重定向符号,将覆盖范围数据存储到 txt 文件中。

forge --report debug > coverage.txt

基本测试

在 Foundry 框架中,有一些内置库和工具可以十分方便的测试合约,所以在编写一个测试合约的时候大致框架如下:

pragma solidity ^0.8.0;

import {Test} from "forge-std/Test.sol"

contract Name is Test {
	function setUp() external {}
}
    // 我们可以进行以下四种测试:
    // 1. 单元测试
    //   - 测试代码中的一个特定部分
    // 2. 集成测试
    //   - 测试多个不同合约是否能够正确地协同工作
    // 3. Fork测试(可以认为时单元/集成测试的一部分)
    //   - 模拟在真实环境中对代码进行测试
    // 4. Staging 测试
    //   - 将代码部署到测试网或者主网之类的环境中,并在这个真实环境中运行所有测试以确保事情能够正常工作

简单的测试(从 setUp 开始)

在所有的测试中,第一件都是设置 setUp() 函数,在这里部署合约,然后就可以写需要进行的测试,举例:

pragma solidity ^0.8.0;

import {Test} from "forge-std/Test.sol"

contract Name is Test {
	uint number = 1;
	
	function setUp() external {
		number = 2;
	}
	
	function testDemo() public {
		assertEq(number, 2);
	}
}

测试结果:

image-20241018151430501

console.log 测试、调试

另一种测试和调试的方法是使用console.log,Test是一个很大的库,还有另一个库叫做 console

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import {Test, console} from "forge-std/Test.sol";

contract FundMeTest is Test {

    uint number = 1;

    function setUp() external {
        number = 2;
    }

    function testDemo() external {
        console.log(number);
        console.log("Hello!");
        assertEq(number, 2);
    }

}

Arrange、Act、Assert 测试方法

首先安排测试环境(Arrange),然后考虑要测试的操作(Act),最后进行测试断言(Assert)。

举例:(对 FundMe 合约的 withDrawWithSingleFunder函数 进行调试)

这里的 funded 就是这里举例的 modifier

function testWithDrawWithSignleFunder() public funded {
	// Arrange
	uint256 startingOwnerBalance = fundMe.getOwner().balance;
	uint256 startingFundMeBalance = address(fundMe).balance;
	
	// Act
	vm.prank(fundMe.getOwner());
	fundMe.withdraw();
	
	// Assert
	uint256 endingOwnerBalance = fundMe.getOwner(),balance;
	uint256 endingFundMeBalance = address(fundMe).balancec;
	assertEq(endingFundMeBalance, 0);
	assertEq(
		startingFundMeBalance + startingOwnerBalance,
		endingOwnerBalance
	);
}

模糊测试(fuzz)

测试是否按照预期回滚

测试交易是否会按照预期回滚会使用vm.expectRevert()这个作弊码来实现。具体关于这个作弊码查看Foundry作弊码部分

这里涉及到自定义错误时,可以使用下面的方法:

function testCantEnterWhenRaffleIsCalculating() public {
    vm.prank(PLAYER);
    raffle.enterRaffle{value: entranceFee}();
    vm.warp(block.timestamp + interval + 1);
    vm.roll(block.number + 1);
    raffle.performUpkeep("");

    vm.expectRevert(Raffle.Raffle__RaffleNotOpen.selector);
    vm.prank(PLAYER);
    raffle.enterRaffle{value: entranceFee}();
}

如果自定义错误还有额外的参数,就需要联合使用abi编码中的abi.encodeWithSelector

function testPerformUpKeeperRevertsIfcheckUpkeepIsFalse() public {
    uint256 currentBalance = 0;
    uint256 numPlayers = 0;
    uint256 raffleState = 0;
    vm.expectRevert(
    abi.encodeWithSelector(
    Raffle.Raffle__UpkeepNotNeeded.selector,
    currentBalance,
    numPlayers,
    raffleState
    )
    );
    raffle.performUpkeep("");
}

对于调试时需要多个地址的解决方法:

在使用 Foundry 进行调试时,有些时候会出现需要模拟多个地址来和合约进行交互的场景,这个时候就需要善用一些 Solidity 的特性了。

可以通过利用 for 循环,加上address()uint160 类型的整数转换为地址的方式来实现该过程

举例:

function testWithdrawFromMultipleFunders() public funded {
	uint160 numberOfFunders = 10;
	uint160 startingFunderIndex = 1;
	for (uint160 i = startingFunderIndex; i < numberOfFunders; i++) {
		// vm.parnk new address
		// vm.deal new address
		// address()
		hoax(address(i), SEND_VALUE);
		fundMe.fund{value: SEND_VALUE}();
	}
}

注意:

  • 从 Solidity 0.8.0 版本开始,使用 address() 进行类型转换时,有了更严格的要求,所以要使用 uint160 的形式定义上面的数据
  • for 循环开始遍历时,不能将 0 作为开始,因为0x0地址在进行使用时,有些时候交易会发生回滚revert

Foundry 测试时关于 Gas 相关:

想要知道一个测试花费了多少 Gas,可以使用上面的forge snapshot

关于测试时花费 Gas 相关问题:

在调试过程中使用vm.prank并执行一笔交易时,我们应该花费一定的 Gas ,这与实际链上的 Gas 价格有关,当使用 anvil(Foundry 内置的本地测试链)时,默认情况下,gas 价格会被设置为 0。所以,在 anvil 本地链开发,无论是否分叉,gas 价格实际上都默认为零。为了模拟具有实际 gas 价格的交易,我们需要告诉测试模拟使用的真实的 gas 价格,可以使用一个作弊码txGasPrice 来设置 Gas 的值

示例:

function testWithDrawWithSignleFunder() public funded {
    // Arrange
    uint256 startingOwnerBalance = fundMe.getOwner().balance;
    uint256 startingFundMeBalance = address(fundMe).balance;
    
    // Art
    uint256 gasStart = gasleft(); // 模拟交易开始时剩余的 gas eg. 1000
    vm.txGasPrice(GAS_PRICE); // 使用 checkcodes 设置目前的 gas price
    vm.prank(fundMe.getOwner());
    fundMe.withdraw(); // 这里应该实际花费多少 gas? eg. 200

    uint256 gasEnd = gasleft(); // 模拟交易后剩余的 gas // 800
    uint256 gasUsed =  (gasStart - gasEnd) * tx.gasprice; 
    // tx.gasprice Solidity 内置函数,用于获取当前的 gas 价格 
    console.log(gasUsed); // 输出实际花费了多少的 Gas 
    
    // Assert
    uint256 endingOwnerBalance = fundMe.getOwner().balance;
    uint256 endingFundMeBalance = address(fundMe).balance;
    assertEq(endingFundMeBalance, 0);
    assertEq(
        startingFundMeBalance + startingOwnerBalance,
        endingOwnerBalance
    );
}

Foundry Test 关于事件(event)的测试

Foundry 测试事件是否正常触发

在 Foundry 中测试时间是否正常触发需要使用一个作弊码(cheat code)vm.expectEmit。该作弊码有两个签名,实际上就是传入四个(或五个)这些参数对应:

  • topic1、topic2、topic3:该事件是否有主题,有几个(有一个,topic1 为 true,其他为false;有两个,topic1、topic2 为 true,其他为false以此类推)
  • Log:时间有没有非indexed的参数,有为true,没有为false
  • address:可选参数,如果需要判断事件的触发者,这传递要断言的事件触发者,如果不需要,则忽略该参数。

在 Foundry 中写 Test 脚本的的时候,需要引入要触发的事件(事件通过 import 无法导入,需要按照原来要定义的事件进行重新定义)。

事件的顺序很重要:

如果我们调用expectEmit并触发了一个事件,那么下一个被触发的事件必须与我们的预期一致

示例:

  • 没有地址的签名:

    event Transfer(address indexed from, address indexed to, uint256 ammount);
    
    function testERC20EmitsTransfer() public {
        // 只有`from`和`to`在ERC20的“Transfer”事件中是 inded,
        // 所以我们专门检查topics 1和2(默认情况下总是检查topic 0),
        // 以及数据(`amount`)。
    	vm.expectEmit(true, true, false, true);
    	
    	// 我们出发的事件:我们预期触发的的事件:
    	emit MyToken.Transfer(address(this), address(1), 10);
    	
    	// 执行调用
    	myToken.transfer(address(1). 10);
    }
  • 有签名的地址:

    event Transfer(address indexed from, address indexed to, uint256 amount);
    
    function testERC20EmitsTransfer() public {
    	// 通过将地址作为第五个参数传递来检测时间的触发者是否为预期的触发者
    	vm.expectEmit(true, true, false, true, address(myToken));
    	emit myToken.Transfer(address(this), address(1), 10);
    	
    	myToken.transfer(address(1), 10);
    }
  • 断言在单次调用中触发多个事件:

    function testERC20EmitsBatchTransfer() public {
    	// 声明多个预期的转账事件
    	for (uint256 i = 0; i< users.length; i++) {
    		vm.expectEmit(true, true, true, true);
    		emit Transfer(address(this), users[i], 10);
    	}
    	
    	// 我们还预期一个自定义的 `BatchTransfer(uint256 numverOfTransfers)` event。
    	vm.expectEmit(false, false, false, false);
    	emit BatchTransfer(users.length);
    	
    	// 执行调用
    	myToken.batchTransfer(users, 10);
    }

测试事件是否能正确触发

测试时间是否能正确触发,需要用到两个作弊码:recordLogsgetRecordedLogs

  • vm.recordLogs:记录接下来触发的事件
  • vm.getRecordedLogs:读取由recordLogs记录的事件。

再使用这两个作弊码来判断事件是否为预期的参数触发,需要按照内置的格式:

struct Log {
	bytes32[] topic;
	bytes data;
	address emitter;
}

而要判断是否为预期触发,可以参考下面的例子:(主义要导入Vm 合约)

分叉测试相关若干问题

分叉测试时,涉及到了需要传入私钥,但是私钥在合约中硬编码会产生安全问题(Anvil 本地链可以硬编码)所以,这个时候,可以使用作弊码vm.envUint从环境变量中读取私钥,配合vm.startBroadcast()使得下一次的交易发起者是该私钥对应的用户。

Foundry 调试更佳实践

使用状态树(state tree)来组织单元测试

使用状态树(state tree)来组织单元测试,找到具有驱动合约行为的父节点特定状态条件。

如果是我们自己的合约,在写一些测试来测试某些东西,一旦测试通过,就为其创建一个 modifier,这样就不必再测试中复制粘贴代码

比如:大部分的调试都需要首先进行使用 vm.prank(USER) 来设置msg.sender 并且需要调用特定的函数来存款(如:fundMe.fund{value: SEND_VALUE}())那么,就可以通过创建一个 modifier 来解决:

modifier funded() {
	vm.prank(USER);
	fundMe.fund{value: SEND_VALUE}();
	_;
}
// 之后在测试需要时,只需加上 funded 修饰符即可:

function testOnlyOwnerCanWithdraw() public funded {
	fundMe.withdraw();
}

function testOnlyDrawWithASingleFunder() public funded {
	
}

Foundry 内置的小的 Solidity 编译器——chisel

在安装了 Foundry 之后,可以使用 chisel 来对一些简单的 Solidity 代码进行测试,只需在终端输入 chisel 即可,它允许在其自己的终端中输入一小段 Solidity 代码并且运行。

—未完待续—