How to Write Tests in Solidity(Part 2)
In this tutorial, you’ll learn how to write Solidity unit tests with Foundry for an ERC‑721 (NFT) contract that supports minting and burning. We’ll cover:
Using cheatcodes like prank, startPrank/stopPrank, expectRevert, label, and fuzzing
Testing ownership-gated minting
Testing ERC‑721 burn behavior (owner/approved)
Common gotchas with OpenZeppelin v5 custom errors
This guide stands on its own, but continues from Part 1 where we created the ERC‑721 contract and deploy/mint/burn scripts. Here, we focus exclusively on defining and writing tests.
Note: Foundry tests run locally on an in‑memory EVM; they do not use Hedera RPC. That makes them fast and deterministic. You’ll still deploy to Hedera when you run your scripts from Part 1.
Prerequisites
⚠️ Complete tutorial part 1 as we continue from this example.
Foundry installed:
curl -L https://foundry.paradigm.xyz | bash
foundryup
OpenZeppelin Contracts installed for the ERC‑721 implementation:
forge install OpenZeppelin/openzeppelin-contracts
Basic familiarity with Solidity and ERC‑721
Table of Contents
The Contract under Test
We’ll test the ERC‑721 contract that supports mint and burn. If you don’t already have it, create it:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ERC721Burnable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
contract MyToken is ERC721, ERC721Burnable, Ownable {
uint256 private _nextTokenId;
constructor(address initialOwner)
ERC721("MyToken", "MTK")
Ownable(initialOwner)
{}
function safeMint(address to) public onlyOwner returns (uint256) {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
return tokenId;
}
}
Key points:
Name/symbol: “MyToken” / “MTK”
Owner‑only
safeMint
Auto‑increment token IDs starting at 0
Burnable via
ERC721Burnable
(owner or approved may burn)
Writing tests in Solidity for Foundry
Create a test file at test/MyToken.t.sol
with the suite below.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
contract MyTokenTest is Test {
MyToken internal token;
address internal owner;
address internal alice;
address internal bob;
function setUp() public {
owner = makeAddr("owner");
alice = makeAddr("alice");
bob = makeAddr("bob");
// Deploy with explicit initial owner
token = new MyToken(owner);
// For nicer traces
vm.label(address(token), "MyToken");
vm.label(owner, "Owner");
vm.label(alice, "Alice");
vm.label(bob, "Bob");
}
/* =========================
Basics
========================= */
function test_NameAndSymbol() public view {
assertEq(token.name(), "MyToken");
assertEq(token.symbol(), "MTK");
}
function test_SupportsERC721Interface() public view {
// IERC721 interfaceId = 0x80ac58cd
assertTrue(token.supportsInterface(type(IERC721).interfaceId));
}
/* =========================
Ownership
========================= */
function test_OnlyOwnerCanMint() public {
// Non-owner tries to mint → revert with OwnableUnauthorizedAccount(address)
vm.prank(alice);
vm.expectRevert(
abi.encodeWithSignature(
"OwnableUnauthorizedAccount(address)",
alice
)
);
token.safeMint(alice);
}
function test_MintByOwner_IncrementsBalanceAndReturnsTokenId() public {
// First mint should return 0
vm.prank(owner);
uint256 id0 = token.safeMint(alice);
assertEq(id0, 0);
assertEq(token.balanceOf(alice), 1);
assertEq(token.ownerOf(0), alice);
// Second mint should return 1
vm.prank(owner);
uint256 id1 = token.safeMint(alice);
assertEq(id1, 1);
assertEq(token.balanceOf(alice), 2);
assertEq(token.ownerOf(1), alice);
}
/* =========================
Burn
========================= */
function test_BurnByOwner_RemovesTokenAndDecrementsBalance() public {
// Mint tokenId 0 to Alice
vm.startPrank(owner);
uint256 id0 = token.safeMint(alice);
vm.stopPrank();
assertEq(id0, 0);
assertEq(token.balanceOf(alice), 1);
assertEq(token.ownerOf(0), alice);
// Alice (owner) burns tokenId 0
vm.prank(alice);
token.burn(0);
// After burn: token no longer exists → ownerOf(0) should revert
vm.expectRevert(
abi.encodeWithSignature("ERC721NonexistentToken(uint256)", 0)
);
token.ownerOf(0);
// Balance drops
assertEq(token.balanceOf(alice), 0);
}
function test_BurnRequiresOwnerOrApproved() public {
// Mint tokenId 0 to Alice
vm.prank(owner);
token.safeMint(alice);
// Bob (not owner/approved) tries to burn → revert with ERC721InsufficientApproval(address,uint256)
vm.prank(bob);
vm.expectRevert(
abi.encodeWithSignature(
"ERC721InsufficientApproval(address,uint256)",
bob,
0
)
);
token.burn(0);
}
function test_BurnByApprovedOperator_Succeeds() public {
// Mint tokenId 0 to Alice
vm.prank(owner);
token.safeMint(alice);
// Alice approves Bob for tokenId 0
vm.prank(alice);
token.approve(bob, 0);
// Bob can now burn tokenId 0
vm.prank(bob);
token.burn(0);
// Token gone
vm.expectRevert(
abi.encodeWithSignature("ERC721NonexistentToken(uint256)", 0)
);
token.ownerOf(0);
assertEq(token.balanceOf(alice), 0);
}
function test_BurnByOperatorApprovedForAll_Succeeds() public {
// Mint tokenId 0 to Alice
vm.prank(owner);
token.safeMint(alice);
// Approve Bob for all of Alice's tokens
vm.prank(alice);
token.setApprovalForAll(bob, true);
// Bob can burn tokenId 0
vm.prank(bob);
token.burn(0);
vm.expectRevert(
abi.encodeWithSignature("ERC721NonexistentToken(uint256)", 0)
);
token.ownerOf(0);
assertEq(token.balanceOf(alice), 0);
}
/* =========================
Fuzzing
========================= */
function testFuzz_MintToAnyNonZeroAddress(address to) public {
vm.assume(to != address(0));
vm.assume(to.code.length == 0); // ensure EOA, not a contract
vm.prank(owner);
uint256 id = token.safeMint(to);
// id should be valid (not strictly needed to check exact id; existence is enough)
assertEq(token.ownerOf(id), to);
assertEq(token.balanceOf(to), 1);
}
}
Understanding each test and concept
Foundry test basics:
Test files go in
test/
and compile like any Solidity.A function is treated as a test if its name starts with
test
(default pattern). You can also use modifiers likeview
for read-only tests.setUp()
runs before every test, letting you deploy fresh state.Assertions come from
forge-std/Test.sol
:assertEq
,assertTrue
, etc.Cheatcodes come from the
vm
interface inTest
: powerful helpers to manipulate EVM context.
Key cheatcodes used:
vm.prank(addr)
: setsmsg.sender
for the next call only.vm.startPrank(addr)
/vm.stopPrank()
: setsmsg.sender
for multiple calls.vm.expectRevert(bytes)
: expects the next call to revert with a specific error.vm.label(addr, "name")
: names an address for prettier traces.makeAddr("salt")
: generates a deterministic address for readability.vm.assume(cond)
: discard fuzz inputs that don’t satisfy a condition.
Now, what each section does:
Basics
test_NameAndSymbol
: sanity checks for token metadata.test_SupportsERC721Interface
: verifies ERC‑721 interface support viasupportsInterface()
. This asserts the contract follows the standard interface.
Ownership
test_OnlyOwnerCanMint
:We simulate a call from
alice
usingvm.prank(alice)
.We assert it reverts with OpenZeppelin v5’s custom error
OwnableUnauthorizedAccount(address)
.The order matters:
vm.expectRevert(...)
must be set before the call that’s expected to revert.
test_MintByOwner_IncrementsBalanceAndReturnsTokenId
:Simulates minting from the
owner
.Asserts token IDs start at 0 and increment.
Checks
balanceOf
andownerOf
correctness.
Burn
test_BurnByOwner_RemovesTokenAndDecrementsBalance
:Owner mints to
alice
.alice
burns her token.After burn,
ownerOf(0)
must revert with OZ v5’sERC721NonexistentToken(uint256)
custom error.
test_BurnRequiresOwnerOrApproved
:A non‑owner, non‑approved account (
bob
) attempts to burn → expectERC721InsufficientApproval(address,uint256)
.
test_BurnByApprovedOperator_Succeeds
andtest_BurnByOperatorApprovedForAll_Succeeds
:Show both approval paths: single token approval and operator approval for all.
After burn, token no longer exists.
Fuzzing
testFuzz_MintToAnyNonZeroAddress(address to)
:Foundry will try many random addresses for
to
.vm.assume(to != address(0))
filters out zero address (which would revert in ERC‑721).We assert ownership and balance for all valid cases.
Running tests
Run all tests:
forge test
With verbose logs/traces:
forge test -vv
(more verbose),-vvv
, or-vvvv
(full traces)
Run a single test by name:
forge test --mt test_BurnByOwner_RemovesTokenAndDecrementsBalance
Run tests from a single contract:
forge test --mc MyTokenTest
Optional tooling:
Gas report: add
gas_reports = ["MyToken", "MyTokenTest"]
tofoundry.toml
, thenforge test --gas-report
Coverage:
forge coverage
(generates LCOV; integrate with your CI or IDE)
Common gotchas
Order of
expectRevert
:Always call
vm.expectRevert(...)
immediately before the call you expect to revert.
prank
vsstartPrank
:vm.prank(addr)
affects only the next call;vm.startPrank(addr)
persists untilvm.stopPrank()
. Use the latter for multi‑call sequences (e.g., deploy → call → call).
view
tests:Mark pure/read‑only tests as
view
for clarity. It’s optional, but communicates intent.
Deterministic addresses:
makeAddr("label")
is a nice pattern for self‑documenting tests.
Fuzzing:
Use
vm.assume
constraints to avoid invalid inputs that would cause spurious reverts (e.g., zero address).Keep fuzz tests independent of each other (no hidden global state).
Test isolation:
setUp()
runs before every test, so each test gets a fresh deployment and state.
Naming:
Descriptive names like
test_BurnByApprovedOperator_Succeeds
make failures easier to diagnose.
Where tests fit with Hedera
These tests run against Foundry’s local EVM, not Hedera. Use them to validate logic quickly.
When satisfied, use your scripts from Part 1 to deploy/mint/burn on Hedera Testnet or Localnet.
If something fails on-chain, add more unit tests here to replicate and fix.
Last updated
Was this helpful?