
Testing Solidity Custom Errors with Precise Revert Assertions
Why custom errors deserve dedicated tests
Before custom errors, many contracts used require(condition, "message") or revert("message"). That works, but it has drawbacks:
- String reverts are more expensive than custom errors.
- Error messages are harder to standardize across a codebase.
- Tests that compare strings can become fragile when messages change.
Custom errors solve these problems by encoding failure data in a typed, compact form:
error InsufficientBalance(address account, uint256 requested, uint256 available);Instead of checking only that a call reverted, you can verify:
- the exact error type,
- the exact arguments,
- and whether the revert happened at the intended branch.
That matters in production systems where multiple failure paths can exist in the same function.
A simple contract with custom errors
Consider a minimal vault contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
error ZeroDeposit();
error InsufficientBalance(address account, uint256 requested, uint256 available);
mapping(address => uint256) private balances;
function deposit() external payable {
if (msg.value == 0) revert ZeroDeposit();
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
uint256 current = balances[msg.sender];
if (amount > current) {
revert InsufficientBalance(msg.sender, amount, current);
}
balances[msg.sender] = current - amount;
payable(msg.sender).transfer(amount);
}
function balanceOf(address account) external view returns (uint256) {
return balances[account];
}
}This contract has two failure modes:
deposit()rejects zero-value calls.withdraw()rejects requests above the caller’s balance.
Both are ideal candidates for custom error assertions.
What to assert in tests
A good custom error test should verify more than “it reverted.” At minimum, check:
- The revert occurred
- The error selector/type matches
- The encoded arguments are correct
- State was not modified before the revert
That last point is important. A revert should roll back state, but tests should still confirm the contract did not partially mutate state before failing.
Testing patterns across frameworks
Different Solidity testing stacks expose different APIs, but the idea is the same.
| Framework | Typical assertion style | Strength |
|---|---|---|
| Foundry | vm.expectRevert(abi.encodeWithSelector(...)) | Very precise selector and data matching |
| Hardhat | await expect(tx).to.be.revertedWithCustomError(contract, "ErrorName") | Readable and ergonomic |
| Truffle | Manual revert checks or helper libraries | Flexible, but less direct |
| Remix | Interactive execution and console inspection | Useful for quick debugging, not large suites |
If you use Foundry or Hardhat, custom error testing is straightforward. The examples below focus on the Solidity-side logic and the assertions you should aim for.
Foundry: precise selector and argument matching
Foundry is especially strong for custom error testing because it lets you match the exact ABI-encoded revert data.
Zero-value deposit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
contract VaultTest is Test {
Vault vault;
function setUp() external {
vault = new Vault();
}
function testDepositRevertsOnZeroValue() external {
vm.expectRevert(Vault.ZeroDeposit.selector);
vault.deposit{value: 0}();
}
}This test checks the selector only. That is enough when the error has no parameters.
Revert with parameters
For parameterized errors, encode the full payload:
function testWithdrawRevertsWithCorrectData() external {
address user = address(0x1234);
vm.deal(user, 1 ether);
vm.prank(user);
vault.deposit{value: 1 ether}();
vm.prank(user);
vm.expectRevert(
abi.encodeWithSelector(
Vault.InsufficientBalance.selector,
user,
2 ether,
1 ether
)
);
vault.withdraw(2 ether);
}This is the most reliable form of assertion because it verifies the exact revert payload.
Why full encoding is better than selector-only checks
Selector-only checks tell you that someInsufficientBalance error occurred, but not whether the values were correct. If your contract uses the same error in multiple branches, selector-only assertions can miss bugs.
For example, if a bug swaps requested and available, the selector still matches. Full encoding catches that immediately.
Hardhat: readable assertions with custom errors
If you prefer JavaScript or TypeScript tests, Hardhat provides a concise API:
await expect(vault.deposit({ value: 0 }))
.to.be.revertedWithCustomError(vault, "ZeroDeposit");For parameterized errors:
await expect(vault.connect(user).withdraw(ethers.parseEther("2")))
.to.be.revertedWithCustomError(vault, "InsufficientBalance")
.withArgs(user.address, ethers.parseEther("2"), ethers.parseEther("1"));This style is highly readable and works well for application-level tests. The key best practice is to always include .withArgs(...) when the error carries data.
Testing edge cases that reveal real bugs
Custom errors are most useful when they protect important boundaries. Focus on cases where invalid inputs are likely in production.
1. Boundary values
Test values exactly at the threshold and just beyond it.
Examples:
- zero deposit
- withdrawing exactly the balance
- withdrawing one wei more than the balance
- calling a function with an empty array when at least one item is required
Boundary tests often reveal off-by-one mistakes.
2. Caller-dependent errors
If the error includes msg.sender, verify the test uses the correct prank or connected account. A common mistake is to assert against the wrong address because the test harness defaulted to a different sender.
3. State-dependent errors
If the error depends on prior actions, set up the state explicitly in the test. Do not rely on hidden fixture behavior. For example, deposit funds first, then attempt an over-withdrawal.
4. Multi-branch errors
If a function can revert for several reasons, write one test per branch. This keeps failures diagnosable and prevents one assertion from masking another.
A practical testing checklist
Use the following checklist when adding custom error tests:
- Define custom errors near the contract interface or top of the contract.
- Prefer typed errors over string messages for new code.
- Test both selector-only and parameterized errors appropriately.
- Assert exact arguments for errors carrying context.
- Verify state remains unchanged after revert.
- Cover boundary values and caller-specific conditions.
- Keep one failure reason per test.
Debugging failed custom error assertions
When a custom error test fails, the issue is usually one of these:
| Symptom | Likely cause | Fix |
|---|---|---|
| Test says “expected revert, got success” | The branch did not trigger | Recheck setup, sender, and input values |
| Selector matches but args fail | Wrong expected values or wrong ordering | Compare ABI encoding and function logic |
| Revert data is empty | The call hit an invalid opcode or panic instead of a custom error | Inspect arithmetic, array access, or external call behavior |
| Different custom error appears | Another guard condition fired first | Reorder setup or isolate the branch under test |
A useful debugging technique is to temporarily simplify the test case and reproduce the revert with the smallest possible input set. That makes it easier to identify which branch is actually executing.
Avoiding brittle tests
Custom error tests should be strict, but not overfit to implementation details.
Good practice: assert the public contract behavior
If the contract promises “withdrawals above balance revert with InsufficientBalance,” test that. Do not assert on internal helper functions or private implementation paths unless they are part of the intended behavior.
Good practice: use named errors consistently
A codebase is easier to test when errors are semantically clear:
error Unauthorized(address caller);
error InvalidFee(uint256 provided, uint256 maxAllowed);
error DeadlinePassed(uint256 deadline, uint256 currentTime);These names make tests self-documenting.
Avoid: reusing one generic error everywhere
A single OperationFailed() error may be convenient, but it removes diagnostic value. Tests become less meaningful because they can no longer distinguish failure causes.
When custom errors are especially valuable
Custom error assertions are particularly useful in contracts that have:
- role or permission checks,
- financial limits and accounting rules,
- parameter validation,
- protocol integrations with multiple failure paths,
- user-facing actions where precise failure reasons matter.
For example, in a lending protocol, a borrow function might revert for:
- insufficient collateral,
- market paused,
- borrow cap exceeded,
- account under liquidation.
Each should be a distinct custom error, and each should have its own test.
A recommended test structure
A clean test file often follows this pattern:
- Deploy the contract in
setUp() - Arrange the exact state needed for the failure
- Call
expectRevertor equivalent - Execute the failing call
- Assert post-revert state if relevant
Example structure in Foundry:
function testWithdrawRevertsWhenBalanceTooLow() external {
address alice = address(0xA11CE);
vm.deal(alice, 1 ether);
vm.prank(alice);
vault.deposit{value: 1 ether}();
vm.prank(alice);
vm.expectRevert(
abi.encodeWithSelector(
Vault.InsufficientBalance.selector,
alice,
2 ether,
1 ether
)
);
vault.withdraw(2 ether);
assertEq(vault.balanceOf(alice), 1 ether);
}The final assertion confirms the failed call did not alter state.
Conclusion
Custom errors make Solidity contracts cheaper and more expressive, but their real value appears when you test them precisely. By asserting the exact selector and encoded arguments, you can verify both the failure condition and the contract’s diagnostic behavior.
For production-grade tests, focus on boundary cases, caller-specific branches, and state rollback. That combination gives you high-confidence coverage without relying on fragile string comparisons.
