Good Solidity unit tests come down to six patterns: assert the exact revert, assert the exact event, test access control from a non-owner, test rounding at the edges, fuzz the properties that must always hold, and check every balance a function touches. Below is one small contract and the test for each pattern, with the bug that test catches.
I already covered setting up automated tests for smart contracts. This post assumes the setup exists and focuses on what to write once it does.
Why patterns
A contract test suite rarely fails because someone forgot to write tests. It fails because the tests pass for the wrong reason. A vm.expectRevert() with no argument passes when the call reverts for any cause, including a typo in the test setup. An event test that checks only that “an event was emitted” passes when the amounts are wrong.
Patterns give you a checklist per function. For each external function I ask which of the six apply, and write those before I look at coverage.
The contract under test
A token with an owner and a transfer fee, expressed in basis points and capped at 5%. The fee goes to the owner on every transfer.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract FeeToken {
error NotOwner();
error FeeTooHigh(uint256 bps);
error InsufficientBalance(uint256 have, uint256 need);
event Transfer(address indexed from, address indexed to, uint256 amount);
event FeeChanged(uint256 oldBps, uint256 newBps);
uint256 public constant MAX_FEE_BPS = 500;
address public owner;
uint256 public feeBps;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
constructor(uint256 supply) {
owner = msg.sender;
totalSupply = supply;
balanceOf[msg.sender] = supply;
}
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
function setFee(uint256 bps) external onlyOwner {
if (bps > MAX_FEE_BPS) revert FeeTooHigh(bps);
emit FeeChanged(feeBps, bps);
feeBps = bps;
}
function transfer(address to, uint256 amount) external returns (bool) {
uint256 bal = balanceOf[msg.sender];
if (bal < amount) revert InsufficientBalance(bal, amount);
uint256 fee = (amount * feeBps) / 10_000;
balanceOf[msg.sender] = bal - amount;
balanceOf[to] += amount - fee;
balanceOf[owner] += fee;
emit Transfer(msg.sender, to, amount - fee);
if (fee > 0) emit Transfer(msg.sender, owner, fee);
return true;
}
}
The test file starts with a shared setup. The test contract deploys the token, so it is the owner. Alice gets some tokens to spend.
import {Test} from "forge-std/Test.sol";
import {FeeToken} from "../src/FeeToken.sol";
contract FeeTokenTest is Test {
event Transfer(address indexed from, address indexed to, uint256 amount);
event FeeChanged(uint256 oldBps, uint256 newBps);
FeeToken token;
address owner = address(this);
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
token = new FeeToken(1_000_000e18);
token.transfer(alice, 10_000e18);
}
// tests below
}
The six patterns at a glance
| Pattern | What it catches | Example assertion |
|---|---|---|
| Revert test | Missing or wrong guard, wrong error | vm.expectRevert(abi.encodeWithSelector(FeeToken.FeeTooHigh.selector, 501)) |
| Event test | Wrong arguments, missing or duplicate event | vm.expectEmit(...) then emit FeeChanged(0, 250) |
| Access control test | Missing onlyOwner, wrong role | vm.prank(alice) then vm.expectRevert(FeeToken.NotOwner.selector) |
| Math and rounding test | Off-by-one at caps, rounding that favors the wrong side | assertEq(token.balanceOf(bob), 99) on a 99-wei transfer |
| Fuzz property test | Inputs you did not think of | assertEq(sumOfBalances, totalSupply) for random amounts and fees |
| State-transition test | Balances or storage that change by the wrong amount | Assert every balance a function touches, before and after |
Revert tests
Assert the specific error, with its arguments. A bare vm.expectRevert() is a test that can only fail if the call stops reverting.
function test_transfer_revertsOnInsufficientBalance() public {
vm.prank(bob);
vm.expectRevert(
abi.encodeWithSelector(FeeToken.InsufficientBalance.selector, 0, 1)
);
token.transfer(alice, 1);
}
function test_setFee_revertsAboveMax() public {
vm.expectRevert(abi.encodeWithSelector(FeeToken.FeeTooHigh.selector, 501));
token.setFee(501);
}
function test_setFee_acceptsMax() public {
token.setFee(500);
assertEq(token.feeBps(), 500);
}
The third test matters as much as the second. A cap has two edges, and the boundary value is where > and >= get confused. I changed the contract to bps >= MAX_FEE_BPS and ran the suite. test_setFee_acceptsMax failed with FeeTooHigh(500). Without that test, a boundary bug like this passes review because the revert test uses 501, which reverts either way.
I deleted the revert FeeTooHigh line too. test_setFee_revertsAboveMax failed with next call did not revert as expected.
Event tests
vm.expectEmit takes five arguments. The first four say which of topic 1, topic 2, topic 3 and the data you want to compare. The fifth is the emitter address. You then emit the event you expect, and the next call has to produce a matching log.
function test_setFee_emitsFeeChanged() public {
vm.expectEmit(false, false, false, true, address(token));
emit FeeChanged(0, 250);
token.setFee(250);
}
function test_transfer_emitsNetAndFeeTransfers() public {
token.setFee(100); // 1%
vm.startPrank(alice);
vm.expectEmit(true, true, false, true, address(token));
emit Transfer(alice, bob, 990e18);
vm.expectEmit(true, true, false, true, address(token));
emit Transfer(alice, owner, 10e18);
token.transfer(bob, 1_000e18);
vm.stopPrank();
}
FeeChanged has no indexed parameters, so the three topic flags are false and only the data is checked. Transfer has two indexed addresses, so those flags are true.
The second test pins the order and amounts of both logs. Indexers and subgraphs read these events, so a wrong amount in a log is a production bug even if the balances are right. When I removed the emit FeeChanged line, the first test failed with log != expected log.
Access control tests
Test the negative case. A test where the owner can call setFee proves nothing about whether others can.
function test_setFee_onlyOwner() public {
vm.prank(alice);
vm.expectRevert(FeeToken.NotOwner.selector);
token.setFee(100);
}
function testFuzz_setFee_nobodyElseCan(address caller) public {
vm.assume(caller != owner);
vm.prank(caller);
vm.expectRevert(FeeToken.NotOwner.selector);
token.setFee(100);
}
The fuzz version removes the guesswork about which address to try. With 256 random callers, a missing modifier shows up immediately. I deleted onlyOwner from setFee and the plain test failed with next call did not revert as expected.
Do the same for every privileged function, and for every role if you use more than one. If your contract has an onlyOwner function and no test that someone else gets rejected, I would treat the function as unprotected until proven otherwise.
Math and rounding tests
Integer division rounds down. In this contract that means a small transfer pays no fee.
function test_fee_roundsDownToZeroOnTinyAmounts() public {
token.setFee(100); // 1%
vm.prank(alice);
token.transfer(bob, 99); // 99 * 100 / 10_000 = 0
assertEq(token.balanceOf(bob), 99);
}
function test_fee_exactOnRoundAmount() public {
token.setFee(100);
uint256 ownerBefore = token.balanceOf(owner);
vm.prank(alice);
token.transfer(bob, 1_000e18);
assertEq(token.balanceOf(bob), 990e18);
assertEq(token.balanceOf(owner) - ownerBefore, 10e18);
}
The first test documents a decision. Whether rounding down is acceptable depends on the contract, but the test makes the choice visible. If someone changes the formula to round up, the test fails. I tried (amount * feeBps + 9_999) / 10_000 and got assertion failed: 98 != 99.
Test the values around rounding boundaries: zero, one, the largest amount that gives a zero fee, the smallest that gives a nonzero fee, and a round number where you can compute the answer by hand. Never compute the expected value in the test with the same formula as the contract. You would be testing that the formula equals itself.
Fuzz tests for properties
When a rule must hold for every input, state it as a property and let Foundry pick the inputs. The property here is that no transfer creates or destroys tokens.
function testFuzz_transfer_conservesSupply(uint96 amount, uint16 bps) public {
bps = uint16(bound(bps, 0, 500));
amount = uint96(bound(amount, 0, 10_000e18));
token.setFee(bps);
vm.prank(alice);
token.transfer(bob, amount);
uint256 sum = token.balanceOf(owner) + token.balanceOf(alice) + token.balanceOf(bob);
assertEq(sum, token.totalSupply());
}
Use bound instead of vm.assume for ranges. vm.assume discards inputs that miss the range, and Foundry gives up when too many are discarded. bound maps every input into the range.
I removed the line that credits the owner with the fee. The plain tests caught it, and so did the fuzz test, with a counterexample showing supply short by the fee. For a bug in rounding logic, the fuzz test is the one that finds the odd amount you would not have written by hand.
State-transition tests
For each function, list every storage value it can change, and assert all of them. People forget the side effects.
function test_transfer_movesExactlyTheRightBalances() public {
token.setFee(200); // 2%
uint256 ownerBefore = token.balanceOf(owner);
vm.prank(alice);
token.transfer(bob, 500e18);
assertEq(token.balanceOf(alice), 9_500e18);
assertEq(token.balanceOf(bob), 490e18);
assertEq(token.balanceOf(owner), ownerBefore + 10e18);
assertEq(token.totalSupply(), 1_000_000e18);
}
function test_feeChange_appliesToLaterTransfersOnly() public {
token.setFee(100);
vm.prank(alice);
token.transfer(bob, 1_000e18);
token.setFee(0);
vm.prank(alice);
token.transfer(bob, 1_000e18);
assertEq(token.balanceOf(bob), 990e18 + 1_000e18);
}
The first test asserts four values after one call. When I removed the line that pays the owner, it failed with 990000000000000000000000 != 990010000000000000000000. A test that checked only Bob’s balance would have passed.
The second test is a sequence. Some bugs only show up when two calls interact, such as a setting change that retroactively alters earlier state.
The same checks in Hardhat
If your project uses Hardhat, the revert and event patterns map directly. These run against the same contract with @nomicfoundation/hardhat-toolbox and ethers v6.
Hardhat versions of the revert and event tests
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("FeeToken", () => {
let token, owner, alice, bob;
beforeEach(async () => {
[owner, alice, bob] = await ethers.getSigners();
token = await ethers.deployContract("FeeToken", [ethers.parseUnits("1000000", 18)]);
await token.transfer(alice.address, ethers.parseUnits("10000", 18));
});
it("reverts when the balance is too low", async () => {
await expect(token.connect(bob).transfer(alice.address, 1))
.to.be.revertedWithCustomError(token, "InsufficientBalance")
.withArgs(0, 1);
});
it("reverts when a non-owner sets the fee", async () => {
await expect(token.connect(alice).setFee(100))
.to.be.revertedWithCustomError(token, "NotOwner");
});
it("emits FeeChanged with old and new value", async () => {
await expect(token.setFee(250)).to.emit(token, "FeeChanged").withArgs(0, 250);
});
it("emits both Transfer events when a fee applies", async () => {
await token.setFee(100);
const amount = ethers.parseUnits("1000", 18);
await expect(token.connect(alice).transfer(bob.address, amount))
.to.emit(token, "Transfer").withArgs(alice.address, bob.address, ethers.parseUnits("990", 18))
.and.to.emit(token, "Transfer").withArgs(alice.address, owner.address, ethers.parseUnits("10", 18));
});
});
revertedWithCustomError(...).withArgs(...) is the Hardhat counterpart of encoding the selector and arguments by hand. Both approaches check the specific error, not just that something reverted.
Common mistakes
- Bare
expectRevert. It passes on any revert. Always give the selector or message. - Testing only the owner path. Every privileged function needs a test where someone else calls it.
- Asserting one balance. If a function moves value between three accounts, assert all three and the total.
- Copying the contract’s formula into the test. Compute expected values by hand for a few round numbers, then let fuzz tests check the properties.
- Skipping the boundary. Test the cap, cap plus one, zero, and one.
- Never breaking the contract. After writing a test, delete the line it protects and watch it fail. A test you have never seen fail is a guess.
Next step
Pick one external function in your own contract and check it against the table. Which of the six patterns have no test yet? Write those, then do the delete-the-line check on each. If the numbers matter, for example in a DeFi contract with several token types, build proper test data first. For contracts with minting and approvals, the NFT edge cases post shows more cases of the same kind.