Why mock contracts matter

A mock contract is a test double deployed on-chain in your local test environment. Unlike a pure library stub, a mock can receive calls, store state, emit events, and return values exactly like a real contract. That makes it useful for testing:

  • ERC20 transfers and approvals
  • oracle reads
  • cross-contract callbacks
  • external integrations with configurable success and failure paths
  • edge cases that are hard to reproduce with real dependencies

Mocks are especially valuable when the real dependency is:

  • expensive to deploy or initialize
  • non-deterministic
  • unavailable in local testing
  • too complex for unit tests
  • unsafe to call in a test environment

A good mock does not try to replicate every detail of the real contract. It only implements the behavior your system depends on.


When to use a mock instead of a real dependency

Use a mock when you need control over the dependency’s response. Use a real implementation when you want integration coverage for the dependency itself.

ApproachBest forTradeoff
Mock contractUnit tests, failure simulation, deterministic behaviorMay diverge from real-world behavior
Real dependencyIntegration tests, compatibility checksSlower, harder to isolate failures
Forked mainnet stateTesting against live contract behaviorRequires network state and can be brittle

A practical testing strategy often combines all three. Most tests should use mocks. A smaller set of integration tests should use real contracts or forked state to validate assumptions.


Designing a useful mock

A mock should be simple, explicit, and configurable. The most common mistake is overengineering a mock until it becomes another production contract. Keep the interface narrow and the behavior obvious.

Example: ERC20-like mock

Suppose your contract accepts payments in an ERC20 token. You want to test success, insufficient balance, and transfer failure without depending on a real token implementation.

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

