
Testing Revert Reasons and Custom Errors in Solidity
Why revert testing matters
Reverts are part of normal contract behavior. A token contract may revert when a transfer exceeds balance, a vault may revert when a withdrawal is unauthorized, and a governance contract may revert when a proposal is malformed. If your tests only check that a transaction fails, they miss an important guarantee: the contract should fail for the intended reason.
Testing revert behavior helps you:
- confirm access control rules
- validate input constraints
- detect unexpected control flow
- protect against regressions when error handling changes
- document contract behavior through executable examples
A good revert test is precise. It should distinguish between:
- a revert with a string message
- a revert with a custom error
- a panic caused by arithmetic or array bounds
- a low-level failure from an external call
Revert mechanisms in Solidity
Solidity exposes several ways to stop execution:
| Mechanism | Typical use | Test implication |
|---|---|---|
require(condition, "message") | Input validation, preconditions | Assert the exact string |
revert("message") | Explicit failure path | Assert the exact string |
revert CustomError(args) | Gas-efficient domain errors | Assert selector and encoded arguments |
assert(condition) | Internal invariants | Expect a panic code |
| Panic errors | Overflow, division by zero, out-of-bounds access | Assert panic selector/code |
String reverts are easy to read, but custom errors are more efficient and more structured. In production contracts, custom errors are often the better default because they reduce deployment and runtime gas costs and make error handling more expressive.
A practical contract example
Consider a simple escrow contract with both string reverts and custom errors:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Escrow {
address public immutable seller;
address public buyer;
uint256 public price;
bool public funded;
error NotSeller();
error NotBuyer();
error AlreadyFunded();
error IncorrectPayment(uint256 expected, uint256 received);
constructor(address _buyer, uint256 _price) {
seller = msg.sender;
buyer = _buyer;
price = _price;
}
function fund() external payable {
if (msg.sender != buyer) revert NotBuyer();
if (funded) revert AlreadyFunded();
if (msg.value != price) revert IncorrectPayment(price, msg.value);
funded = true;
}
function release() external {
require(msg.sender == seller, "Only seller can release");
require(funded, "Escrow not funded");
funded = false;
payable(seller).transfer(price);
}
}This contract is intentionally mixed: fund() uses custom errors, while release() uses string-based require. That gives us a useful comparison for test design.
Testing string reverts
String reverts are straightforward to assert, but the exact syntax depends on your testing framework. The key idea is always the same: call the function, expect failure, and compare the revert string.
A typical test flow looks like this:
- deploy the contract
- call a function from an unauthorized account
- assert the revert message
Example pseudocode for a test runner:
// Example structure only; adapt to your framework
function testReleaseFailsForNonSeller() public {
vm.prank(buyer);
vm.expectRevert(bytes("Only seller can release"));
escrow.release();
}Best practices for string reverts
- Keep messages short and specific.
- Use consistent wording across the codebase.
- Avoid embedding dynamic values in strings if they are not essential.
- Treat revert strings as part of the contract’s public behavior if external integrators rely on them.
Common pitfall: brittle message matching
String matching can become fragile if you later rephrase a message. For example, changing "Only seller can release" to "Seller only" will break tests even though the logic is unchanged. That is one reason many teams prefer custom errors for new contracts.
Testing custom errors
Custom errors are the modern Solidity approach for structured failure handling. They are declared at contract scope and can accept typed arguments.
In the escrow example:
NotBuyer()has no argumentsIncorrectPayment(uint256 expected, uint256 received)includes details
Testing custom errors requires asserting the encoded error signature, not just a human-readable string.
Why custom errors are better for tests
Custom errors are useful because they:
- are cheaper than revert strings
- encode structured data
- are easier to match precisely
- scale better when error conditions grow more complex
A test for IncorrectPayment should verify both the error type and the values passed into it.
Example test pattern
function testFundRevertsWithIncorrectPayment() public {
vm.prank(buyer);
vm.expectRevert(
abi.encodeWithSelector(
Escrow.IncorrectPayment.selector,
price,
1 ether
)
);
escrow.fund{value: 1 ether}();
}This test is stronger than a string comparison because it checks:
- the exact error selector
- the expected amount
- the actual amount
If the contract later changes to a different error type, the test fails immediately.
Testing panic errors and invariants
Not every revert is intentional in the business-logic sense. Solidity also emits panic errors for runtime faults such as:
- arithmetic overflow or underflow
- division by zero
- invalid enum conversion
- out-of-bounds array access
These are especially important when you want to verify that an invariant is protected by the compiler or by explicit checks.
Example: panic from array access
function testArrayOutOfBoundsPanics() public {
uint256[] memory values = new uint256[](1);
values[0] = 42;
vm.expectRevert(stdError.indexOOBError);
uint256 x = values[1];
x;
}If your framework does not provide a helper constant, you can assert the panic selector and code directly. The important point is that panic errors should be tested differently from business-logic reverts.
When to test for panic
Use panic assertions when:
- the contract intentionally relies on Solidity safety checks
- you want to ensure no hidden unchecked arithmetic exists
- a low-level bug would be catastrophic and should never be masked
Do not use panic tests as a substitute for explicit validation. If a user input should be rejected, prefer require or a custom error.
Comparing revert assertion styles
Different failure types call for different assertion strategies.
| Revert type | Example | Recommended assertion |
|---|---|---|
| String revert | require(x, "bad input") | Exact string match |
| Custom error without args | revert NotAuthorized() | Selector match |
| Custom error with args | revert InvalidAmount(expected, actual) | Selector + encoded args |
| Panic | Overflow, OOB access | Panic selector/code |
| Low-level failure | External call returns false | Check returned success flag or bubbled revert |
Use the narrowest assertion that still reflects the intended contract behavior. Overly broad assertions can hide regressions; overly specific ones can make tests brittle.
Handling external call failures
Contracts often interact with tokens, routers, or other protocols. External calls can fail in several ways:
- the callee reverts with a string
- the callee reverts with a custom error
- the callee returns
false - the call runs out of gas or hits a panic
When testing wrappers around external calls, verify both the local behavior and the propagated failure.
Example scenario
Suppose a payment router calls an ERC20 token transfer. A robust test should confirm:
- the wrapper reverts if the token transfer fails
- the wrapper does not swallow the failure silently
- the contract state is unchanged after failure
A common mistake is to test only the revert and ignore state. If a function mutates state before the external call and then fails to revert cleanly, your test should catch it.
Recommended pattern
- set up the failing dependency
- call the wrapper
- expect the failure
- assert that no state changed
This is especially important in contracts that perform multi-step operations.
Designing stable revert tests
Revert tests should be precise, but not unnecessarily fragile. The goal is to protect behavior, not implementation details.
Prefer custom errors for new code
If you are starting a new contract, use custom errors for most validation failures. They are easier to test robustly and cheaper to execute.
Keep error names semantic
Choose names that describe the condition, not the UI text.
Good:
NotSeller()IncorrectPayment(expected, received)DeadlinePassed()
Less useful:
Error1()InvalidState2()
Test the contract boundary, not internal branches
A test should express the behavior a caller observes. Avoid writing tests that depend on internal helper functions unless those helpers are part of the public API.
Use helper functions for repeated assertions
If many tests expect the same revert, factor out a helper to reduce duplication. This keeps the suite readable and makes message updates easier.
A focused test matrix for revert behavior
For contracts with multiple failure modes, a small matrix helps ensure coverage:
| Function | Caller | Input | Expected failure |
|---|---|---|---|
fund() | non-buyer | any value | NotBuyer() |
fund() | buyer | wrong value | IncorrectPayment(price, msg.value) |
fund() | buyer | correct value after funded | AlreadyFunded() |
release() | non-seller | funded | "Only seller can release" |
release() | seller | not funded | "Escrow not funded" |
This style makes it easy to see whether each branch is covered and whether the failure reason matches the intended rule.
Debugging failed revert tests
When a revert test fails, the cause is usually one of these:
- the wrong caller was set
- the wrong value was sent
- the contract reverted earlier than expected
- the revert type changed from string to custom error or vice versa
- the test expected the wrong selector or encoded arguments
Practical debugging steps
- Confirm the transaction context: sender, value, and block state.
- Inspect whether the revert happens before the line you expected.
- Check if a helper or modifier is causing the failure.
- Verify the exact revert encoding.
- Compare the deployed bytecode version with the source used in the test.
If your framework supports traces, use them to identify the first failing opcode or the exact revert site. That is often faster than reading the test output alone.
Migration tip: from strings to custom errors
Many codebases begin with string-based require statements and later migrate to custom errors. This is a good time to update tests carefully.
A safe migration plan:
- introduce custom errors alongside existing logic
- update tests to assert selectors and arguments
- remove obsolete string assertions
- standardize error naming across the codebase
If you must preserve backward compatibility for integrators, keep the external behavior stable and document the change clearly. Otherwise, a test suite that asserts exact revert strings will break during the migration.
Conclusion
Testing revert reasons and custom errors is one of the most valuable habits in Solidity development. It turns failure paths into explicit, verifiable behavior and gives you confidence that your contract rejects invalid actions for the right reasons.
For new contracts, prefer custom errors, assert their selectors and arguments, and reserve string reverts for cases where human-readable messages are essential. Combine revert assertions with state checks, and your tests will be both more precise and more resilient.
