0


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.sender

tx.origin

如果不只需要更改

msg.sender

还需要更改

tx.origin

,那么

vm.prank

vm.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.deal

vm.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

到此结束,大家也可以,https://t.me/gtokentool

标签: 单元测试

本文转载自: https://blog.csdn.net/2408_87746709/article/details/143067318
版权归原作者 加密新世界 所有, 如有侵权,请联系我们删除。

“Foundry 使用教程和单元测试示例”的评论:

还没有评论