contract MockERC20 {
    string public name = "Mock Token";
    string public symbol = "MCK";
    uint8 public decimals = 18;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    bool public transferShouldFail;
    bool public transferFromShouldFail;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    function mint(address to, uint256 amount) external {
        balanceOf[to] += amount;
        emit Transfer(address(0), to, amount);
    }

    function setTransferShouldFail(bool value) external {
        transferShouldFail = value;
    }

    function setTransferFromShouldFail(bool value) external {
        transferFromShouldFail = value;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        if (transferShouldFail) return false;
        require(balanceOf[msg.sender] >= amount, "insufficient balance");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        emit Transfer(msg.sender, to, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        if (transferFromShouldFail) return false;
        require(balanceOf[from] >= amount, "insufficient balance");
        require(allowance[from][msg.sender] >= amount, "insufficient allowance");

        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
        return true;
    }
}

This mock is intentionally minimal. It supports the exact behaviors a payment contract usually needs: minting test balances, approving spenders, and toggling failure modes.


Testing a contract that depends on an external token

Consider a simple payment contract that pulls tokens from a user and records the purchase.

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

interface IERC20Like {
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

contract Storefront {
    IERC20Like public immutable paymentToken;
    mapping(address => uint256) public purchases;

    constructor(address token) {
        paymentToken = IERC20Like(token);
    }

    function buy(uint256 amount) external {
        require(paymentToken.transferFrom(msg.sender, address(this), amount), "payment failed");
        purchases[msg.sender] += amount;
    }
}

A test can deploy MockERC20, mint tokens to a buyer, approve the storefront, and verify the purchase path.

Example test flow

  1. Deploy the mock token.
  2. Deploy the contract under test with the mock address.
  3. Mint tokens to the test user.
  4. Approve the storefront.
  5. Call buy.
  6. Assert token balances and internal accounting.

This approach isolates the storefront logic. If the test fails, you know the issue is in your contract, not in a live token.


Simulating success and failure paths

The most important benefit of mocks is the ability to force edge cases on demand. Real dependencies may not fail when you need them to, but a mock can.

Common failure modes to model

  • returning false instead of reverting
  • reverting with a generic error
  • reverting with a custom error
  • returning malformed or unexpected data
  • behaving differently based on caller or input
  • changing state between calls

A useful mock should expose setters or helper functions that toggle these behaviors. That lets each test define the exact scenario it needs.

Example: testing a failed payment

If transferFrom returns false, your contract should revert with payment failed.

function testBuyRevertsWhenTokenTransferFails() external {
    mockToken.setTransferFromShouldFail(true);

    vm.prank(buyer);
    vm.expectRevert(bytes("payment failed"));
    storefront.buy(100 ether);
}

This test verifies that your contract handles a failed external call correctly. It also proves that your business logic does not assume every token behaves perfectly.


Verifying call order and side effects

Mocks are not only for return values. They also help you verify that your contract calls external dependencies in the right order and with the right parameters.

For example, if your contract should update internal state only after a successful external transfer, a mock can help you assert that the transfer happens first.

Pattern: event-based verification

Emit events from the mock and inspect them in tests. This is especially helpful when the external contract has no accessible state or when you want to confirm the exact arguments passed.

event Transfer(address indexed from, address indexed to, uint256 value);

Because the mock emits the same event signature as a real ERC20, your test can verify that the expected transfer occurred from the buyer to the storefront.

Pattern: state-based verification

Read the mock’s public mappings after the call:

  • balanceOf[buyer] should decrease
  • balanceOf[address(storefront)] should increase
  • allowance[buyer][storefront] should decrease

State assertions are often easier to maintain than log assertions when the mock is under your control.


Mocking callback-driven interactions

Some contracts do not just call external dependencies; they are also called back by them. Examples include:

  • flash loan receivers
  • token hooks
  • oracle callbacks
  • escrow release flows
  • cross-contract settlement logic

In these cases, the mock should simulate the callback sequence, not just a single function call.

Example: callback-style mock

Imagine a contract that expects an external lender to call executeOperation during a flash loan.

Your mock lender can:

  1. transfer funds to the borrower
  2. call the borrower’s callback
  3. verify repayment
  4. revert if repayment is missing

This allows you to test both the happy path and the borrower’s failure handling without integrating a real lending protocol.

The key idea is to make the mock drive the interaction the same way the real system would.


Avoiding brittle mocks

Mocks become a liability when they encode assumptions that do not match production behavior. Keep them useful by following a few rules.

1. Match the external interface closely

If your production dependency returns bool, your mock should return bool. If it reverts on failure, your mock should also revert in the same situations. Avoid “helpful” simplifications that hide real-world behavior.

2. Keep the mock minimal

Only implement the functions your contract actually uses. Extra behavior increases maintenance cost and can create false confidence.

3. Separate test control from production behavior

Use explicit helper functions such as setTransferShouldFail(true) rather than hidden conditions based on magic values. Tests should read like scenarios, not puzzles.

4. Reset state between tests

Because mocks are stateful contracts, one test can influence another if you reuse deployments. Prefer fresh deployments per test or explicit setup fixtures.

5. Test the mock itself only lightly

The mock is a test tool, not the system under test. You only need enough coverage to ensure it behaves as intended for your scenarios.


Mocking external contracts with narrow interfaces

You do not need the full ABI of a dependency to test against it. In fact, a narrow interface is often better.

If your contract only calls balanceOf and transferFrom, define only those functions in the interface. This reduces coupling and makes the code easier to audit.

interface IERC20Minimal {
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

A narrow interface also makes it easier to swap in a mock, a real token, or a forked deployment without changing your contract logic.


Practical checklist for mock-based testing

Before adding a mock to your test suite, check the following:

  • Does the mock represent a dependency your contract actually uses?
  • Can it simulate both success and failure?
  • Does it expose enough state for assertions?
  • Does it behave like the real interface in the ways that matter?
  • Is it simple enough to maintain?
  • Are you still covering at least one integration path with a real contract or fork?

If the answer to most of these is yes, the mock is probably doing useful work.


Common mistakes to avoid

Overmocking

If every dependency is mocked, your tests may pass even though the system fails in production. Keep a balance between unit and integration coverage.

Under-specifying failure behavior

A mock that only returns success is not enough. Real systems fail, and your tests should prove that your contract handles failure safely.

Hiding business logic inside the mock

If the mock contains complex logic that mirrors production rules, it can mask bugs. The contract under test should own the logic; the mock should only provide controlled responses.

Reusing a mock across unrelated scenarios

A single “god mock” with dozens of toggles becomes hard to reason about. Prefer small, focused mocks per dependency type.


Summary

Mock contracts are one of the most effective tools for testing Solidity systems that depend on external contracts. They let you isolate your code, simulate edge cases, and verify call behavior without relying on live infrastructure.

The best mocks are:

  • narrow in scope
  • configurable in behavior
  • faithful to the external interface
  • easy to deploy and reset
  • focused on the interaction your contract actually uses

Used well, mocks make your tests faster, clearer, and more reliable.

Learn more with useful resources