Hello! Welcome to your sixth lesson in the "Modern Development & Testing Toolchain" module.
Introduction
In our last lesson, we focused on "happy path" testing, ensuring our contracts behave as expected under normal conditions. We established the Arrange-Act-Assert pattern as a universal structure for writing clear and effective tests.
While verifying correct functionality is crucial, a truly robust contract must also handle incorrect usage and unexpected situations gracefully and securely. This is where "unhappy path" testing comes in. It's the process of verifying that your contract fails when it's supposed to, and just as importantly, how it fails.
Today, we will dive into this critical aspect of smart contract testing.
Learning Outcome: By the end of this lesson, you will be able to write tests for failure conditions, including expected reverts with custom errors.
We will cover:
- Testing for generic reverts.
- Asserting specific
requirestring messages. - Verifying that the correct
custom erroris thrown, which is the modern standard for error handling in Solidity.
As before, we'll explore these concepts in both Foundry and Hardhat.
1. The Importance of Testing Failure Conditions
In web development, you test for 4xx and 5xx HTTP status codes to ensure your application handles client and server errors correctly. Testing for reverts in Solidity serves a similar purpose: it guarantees the integrity of your contract's state by preventing invalid operations.
A function can fail for many reasons: a user doesn't have permission, they haven't sent enough ETH, a deadline has passed, or an arithmetic operation would result in an overflow. By writing tests for these failure conditions, you ensure that:
- Invalid state transitions are impossible.
- Users receive clear feedback on why their transaction failed.
- The contract is secure against exploits that rely on bypassing its internal checks.
2. Testing Reverts in Foundry
Foundry provides several ways to test for reverts, from simple checks to highly specific assertions. Let's build on the Counter contract from our previous lesson, adding a function that can fail.
// src/Counter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Counter {
uint256 public count;
error NotTheOwner();
address public owner;
constructor() {
owner = msg.sender;
}
function inc() public {
count += 1;
}
// This function will underflow if count is 0
function dec() public {
count -= 1;
}
// This function can only be called by the owner
function reset() public {
if (msg.sender != owner) {
revert NotTheOwner();
}
count = 0;
}
}
2.1. Testing Generic Reverts
Foundry offers two simple ways to assert that a call reverts, without checking the specific reason.
testFailprefix: If you name your test function starting withtestFail, Foundry expects the code inside to revert.vm.expectRevert(): This is a more explicit "cheat code" that tells Foundry the very next call is expected to revert.
The following video demonstrates both of these approaches, using the dec() function which will cause an arithmetic underflow if called when count is 0.
How to Write Basic Tests | Testing with Foundry
This video from Smart Contract Programmer shows the two basic ways to test for a generic failure in Foundry.
Please watch from 03:50 to 05:32. This section covers: Using the testFail prefix to create a test that is expected to revert. An example of how calling dec() on a new contract (where count is 0) causes a revert, making the test pass.
While these methods work, they are not ideal. A test that simply checks for any revert is brittle; the function could be failing for a reason you didn't expect. It's much better to be specific.
2.2. Testing for require Messages and Custom Errors
Modern Solidity development has largely moved from require(condition, "Error message") to custom errors (revert MyCustomError()). Custom errors are significantly more gas-efficient and provide cleaner, more structured error handling. We'll cover their implementation in a later module, but for now, let's focus on how to test for them.
The vm.expectRevert() cheat code can be overloaded to check for specific error data.
- For
requirestrings:vm.expectRevert(bytes("Error message")); - For custom errors:
vm.expectRevert(ErrorName.selector);
This next video provides a perfect guide to testing both types.
Let's watch another video from Smart Contract Programmer that focuses specifically on testing error messages and custom errors.
Please watch from 02:35 to 05:03. This segment demonstrates: Testing require messages (02:35 - 03:48): How to pass the expected error string, cast to bytes, into vm.expectRevert. Testing custom errors (03:48 - 05:03): The syntax for passing a custom error's selector to vm.expectRevert to ensure the correct custom error is thrown.
Let's apply this to our Counter contract. To test the reset() function, we would write:
// test/Counter.t.sol
// Assume vm is an alias for the cheat code address
import "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
contract CounterTest is Test {
Counter public counter;
address public someOtherUser = makeAddr("someOtherUser");
function setUp() public {
counter = new Counter();
}
function testFail_ResetAsNonOwner() public {
vm.prank(someOtherUser); // Make the next call come from someOtherUser
vm.expectRevert(Counter.NotTheOwner.selector);
counter.reset();
}
}
This test is robust. It will only pass if the reset() call is reverted specifically with the NotTheOwner error.
3. Testing Reverts in Hardhat
Given your background, the testing patterns in Hardhat using ethers.js and the hardhat-chai-matchers library will feel very natural. The library extends Chai with matchers specifically for testing smart contracts.
3.1. Testing require and Custom Errors
The core pattern for testing reverts in Hardhat is:
await expect(contractCall).to.be.reverted...
It's crucial that the await is placed before expect. You are not awaiting the result of the function (which would throw an error and fail the test), but rather awaiting the resolution of the expect assertion.
The hardhat-chai-matchers library provides several ways to check for reverts:
.to.be.reverted: Checks for any revert..to.be.revertedWith("Error message"): Checks for arequireorrevertwith a specific string..to.be.revertedWithCustomError(contract, "ErrorName"): Checks for a specific custom error..withArgs(...): Can be chained torevertedWithCustomErrorto check the arguments passed to the error.
This Stack Exchange thread provides a concise summary and examples of the correct syntax.
Testing for custom error reverts in hardhat
This Stack Exchange page is a great quick reference for the modern Hardhat syntax for testing custom errors.
Please read through the answers on this page. Focus on: The first answer, which points out the common mistake of misplacing await. The second answer, which shows the clean revertedWithCustomError(contract, "ErrorName") syntax. The final answer, which introduces how to check error arguments with .withArgs(...).
3.2. A Practical Example
Let's see this in the context of a more complex contract. The following article walks through building a test suite for an auction contract. It provides excellent, real-world examples of testing failure conditions.
The Complete Hardhat Testing Guide for Secure Smart ...
This guide from Lee Marreros demonstrates how to test for custom errors in a realistic scenario. It shows how to combine time manipulation with revert assertions to test time-sensitive logic.
Please read the section '7. Placing a bid testing: errors and token transfers' and its subsections 'I' and 'II'. Pay attention to the comments in the placeBid function code, which outline the branches that need testing. In subsection 'I. Testing when bidding out of time', notice the use of time.increase to create the failure condition and revertedWithCustomError to catch the BiddingIsEnded error. In subsection 'II. Testing when a bid is lower than the higher bid', see how they orchestrate a scenario with two bidders to trigger and test the BidShouldBeHigher error.
Here is how we would test the reset() function from our Counter contract in Hardhat:
// test/Counter.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
describe("Counter", function () {
async function deployCounterFixture() {
const [owner, otherAccount] = await ethers.getSigners();
const Counter = await ethers.getContractFactory("Counter");
const counter = await Counter.deploy();
return { counter, owner, otherAccount };
}
// ... happy path tests ...
describe("Reverts", function () {
it("Should revert if reset() is called by non-owner", async function () {
const { counter, otherAccount } = await loadFixture(deployCounterFixture);
// Act & Assert
await expect(
counter.connect(otherAccount).reset()
).to.be.revertedWithCustomError(counter, "NotTheOwner");
});
it("Should revert with underflow if dec() is called at zero", async function () {
const { counter } = await loadFixture(deployCounterFixture);
// Hardhat can also catch built-in Solidity errors like arithmetic underflow/overflow.
// The error string for this changed in Solidity 0.8.20.
// For simplicity, we can just check for a generic revert.
await expect(counter.dec()).to.be.reverted;
});
});
});
This test uses counter.connect(otherAccount) to simulate a call from a different user and asserts that the transaction reverts with the expected NotTheOwner custom error.
Conclusion
You have now learned how to write tests for the "unhappy path," a fundamental skill for building secure and predictable smart contracts. By explicitly testing for failure conditions, you create a safety net that proves your contract's guards and checks are working correctly.
Key Takeaways:
- Testing for failures is as important as testing for success. It ensures your contract is secure and behaves predictably when used incorrectly.
- In Foundry, you use
vm.expectRevertwith either abytesstring forrequiremessages or an error's.selectorfor custom errors. - In Hardhat, the
hardhat-chai-matcherslibrary provides expressive assertions likerevertedWith("...")andrevertedWithCustomError(...). - Always be as specific as possible in your revert tests. Checking for a specific custom error is much more robust than checking for a generic revert.
Next Steps:
So far, we have tested state changes, return values, and reverts. There is one more critical piece of a contract's behavior to verify: events. In the next lesson, we will learn how to write tests that assert the correct events are emitted with the correct parameters, which is essential for off-chain applications and user interfaces that listen for contract activity.
Can't find a good explanation? Sign up and we'll make it for you
Sign up