
Testing Solidity Contract Interactions with Mock Contracts
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.
| Approach | Best for | Tradeoff |
|---|---|---|
| Mock contract | Unit tests, failure simulation, deterministic behavior | May diverge from real-world behavior |
| Real dependency | Integration tests, compatibility checks | Slower, harder to isolate failures |
| Forked mainnet state | Testing against live contract behavior | Requires 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
- Deploy the mock token.
- Deploy the contract under test with the mock address.
- Mint tokens to the test user.
- Approve the storefront.
- Call
buy. - 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
falseinstead 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 decreasebalanceOf[address(storefront)]should increaseallowance[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:
- transfer funds to the borrower
- call the borrower’s callback
- verify repayment
- 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.
