
Testing Solidity Contract Interactions with Mock Contracts
Why mock contracts matter
A mock contract is a lightweight test double that imitates another contract’s behavior. Unlike a full integration environment, a mock is intentionally narrow: it returns controlled values, emits predictable events, or reverts in specific ways.
Mocks are useful when your contract depends on:
- external price feeds
- ERC20 or ERC721 tokens
- registries and permission managers
- routers, vaults, or bridges
- callback-based protocols
Without mocks, tests become slow, brittle, and difficult to reproduce. With mocks, you can validate your contract’s logic under success, failure, and malformed-response scenarios.
What to test with mocks
Use mocks to verify:
- how your contract reacts to external return values
- whether it handles reverts from dependencies
- whether it makes the right calls in the right order
- whether it tolerates unexpected or malicious behavior
- whether integration assumptions are encoded correctly
Mocks are especially valuable for testing defensive code paths that are hard to trigger on a live network.
Mock contracts versus real dependencies
A common mistake is to treat mocks as a replacement for all integration testing. They are not. Mocks are best for unit-level and component-level tests, while real deployments or fork tests are better for end-to-end validation.
| Approach | Best for | Strengths | Limitations |
|---|---|---|---|
| Mock contract | Isolated behavior, edge cases | Fast, deterministic, easy to control | May diverge from real implementation |
| Real dependency on local chain | Integration behavior | More realistic call flow | Harder to set up, less deterministic |
| Mainnet fork | Production-like validation | Uses real state and bytecode | Slower, external state can change |
A good testing strategy often combines all three. Use mocks for precision, and real dependencies for confidence.
Designing a useful mock
A good mock should be simple, explicit, and configurable. Avoid copying the full production contract unless the production interface is small and stable. Instead, implement only the functions your system under test actually uses.
Example scenario
Suppose you are building a payment contract that queries an external oracle for a conversion rate before accepting deposits. Your contract may need to:
- read a rate from the oracle
- reject deposits if the rate is stale or invalid
- proceed if the rate is acceptable
A mock oracle can simulate each of those cases.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IPriceOracle {
function getPrice() external view returns (uint256 price, uint256 updatedAt);
}
contract PaymentVault {
IPriceOracle public immutable oracle;
uint256 public constant MAX_STALENESS = 1 hours;
constructor(address oracleAddress) {
oracle = IPriceOracle(oracleAddress);
}
function deposit() external payable {
(uint256 price, uint256 updatedAt) = oracle.getPrice();
require(price > 0, "invalid price");
require(block.timestamp - updatedAt <= MAX_STALENESS, "stale price");
// Deposit logic would use the price here.
}
}
contract MockPriceOracle is IPriceOracle {
uint256 public price;
uint256 public updatedAt;
bool public shouldRevert;
function setPrice(uint256 newPrice, uint256 newUpdatedAt) external {
price = newPrice;
updatedAt = newUpdatedAt;
}
function setRevert(bool value) external {
shouldRevert = value;
}
function getPrice() external view returns (uint256, uint256) {
require(!shouldRevert, "oracle failure");
return (price, updatedAt);
}
}This mock is intentionally small. It exposes setters so tests can configure the exact oracle state they need.
Patterns for building mocks
1. Return controlled values
The simplest mock returns values you set in the test. This is ideal for deterministic branches.
Examples:
- token balance queries
- oracle prices
- registry lookups
- fee calculations
2. Revert on demand
Many production failures are external. Your contract should behave correctly when a dependency reverts. A mock can simulate this with a boolean flag or a dedicated revert function.
Use this to test:
- graceful failure
- custom error propagation
- fallback logic
- transaction atomicity
3. Record calls
Sometimes you need to verify that your contract called a dependency with the correct parameters. A mock can store the last arguments it received.
This is useful for:
- allowance approvals
- router swaps
- callback payloads
- cross-contract accounting
4. Count invocations
Call counters help verify that a function is called exactly once, or not called at all under certain conditions.
5. Simulate stateful behavior
Some dependencies are stateful, such as token balances or vault shares. A mock can update internal state to resemble real behavior more closely.
Testing call flow and side effects
When testing interactions, the goal is not only to check return values. You also want to confirm that the contract under test performs the right sequence of external calls and state changes.
Consider a contract that approves a token, then transfers it to a router. A mock ERC20 can record approvals and transfers.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20Like {
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
contract MockERC20 is IERC20Like {
mapping(address => mapping(address => uint256)) public allowance;
mapping(address => uint256) public balanceOf;
address public lastApprover;
address public lastSpender;
uint256 public lastApprovedAmount;
address public lastFrom;
address public lastTo;
uint256 public lastTransferAmount;
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
lastApprover = msg.sender;
lastSpender = spender;
lastApprovedAmount = amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
require(allowance[from][msg.sender] >= amount, "insufficient allowance");
require(balanceOf[from] >= amount, "insufficient balance");
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
lastFrom = from;
lastTo = to;
lastTransferAmount = amount;
return true;
}
}This mock is useful because it behaves like a minimal token and also exposes observability for assertions.
What to assert
In your tests, verify:
- the expected external method was called
- the call used the correct parameters
- the dependency state changed as expected
- the main contract state changed only after successful interaction
Handling malicious or non-standard behavior
Real integrations are not always well-behaved. A robust contract should survive unexpected responses from external contracts. Mocks let you reproduce problematic behavior without needing a hostile deployment.
Common failure modes to simulate
| Behavior | Why it matters | Example test goal |
|---|---|---|
| Revert with reason | Dependency rejects the call | Ensure your contract bubbles or handles failure |
| Return false instead of reverting | Common in older token interfaces | Ensure you check return values |
| Return malformed data | Low-level calls can decode unexpectedly | Ensure decoding assumptions are safe |
| Reenter during callback | External calls can reenter stateful functions | Ensure state is updated before interaction |
| Change state between calls | Dependency is not pure | Ensure logic does not assume immutability |
Example: testing a false-returning token
Some token contracts return false instead of reverting. If your code assumes success without checking, it may silently fail.
contract MockBadToken {
function transfer(address, uint256) external pure returns (bool) {
return false;
}
}If your contract uses require(token.transfer(...), "transfer failed");, this mock confirms the check is present. If it does not, the test should fail.
Using mocks to test callbacks and hooks
Callback-driven designs are common in Solidity: flash loans, token hooks, and router callbacks all depend on external contracts invoking your code at the right moment.
Mocks are ideal for these cases because they can trigger the callback directly and validate the response.
Example: callback verification
Imagine a router that calls back into your contract after a swap. Your mock router can invoke the callback with controlled parameters and verify that your contract settles correctly.
The important testing pattern is:
- the mock initiates the callback
- your contract processes the callback
- the test checks final balances and internal accounting
This approach is much safer than trying to reproduce the entire protocol stack.
Best practices for mock-based Solidity tests
Keep mocks minimal
Only implement the functions your tests need. Excess logic makes mocks harder to reason about and easier to get wrong.
Make behavior configurable
Use setters or constructor parameters to switch between success, revert, and edge-case modes. This keeps one mock reusable across many tests.
Prefer explicit state over hidden magic
A mock should be easy to inspect. Store the last call parameters, counters, and relevant state in public variables when possible.
Avoid duplicating production logic
If the mock copies the production contract too closely, it may reproduce the same bug and hide it. A mock should imitate behavior, not architecture.
Test both happy path and failure path
For every external interaction, write at least one success test and one failure test. If the dependency can return invalid data, test that too.
Combine mocks with integration tests
Use mocks for fast feedback, then validate critical flows against real contracts or forked state. This layered approach catches both logic bugs and integration mismatches.
A practical testing checklist
Before you finalize a contract that depends on external calls, ask:
- Have I tested the dependency returning valid data?
- Have I tested the dependency reverting?
- Have I tested false or zero values where applicable?
- Have I verified the exact call parameters?
- Have I checked that state changes happen in the right order?
- Have I simulated at least one malicious or non-standard response?
If the answer to any of these is no, a mock contract can usually fill the gap.
Common mistakes to avoid
Overusing mocks for everything
Mocks are not a substitute for real integration. A test suite made only of mocks can miss interface mismatches, gas issues, and deployment-specific behavior.
Making mocks too complex
If a mock contains business logic, it becomes another system to debug. Keep it focused on the behavior under test.
Ignoring interface drift
If the production dependency changes, update the mock interface too. A stale mock can produce false confidence.
Not testing negative paths
The most expensive bugs often come from failure handling. A mock that only returns success is incomplete.
Conclusion
Mock contracts are one of the most effective tools for testing Solidity interactions. They let you isolate external dependencies, reproduce edge cases, and validate call flow with precision. When designed well, mocks make your tests faster, clearer, and more resilient.
Use them to model the behavior your contract depends on, not to replace integration testing entirely. The best Solidity test suites combine minimal, configurable mocks with real-world validation for the contracts that matter most.
