Why revert assertions deserve precision

A generic “transaction reverted” check is often too weak. It can hide regressions where:

  • a validation message changes unexpectedly,
  • a custom error is replaced by a generic revert,
  • arithmetic or array access triggers a panic instead of an intended guard,
  • a low-level call fails for the wrong reason.

Precise revert assertions help you lock down contract semantics. They are especially useful for public APIs, where revert behavior becomes part of the developer experience and often influences front-end error handling.

Common revert categories

Failure typeTypical sourceBest assertion style
Revert stringrequire(condition, "message")Match exact string
Custom errorrevert Unauthorized(msg.sender)Match error selector and arguments
Panic codeDivision by zero, overflow in checked math, out-of-bounds accessMatch panic code
Low-level call failurecall, delegatecall, staticcallAssert success flag and returned data

Revert strings: simple, but easy to overuse

Revert strings are the most familiar form of failure reporting:

function withdraw(uint256 amount) external {
    require(amount > 0, "Amount must be positive");
    require(balance[msg.sender] >= amount, "Insufficient balance");

    balance[msg.sender] -= amount;
    payable(msg.sender).transfer(amount);
}

In tests, verify the exact message when the string is part of the public contract behavior. This is useful for user-facing validation, but avoid relying on strings for internal logic that may evolve.

Foundry example

function testWithdrawRejectsZeroAmount() public {
    vm.expectRevert(bytes("Amount must be positive"));
    vault.withdraw(0);
}

Best practices for revert strings

  • Keep messages short and stable.
  • Use them for user-facing validation, not deep internal invariants.
  • Avoid asserting on strings if the contract is likely to be localized or refactored frequently.
  • Prefer custom errors for gas efficiency and structured testing.

Custom errors: the preferred modern pattern

Custom errors are cheaper than revert strings and easier to test precisely. They also encode arguments, which makes them ideal for debugging and for downstream tooling.

error ZeroAmount();
error InsufficientBalance(address account, uint256 available, uint256 required);

function withdraw(uint256 amount) external {
    if (amount == 0) revert ZeroAmount();

    uint256 available = balance[msg.sender];
    if (available < amount) {
        revert InsufficientBalance(msg.sender, available, amount);
    }

    balance[msg.sender] = available - amount;
    payable(msg.sender).transfer(amount);
}

Testing custom errors in Foundry

Foundry lets you assert the exact selector and encoded arguments:

function testWithdrawRejectsZeroAmount() public {
    vm.expectRevert(ZeroAmount.selector);
    vault.withdraw(0);
}

function testWithdrawRejectsInsufficientBalance() public {
    vm.expectRevert(
        abi.encodeWithSelector(
            InsufficientBalance.selector,
            address(this),
            0,
            1 ether
        )
    );
    vault.withdraw(1 ether);
}

Why this is better

Custom errors make tests more expressive:

  • The selector proves the exact error type.
  • The encoded arguments prove the contract observed the expected state.
  • Tests become more resilient than string matching, especially across refactors.

A practical caution

If your test depends on msg.sender, make sure the caller is set explicitly. Otherwise, the expected encoded address may be wrong. In Foundry, use vm.prank(user) or vm.startPrank(user) before the call.


Panic codes: catching compiler-generated failures

Not all failures come from your explicit checks. Solidity emits panics for certain runtime errors, such as:

  • arithmetic overflow or underflow in checked math,
  • division or modulo by zero,
  • array out-of-bounds access,
  • invalid enum conversions,
  • popping an empty array.

These are not business-rule failures. They indicate a runtime fault or an unsafe operation.

Example contract

contract PanicDemo {
    function divide(uint256 a, uint256 b) external pure returns (uint256) {
        return a / b;
    }

    function read(uint256[] calldata values, uint256 index) external pure returns (uint256) {
        return values[index];
    }
}

Foundry panic assertions

Foundry can assert panic codes directly:

function testDivideByZeroPanics() public {
    vm.expectRevert(stdError.divisionError);
    demo.divide(10, 0);
}

function testOutOfBoundsPanics() public {
    uint256[] memory values = new uint256[](1);
    values[0] = 42;

    vm.expectRevert(stdError.indexOOBError);
    demo.read(values, 1);
}

If your test framework does not provide named panic helpers, you can match the panic selector and code manually. The panic selector is 0x4e487b71, and the code is ABI-encoded as a uint256.

When to assert panic codes

Use panic assertions when you want to ensure:

  • the code path is truly unreachable under valid inputs,
  • a guard is not silently replacing a compiler check,
  • unsafe operations remain protected by explicit preconditions.

Matching the right failure mode

A useful test suite distinguishes between “expected rejection” and “unexpected bug.” The same input may fail in different ways depending on implementation details.

Consider a token transfer function:

function transfer(address to, uint256 amount) external {
    if (to == address(0)) revert ZeroAddress();
    if (amount == 0) revert ZeroAmount();
    if (balance[msg.sender] < amount) revert InsufficientBalance(msg.sender, balance[msg.sender], amount);

    balance[msg.sender] -= amount;
    balance[to] += amount;
}

