在 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 可以在以下场景中使用:

  1. 在不实际部署合约的情况下,测试与外部合约交互时,模拟外部合约的返回值
  2. 模拟各种边界情况和异常场景
  3. 加速测试(因为无需实际执行外部合约逻辑)

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?使用clearMockedCallscheatcode 即可

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。