Foundry 使用教程和单元测试示例

  • 0xE
  • 更新于 1小时前
  • 阅读 49

本文介绍了 Foundry 各个组件的使用,不单纯重复文档的内容,而是关注最常使用的部分。

Foundry 是一个用 Rust 编写的以太坊应用开发工具包,具有极速、可移植和模块化的特点。

Foundry 包括以下组件:

  • Forge:以太坊测试框架(类似于 Truffle、Hardhat 和 DappTools)。
  • Cast:用于与 EVM 智能合约交互、发送交易和获取链上数据的瑞士军刀工具。
  • Anvil:本地以太坊节点,类似于 Ganache 和 Hardhat Network。
  • Chisel:快速、实用且详细的 Solidity REPL。

我们这篇文章不会单纯重复文档的内容,而是关注最常使用的部分。

Foundry 安装和基本使用

Foundry 安装指南

curl -L https://foundry.paradigm.xyz | bash
foundryup

使用 forge -h手册查看相关命令:

新建项目

forge init

里面的 foundry.toml 是 Forge 项目中的配置文件。在 foundry.toml 中使用 solc 配置编译器版本:

[profile.default]
src = 'src'
out = 'out'
libs = ['lib']

solc = "0.8.x" 

编译

forge build

测试

# 运行所有测试
forge test

# 单独运行匹配前缀为 `CounterTest` 的单元测试
forge test --match-contract CounterTest

# 单独运行 `CounterTest` 的单元测试中的测试用例 `test_Increment` (match 同样是匹配前缀)
forge test --match-contract CounterTest --match-test test_Increment

# 当合约内容有变动时,就会重新运行所有的单元测试
forge test --watch 

依赖包: 比如 transmissions11/solmate 依赖包:

# 安装
forge install transmissions11/solmate

# 移除
forge remove lib/solmate

# 更新
forge update lib/solmate

如何让 VSCode 能识别到依赖包里的合约,使用:

forge remappings > remappings.txt

如果还是不能识别,尝试重启 VSCode。

覆盖测试

如果你在 Foundry 项目中运行 forge coverage,会看到一张表,显示代码的行覆盖率和分支覆盖率。

想更直观的显示对应代码行,可以使用以下方法:

安装 lcov:

brew install lcov

在 Foundry 项目中创建 coverage 目录:

mkdir coverage

运行以下命令:

forge coverage --report lcov

genhtml lcov.info --branch-coverage --output-dir coverage

如果有错误,可尝试:

genhtml --ignore-errors inconsistent,corrupt lcov.info --branch-coverage --output-dir coverage

最终打开 coverage/index.html 中的网页:

另外,也可以安装 VSCode 插件 Coverage Gutters

使用命令生成覆盖测试报告:

forge coverage --report lcov

然后在 VSCode 右键选择 Coverage Gutters 中的 Display Coverage

单元测试

Assert 断言

在单元测试中,往往需要使用断言。以下是调用 forge init 后提供的默认测试文件。

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

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