A good test suite checks each branch independently:

  1. zero recipient reverts with ZeroAddress,
  2. zero amount reverts with ZeroAmount,
  3. insufficient balance reverts with InsufficientBalance,
  4. a valid transfer updates balances.

This separation makes failures easier to diagnose. If a test expecting ZeroAmount instead gets a panic, you have likely introduced an unsafe operation or reordered logic incorrectly.


Low-level calls and revert data

When testing integrations, you often interact with contracts through low-level calls. In that case, the call does not automatically bubble up a typed error in your test harness. You need to inspect the returned success flag and revert data.

Example

(bool ok, bytes memory data) = target.call(
    abi.encodeWithSignature("withdraw(uint256)", 1 ether)
);

require(!ok, "call should fail");

For precise testing, decode the revert data:

function testLowLevelCallRevertData() public {
    (bool ok, bytes memory data) = address(vault).call(
        abi.encodeWithSignature("withdraw(uint256)", 0)
    );

    assertTrue(!ok);

    bytes4 selector = bytes4(data);
    assertEq(selector, ZeroAmount.selector);
}

This approach is especially useful when testing proxies, routers, and adapter contracts that intentionally use low-level calls to preserve flexibility.

Best practices for revert data

  • Assert the selector first.
  • Decode arguments only when they are stable and meaningful.
  • Avoid brittle tests that depend on full ABI-encoded bytes unless necessary.
  • If the call crosses multiple contract boundaries, verify the final surfaced error, not just the first internal failure.

Structuring tests for maintainability

Precise revert assertions are most valuable when the tests are organized around behavior rather than implementation details.

Recommended pattern

  • One test per failure branch.
  • One test for the happy path.
  • Shared setup in helper functions.
  • Explicit caller and state setup before each revert expectation.

Example test layout

function _setBalance(address user, uint256 amount) internal {
    vault.setBalance(user, amount);
}

function testWithdrawHappyPath() public {
    _setBalance(address(this), 1 ether);
    vault.withdraw(1 ether);
    assertEq(address(this).balance, 1 ether);
}

function testWithdrawZeroAmountReverts() public {
    vm.expectRevert(ZeroAmount.selector);
    vault.withdraw(0);
}

function testWithdrawInsufficientBalanceReverts() public {
    _setBalance(address(this), 0.5 ether);

    vm.expectRevert(
        abi.encodeWithSelector(
            InsufficientBalance.selector,
            address(this),
            0.5 ether,
            1 ether
        )
    );
    vault.withdraw(1 ether);
}

This structure keeps each assertion focused and prevents accidental coupling between tests.


Choosing between strings, custom errors, and panics

The right assertion depends on the source of failure and the contract’s API design.

ScenarioPreferred mechanismReason
User input validationCustom errorGas-efficient and structured
Human-readable external messageRevert stringEasy for front-end display
Compiler/runtime faultPanic codeDistinguishes unsafe execution from business logic
External integration failureLow-level revert dataPreserves data across call boundaries

A strong rule of thumb: if the failure is part of your contract’s intended behavior, make it a custom error. If it is a compiler/runtime issue, assert the panic. If the failure is only for convenience or legacy compatibility, a string may still be acceptable.


Debugging failing revert tests

When a revert assertion fails, the issue is often one of the following:

  • The caller is wrong, so msg.sender-dependent logic does not match.
  • The test state is not initialized as expected.
  • A previous call changed storage and altered the branch taken.
  • The contract now reverts earlier than before, producing a different error.
  • The test is matching the wrong error type or selector.

Practical debugging tips

  1. Check the caller first. Many revert mismatches are caused by missing prank setup.
  2. Inspect intermediate state. Print or assert balances, allowances, and flags before the call.
  3. Separate setup from action. Avoid hiding state changes inside the same helper that performs the revert assertion.
  4. Prefer exact selectors over broad revert checks. A generic revert can mask regressions.
  5. Use one assertion per failure path. This makes it obvious which condition changed.

If your framework supports tracing, use it to confirm where the revert originates. That is often faster than guessing from the top-level failure.


A concise testing checklist

Before you finalize a revert-focused test suite, verify the following:

  • Every public validation branch has a dedicated test.
  • Custom errors are asserted by selector and arguments.
  • Panic conditions are tested where unsafe operations exist.
  • Low-level calls decode and inspect revert data.
  • Caller identity is explicit in tests that depend on msg.sender.
  • Happy-path tests confirm that valid inputs do not accidentally revert.

Conclusion

Precise revert assertions turn failure handling into a first-class part of your Solidity test strategy. Instead of checking only that a transaction failed, you verify that it failed for the correct reason, with the correct data, at the correct boundary. That makes tests more expressive, more stable, and far more useful during debugging.

For modern Solidity development, custom errors should usually be your default. Use revert strings when human-readable messages matter, panic assertions when you want to catch compiler-generated faults, and low-level revert decoding when testing integrations. Together, these techniques give you a robust way to validate contract behavior under failure conditions.

Learn more with useful resources