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:

  1. The revert occurred
  2. The error selector/type matches
  3. The encoded arguments are correct
  4. 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.

FrameworkTypical assertion styleStrength
Foundryvm.expectRevert(abi.encodeWithSelector(...))Very precise selector and data matching
Hardhatawait expect(tx).to.be.revertedWithCustomError(contract, "ErrorName")Readable and ergonomic
TruffleManual revert checks or helper librariesFlexible, but less direct
RemixInteractive execution and console inspectionUseful 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:

SymptomLikely causeFix
Test says “expected revert, got success”The branch did not triggerRecheck setup, sender, and input values
Selector matches but args failWrong expected values or wrong orderingCompare ABI encoding and function logic
Revert data is emptyThe call hit an invalid opcode or panic instead of a custom errorInspect arithmetic, array access, or external call behavior
Different custom error appearsAnother guard condition fired firstReorder 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:

  1. Deploy the contract in setUp()
  2. Arrange the exact state needed for the failure
  3. Call expectRevert or equivalent
  4. Execute the failing call
  5. 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.

Learn more with useful resources