contract CounterTest is Test {
    Counter public counter;

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

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

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

setUp() 函数部署了正在测试的合约,每次测试用例的执行会默认执行 setUp()。任何以 test 开头的函数都会被执行作为单元测试。

以下是可以使用的断言:

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

断言的前两个参数是要进行比较的值,但你也可以添加一个错误消息作为第三个参数,建议总是这么做。以下是编写断言的推荐方式:

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

使用 vm.prank 改变 msg.sender

Foundry 通过一种“欺骗码” vm.prank 提供了一种方法来更改 sender。

下面是一个简单示例:

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

vm.prank 仅对其后立即发生的交易有效。如果你希望之后的一系列交易都使用相同的地址,可以使用 vm.startPrank 开始,并用 vm.stopPrank 结束。

function testMultipleTransactions() public {
    vm.startPrank(owner);
    // 作为 owner 地址发送的交易
    vm.stopPrank();
}

在 Foundry 中定义账户和地址

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

address owner = address(1234);

address owner = 0x0d8dA6BF26964aF9D7eEd9e03E53415D37aA96045;

address owner = vm.addr(privateKey);

更改 msg.sendertx.origin

如果不只需要更改 msg.sender 还需要更改 tx.origin,那么 vm.prankvm.startPrank 可以选择性地接受两个参数,其中第二个参数是 tx.origin

vm.prank(msgSender, txOrigin);

检查余额

Foundry 中检查余额非常简单,因为它是用 Solidity 编写的。

比如以下合约:

contract Deposit {
    event Deposited(address indexed);

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

测试函数如下:

function test_BuyerDeposit() 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");
}

使用 vm.expectRevert 预期 revert

上面的测试在当前形式下存在的问题是,如果您删除 require 语句,测试仍会通过。让我们改进测试,使得删除 require 语句会导致测试失败。

还是使用以上的例子,需要测试 buyerDeposit() 函数中的 require 导致 revert 的情况。vm.expectRevert 在期望发生 revert 之前调用。

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

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

测试自定义 error

以下是使用了自定义 error 的合约。

contract CustomErrorContract {
    error SomeError(uint256);

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

测试函数如下:

error SomeError(uint256);

function test_Revert() public {
    vm.expectRevert(abi.encodeWithSelector(SomeError.selector, 6));
    customErrorContract.revertError(6);
}

使用 vm.expectEmit 测试事件

还是使用以上的例子,需要测试 buyerDeposit() 函数中的 Deposited 事件。vm.expectEmit 的使用有些反直觉,你需要在测试文件中写一个同样的事件,并且按照以下例子中的顺序编写。

event Deposited(address indexed);

function test_BuyerDepositEvent() public {
    vm.expectEmit();
    emit Deposited(buyer);
    depositContract.buyerDeposit{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) 调整到当前时间。

这是使用 vm.warp 调整时间的方式,但因为每个测试用例一般都需要修改时间,我们可以使用修饰符:

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

测试文件示例

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

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

contract DepositTest is Test {
    Deposit public deposit;
    Deposit public faildeposit;
    address constant SELLER = address(0x5E11E7);
    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(1729072888);
        _;
    }

    address public buyer = address(this);

    // 测试卖家不能在买家存款后的 3 天之内提款。
    function testBuyerDepositSellerWithdrawBefore3days() public startAtPresentDay {
        vm.startPrank(buyer); 
        deposit.buyerDeposit{value: 1 ether}();
        assertEq(address(deposit).balance, 1 ether, "Contract balance did not increase");
        vm.stopPrank();

        vm.startPrank(SELLER); 
        vm.warp(1729072888 + 2 days);
        vm.expectRevert(); 
        deposit.sellerWithdraw(address(this));
    }
}

使用 vm.roll 调整 block.number

如果你想要调整 block.number,可以使用:

vm.roll(blockNumber)

要向前推进一定数量的区块,可以这样做:

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

测试失败的 ETH 转账

在之前的例子中,卖家在 3 天后可以提取 ETH,如果我们要测试合约转 ETH 转账失败的情况,需要一定的技巧来达到完整的代码覆盖率。

我们可以编写一个 RejectTransaction 合约,使得不能接收 ETH 的转入。

contract RejectTransaction {
    receive() external payable {
        revert("Revert");
    }
}

以下是测试 require(ok...) 失败的测试用例:

function testRejectedWithdrawl() public startAtPresentDay {
    vm.startPrank(buyer); // msg.sender == buyer
    faildeposit.buyerDeposit{value: 1 ether}();
    vm.stopPrank();
    assertEq(address(faildeposit).balance, 1 ether, "assertion failed");

    vm.warp(1729072888 + 3 days + 1 seconds); // 3 days and 1 second later...

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

模糊测试

虽然我们可以使用错误的卖家地址来调用 sellerWithdraw(),但是如果系统可以自动尝试不同的值会更好。当测试用例有传参时,Foundry 会自动测试多个不同的值。为了防止随机到不合适的值,可以使用 vm.assume。使用 testFuzz 作为名称开头。

function testFuzzInvalidSellerAddress(address notSeller) public {
    vm.assume(notSeller != SELLER);
    vm.startPrank(notSeller);
    vm.expectRevert("not the seller");
    deposit.sellerWithdraw(buyer);
    vm.stopPrank();
}

使用 Foundry 进行 console.log 调试

需要确保导入了 console

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

然后用以下命令运行测试:

forge test -vv

测试签名

以下是使用 OZ 库,创建和验证 ECDSA 签名的例子。

Verifier.sol

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

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract Verifier {
    using ECDSA for bytes32;

    address public verifyingAddress;

    constructor(address _verifyingAddress) {
        verifyingAddress = _verifyingAddress;
    }

    function verifyV1(
        string calldata message,
        bytes32 r,
        bytes32 s,
        uint8 v
    ) public view {
        bytes32 signedMessageHash = keccak256(abi.encode(message))
            .toEthSignedMessageHash();
        require(
            signedMessageHash.recover(v, r, s) == verifyingAddress,
            "signature not valid v1"
        );
    }

    function verifyV2(
        string calldata message,
        bytes calldata signature
    ) public view {
        bytes32 signedMessageHash = keccak256(abi.encode(message))
            .toEthSignedMessageHash();
        require(
            signedMessageHash.recover(signature) == verifyingAddress,
            "signature not valid v2"
        );
    }
}

Verifier.t.sol

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

import {Test, console} from "forge-std/Test.sol";
import {Verifier} from "../src/Verifier.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract TestSigs1 is Test {
    using ECDSA for bytes32;
    Verifier verifier;

    address owner;
    uint256 privateKey =
        0x1010101010101010101010101010101010101010101010101010101010101010;

    function setUp() public {
        owner = vm.addr(privateKey);
        verifier = new Verifier(owner);
    }

    function testVerifyV1andV2() public {
        string memory message = "attack at dawn";

        bytes32 msgHash = keccak256(abi.encode(message))
            .toEthSignedMessageHash();

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, msgHash);

        bytes memory signature = abi.encodePacked(r, s, v);
        assertEq(signature.length, 65);

        console.logBytes(signature);
        verifier.verifyV1(message, r, s, v);
        verifier.verifyV2(message, signature);
    }
}

测试 Solidity 的 internal 函数

要测试 internal 函数,只能再写一个合约继承原来的合约,使用 external 函数调用 internal 函数。比如,以下的例子:

contract InternalFunction {

    uint256 private constant REWARD_RATE_PER_SECOND = 1e18;

    function calculateReward(uint256 depositTime) internal view returns (uint256 reward) {
        reward = (block.timestamp - depositTime) * REWARD_RATE_PER_SECOND;
    }

}

contract InternalFunctionHarness is InternalFunction {

    function calculateReward_HARNESS(uint256 depositTime) external view returns (uint256 reward) {
        reward = calculateReward(depositTime);
    }

}

如果想要测试 private 函数,可以考虑修改成 internal 函数,这对 gas 并无影响。

使用 vm.dealvm.hoax 设置地址余额

vm.hoax 允许你同时设置地址余额并伪造调用者身份。

vm.hoax(addressToPrank, balanceToGive);
// next call is a prank for addressToPrank

vm.deal(alice, balanceToGive);

Fork 网络后测试

有时我们需要主网上的数据,我们可以 fork 指定的链,再跑测试。

在 .env 中保存主网 RPC。

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

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

contract CounterTest is Test {
    uint256 mainnetFork;
    string mainnetRPC = vm.envString("MAINNET_RPC");
    Counter public counter;

    function setUp() public {
        mainnetFork = vm.createFork(mainnetRPC);
        vm.selectFork(mainnetFork);
        counter = new Counter();
        counter.setNumber(0);
    }

    function testActiveFort() public {
        assertEq(vm.activeFork(), mainnetFork);
    }
}

也可以使用命令,指定 fork 的网络和指定区块号

forge test --match-contract CounterTest --fork-url <RPC_URL> --fork-block-number <BLOCK_NUMBER>

部署 与 Verify 合约

部署到本地

启动本地节点:

anvil

// 获取 rpc http://127.0.0.1:8545

新建终端,再部署合约:

forge create --rpc-url http://127.0.0.1:8545 --constructor-args 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 src/Deposit.sol:Deposit

// --constructor-args 为构造函数参数

部署到链上

命令行部署到链上并且验证合约

forge create 
             --rpc-url <RPC_URL>
             --constructor-args <CONSTRUCTOR_ARGS> 
             --private-key <PK>
             --etherscan-api-key <ETHERSCAN_API_KEY>
             --verify src/Deposit.sol:Deposit

或者在 foundry.toml 文件中配置 etherscan 的 API KEY:

[etherscan]
sepolia = { key = "${ETHERSCAN_API_KEY}" }

并且在 .env 中存放 ETHERSCAN_API_KEY:

ETHERSCAN_API_KEY=

申请 etherscan API

脚本部署

在 .env 中保存 RPC 和 私钥:

SEPOLIA_RPC_URL=
PRIVATE_KEY=0x...

编辑 foundry.toml 文件:

[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"
local = "http://127.0.0.1:8545"

在 script 目录下创建脚本 Deposit.s.sol:

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

import {Script, console} from "forge-std/Script.sol";
import {Deposit} from "../src/Deposit.sol";

contract DepositScript is Script {
    Deposit public deposit;

    function setUp() public {}

    function run() public {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");
        address deployerAddress = vm.addr(privateKey);

        vm.startBroadcast(privateKey);
        deposit = new Deposit(deployerAddress);
        console.log("Deposit deployed on %s", address(deposit));
        vm.stopBroadcast();
    }
}

Cast 命令行工具

Chain

# 获取余额
cast balance --rpc-url <RPC_URL> <address>

# 获取 chain ID
cast chain-id --rpc-url <RPC_URL>

# 获取该节点使用的客户端软件的类型和版本信息
cast client --rpc-url <RPC_URL>

Block

export mainnet=https://eth.llamarpc.com

# 获取最新区块号
cast block-number --rpc-url $mainnet

# 获取区块出块时间,默认最新,也可指定区块
cast age --rpc-url $mainnet
cast age --rpc-url $mainnet 1

# 根据时间戳获取区块
cast find-block --rpc-url $mainnet 1729072888

# 获取区块内容
cast block --rpc-url $mainnet
cast block --rpc-url $mainnet 20990795
cast block --rpc-url $mainnet --json
cast block --rpc-url $mainnet --field number
cast block --rpc-url $mainnet --field hash
cast block --rpc-url $mainnet pending
cast block --rpc-url $mainnet --full

# 获取当前 gas 价格
cast gas-price --rpc-url $mainnet

# 获取 basefee
cast base-fee --rpc-url $mainnet
cast base-fee --rpc-url $mainnet 20990795

ABI

export mainnet=https://eth.llamarpc.com

# 对参数进行编码和解码
cast abi-encode "transfer(address, uint256)" 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 10000

cast --abi-decode "transfer()(address, uint256)" 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000000002710

# 对调用方法和参数编码和解码
cast calldata "transfer(address, uint256)" 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 10000

cast --calldata-decode "transfer(address, uint256)" 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000000002710

cast pretty-calldata 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000000002710

cast 4byte-decode 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000000002710

# 对调用方法解码
cast 4byte 0xa9059cbb

# 对事件 Topics 进行解码
cast 4byte-event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

# 将字符串转换成 bytes32
cast --format-bytes32-string "hello world"

# 将字节转换成字符串 string
cast --parse-bytes32-string "0x68656c6c6f20776f726c64000000000000000000000000000000000000000000"

# 将字符串转换成 utf8
cast --from-utf8 "hello world"

# 将 ascii 转换成字符串
cast --to-ascii "0x68656c6c6f20776f726c64"

# 将整数转换成 32 字节
cast --to-uint256 10

Account

# 获取余额
cast balance --rpc-url $mainnet vitalik.eth
cast balance --rpc-url $mainnet --ether 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045

# 获取 nonce 值
cast nonce --rpc-url $mainnet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045

# 获取 ENS
cast lookup-address  --rpc-url $mainnet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045

# 获取 ENS 对应地址
cast resolve-name --rpc-url $mainnet vitalik.eth

# 获取存储槽 slot 数据
cast storage --rpc-url $mainnet 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 0

# 获取合约的 bytescode
cast code --rpc-url $mainnet 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2

# 获取合约源代码
export ETHERSCAN_API_KEY=
cast etherscan-source 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
cast etherscan-source -d weth 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2

Transation

开启 anvil,以下命令是在本地节点运行。如果需要对测试网或者主网进行操作,需要增加 --rpc-url。

export privateKey=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

# 转账
cast send --private-key $privateKey 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --value 10ether

# 获取账号余额
cast balance 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --ether

# 创建合约
forge create --private-key $privateKey src/Counter.sol:Counter

# 调用合约方法
cast send --private-key $privateKey 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 "setNumber(uint256)" 100

# 调用 static 合约方法
cast call 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 "number()(uint256)"

# 获取 Transation 信息
cast tx 0x914610aa6c9bb7659a9b5b8ae1c0575b4dfce51226a01ecd1180d7553977d0e1

Wallet

# 创建钱包方式一
cast wallet new

# 创建钱包方式二
mkdir keystore
cast wallet new keystore

# 根据 json 钱包获取地址
cast wallet address --keystore 1c0ac11a-d844-4bf6-be36-81f2da400f19

# 签名
export privateKey=0x113d463b15d61eb6df9182a7c45c8b952a9768b7a84e1d4e471731c271360ca6

cast wallet sign --private-key $privateKey "hello"

# 验签
cast wallet verify --address 0xEF9D0359bD4Ade81386C49e91D3dB75c2b75A1C8 "hello" 0x8b3c393dcea9794ad7aebe10436b9cdaea6efef3841cee59aae38e5ccaf1fd33105aa97a838b92e50d28e65828e7483f14f857bdb1e10d51e05a8769bc50f20b1c

# 生成靓号
cast wallet vanity --starts-with 00 --ends-with 00
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
0xE
0xE
0x59f6...a17e
17年进入币圈,做过FHE,联盟链,现在是智能合约开发者。 刨根问底探链上真相,品味坎坷悟Web3人生。