使用 foundry cheatcode 在 test 中模拟交易
在 Foundry 的 cheatcode 中,有几个很好用的 cheatcode 能够帮助我们在不进行大量初始化的情况下来对我们的合约进行 test。这三个 cheatcode 分别是:
mockCall
mockCallRevert
mockFunction
当然这三个 cheatcode 还有一个配套的 cheatcode 来清除我们当前模拟的调用:clearMockedCalls
首先我们先来了解一下这四个 cheatcode:
cheatcode
mockCall
函数签名如下:
function mockCall(address where, bytes calldata data, bytes calldata retdata) external;
function mockCall(
address where,
uint256 value,
bytes calldata data,
bytes calldata retdata
) external;
功能:
模拟对地址where
的所有调用,其中 calldata 严格或松散匹配数据data
,并返回retdata
。
当对 where
进行调用时,会首先检查 calldata,以查看他是否与 data 完全匹配。如果不是,则检查 calldata 来查看是否存在部分匹配,匹配从调用数据的第一个字节开始。
如果找到匹配项,则从调用中返回retdata
使用第二个签名,我们可以模拟具有特定 msg.value
的调用。在岐义的情况下,Calldata
匹配优选于msg.value
模拟调用在调用 clearMockedCalls
之前一直有效。
mockCallRevert
函数签名如下:
function mockCallRevert(address where, bytes calldata data, bytes calldata retdata) external;
function mockCallRevert(
address where,
uint256 value,
bytes calldata data,
bytes calldata retdata
) external;
功能:
模拟对地址 where
的所有调用全部回滚,其中 calldata 严格或松散匹配数据data
,并触发retdata
回滚信息。
retdata
可以是原始回滚信息或自定义错误。
当对 where
发起调用时,会首先检查 calldata,以查看它是否与数据完全匹配。如果不是,则检查 calldata 是否存在部分匹配,匹配从 calldata 的第一个字节开始。
如果找到匹配项,则回滚调用并返回 retdata
使用第二个签名,我们可以使用特定的 msg.value 来模拟调用。如果出现歧义,Calldata
匹配优先于 msg.value
。
回滚的模拟调用在调用 clearMockedCalls 之前一直有效。
mockFunction
函数签名如下:
function mockFunction(address callee, address target, bytes calldata data) external;
功能:
如果 calldata 严格或松散匹配 data,则使用地址callee
的字节码执行对地址target
的调用。
当 callee
进行调用时,首先会检查 calldata,以查看他是否与 data 完全匹配。如果不匹配则检查调用数据以查看函数选择器上是否存在部分匹配。
如果找到匹配项,则使用target
地址的字节码执行 call。
clearMockedCalls
函数签名如下:
function clearMockedCalls() external;
功能:
清除所有的模拟调用。
使用 mockCall
mockCall 可以在以下场景中使用:
- 在不实际部署合约的情况下,测试与外部合约交互时,模拟外部合约的返回值
- 模拟各种边界情况和异常场景
- 加速测试(因为无需实际执行外部合约逻辑)
example1:测试一个 ERC20 合约的 balanceOf 函数
function testUSDCBalance() public {
address usdc = address(0x1234);
address user = address(0x5678);
// 模拟 USDC.balanceOf() 调用
vm.mockCall(
usdc,
abi.encodeWithSelector(IERC20.balanceOf.selector, user),
abi.encode(100)
);
uint256 balance = IERC20(usdc).balanceOf(user);
assertEq(balance, 1000);
}
example2:DeFi 协议测试
contract ComplexTest is Test {
// 假设我们在测试一个 DeFi 协议,需要与多个外部合约交互
function testComplexDeFiInteraction() public {
address aavePool = address(0x1);
address chainlink = address(0x2);
address token = address(0x3);
// 模拟 Chainlink 喂价
vm.mockCall(
chainlink,
abi.encodeWithSelector(AggregatorV3Interface.latestRoundData.selector),
abi.encode(uint80(1), int256(2000e8), uint256(0), uint256(block.timestamp), uint80(1))
);
// 模拟 Aave 借贷池状态
vm.mockCall(
aavePool,
abi.encodeWithSelector(IPool.getUserAccountData.selector, address(this)),
abi.encode(
uint256(1000e18), // totalCollateralBase
uint256(500e18), // totalDebtBase
uint256(800e18), // availableBorrowsBase
uint256(8000), // currentLiquidationThreshold
uint256(8500), // ltv
uint256(1000) // healthFactor
)
);
// 我们要测试的我们的 DeFi 协议的逻辑
...
}
}
mockCall 和 mockFunction 有什么区别?
从某种角度上来看,这两个 cheatcode 都是模拟我们对合约进行了调用,并返回了相关的数据,但是这两个 cheatcode 的主要区别主要在于使用场景和粒度。
- mockCall 是模拟整个调用数据(完整的 calldata)
- mockFunction 是基于函数签名和调用者来模拟的(更细粒度的控制)
来看几个例子:
example1:模拟多个调用者,且多个调用者的余额不同
contract MockingTest is Test {
function testCallerSpecific() public {
address token = address(0x1);
// 模拟不同调用者获取不同的余额
vm.mockFunction(
token,
IERC20.balanceOf.selector,
address(0x100),
abi.encode(1000)
);
vm.mockFunction(
token,
IERC20.balanceOf.selector,
address(0x200),
abi.encode(2000)
);
vm.prank(address(0x100));
assertEq(IERC20(token).balanceOf(address(0x100)), 1000);
vm.prank(address(0x200));
assertEq(IERC20(token).balanceOf(address(0x200)), 2000);
}
}
example2:权限控制测试
contract AccessControlTest is Test {
function testDifferentAccessLevels() public {
address vault = address(0x1);
address admin = address(0x100);
address user = address(0x200);
// 模拟管理员调用
vm.mockFunction(
vault,
IVault.withdraw.selector,
admin,
abi.encode(true) // 允许提现
);
// 模拟普通用户调用
vm.mockFunction(
vault,
IVault.withdraw.selector,
user,
abi.encode(false) // 拒绝提现
);
// 测试管理员调用
vm.prank(admin);
bool adminSuccess = IVault(vault).withdraw(100);
assertTrue(adminSuccess);
// 测试用户调用
vm.prank(user);
bool userSuccess = IVault(vault).withdraw(100);
assertFalse(userSuccess);
}
}
example3:组合使用
contract CombinedMockingTest is Test {
function testComplexMocking() public {
address protocol = address(0x1);
address user1 = address(0x2);
address user2 = address(0x3);
// 使用 mockCall 设置通用行为
vm.mockCall(
protocol,
abi.encodeWithSelector(IProtocol.getGlobalState.selector),
abi.encode(true, 1000, block.timestamp)
);
// 使用 mockFunction 设置用户特定行为
vm.mockFunction(
protocol,
IProtocol.getUserState.selector,
user1,
abi.encode(100, true) // user1 的状态
);
vm.mockFunction(
protocol,
IProtocol.getUserState.selector,
user2,
abi.encode(200, false) // user2 的状态
);
// 可以进行更复杂的组合测试...
}
}
通过这三个例子,我们可以看出:
mockCall 适用于:
- 模拟简单的合约调用
- 不需要区分调用者的场景
- 需要精确控制完整 calldata 的场景
mockFunction 适用于:
- 需要基于调用者区分返回值的场景
- 权限控制相关的测试
- 更细粒度的函数调用控制
mockCall 使用注意事项:
注意:如果 mockCall 使用不当也会对我们的测试带来困扰。我们来看这样几个场景:
场景 1:mock 不存在的函数
// 即使合约中不存在这个函数,mock 依然会生效
contract VaultTest is Test {
function testNonExistentFunction() public {
address vault = address(0x1);
// 即使 vault 合约不存在 nonExistentFunction,这个 mock 也会生效
vm.mockFunction(
vault,
bytes4(keccak256("nonExistentFunction()")),
address(this),
abi.encode(true)
);
}
}
mockCall 实际上是模拟一个地址上能够调用我们预期的函数,并且有我们预期的返回值,所以实际上,Foundry 并不知道这个地址对应的合约实际上存在哪些函数,它仅仅就是假设,我们对这个地址发起调用,返回我们规定的返回值。
场景 2:mock 不可能出现的场景(mock 的优先级)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
contract Vault {
function withdraw() external returns (bool) {
return true;
}
}
contract MockingPriorityTest is Test {
Vault vault;
function setUp() public {
vault = new Vault();
}
function testMockPriority() public {
// mock 返回 false
vm.mockFunction(
address(vault),
Vault.withdraw.selector,
address(this),
abi.encode(false)
);
// 尽管实际合约返回 true,但是由于 mock 的存在
// 这次调用会返回 false
bool result = vault.withdraw();
assertFalse(result); // 这个断言会通过,因为 mock 优先级更高
}
}
我们可以看到,我们的 Vault 合约中的 withdraw
函数,实现上是总是返回 true
的。但是在我们的测试合约中,我们 mockCallwithdraw
函数返回 false
,此时我们的 test 认为:现在调用 vault
合约的 withdraw()
函数的返回值就是 false
。还是那句话:Foundry 并不知道这个地址对应的合约实际上存在哪些函数,它仅仅就是假设,我们对这个地址发起调用,返回我们规定的返回值。也可以认为,mock 的优先级更高。
那么我们如何清除 mock?使用clearMockedCalls
cheatcode 即可
mockCall 作用范围,清除 mockCall
contract Vault {
function withdraw() external returns (bool) {
return true;
}
}
contract ClearMockTest is Test {
Vault vault;
function setUp() public {
vault = new Vault();
}
function testClearMock() public {
// 设置mock
vm.mockFunction(
address(vault),
Vault.withdraw.selector,
address(this),
abi.encode(false)
);
// mock生效,返回false
bool result1 = vault.withdraw();
assertFalse(result1);
// 清除所有mock
vm.clearMockedCalls();
// 使用实际实现,返回true
bool result2 = vault.withdraw();
assertTrue(result2);
}
}
注意:clearMockedCalls()
清除所有 mock,不仅仅是 mockCall
这一个 cheatcode。