- 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
You can take a look at the complete code in the Hedera-Code-Snippets
repository.
Prerequisites
- ⚠️ Complete tutorial part 1 as we continue from this example.
- Foundry installed:
curl -L https://foundry.paradigm.xyz | bashfoundryup
- 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
- Writing tests in Solidity for Foundry
- Understanding each test and concept
- Running tests
- Common gotchas
- Where tests fit with Hedera
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:- 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 attest/MyToken.t.sol with the suite below.
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 likeviewfor 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
vminterface inTest: powerful helpers to manipulate EVM context.
vm.prank(addr): setsmsg.senderfor the next call only.vm.startPrank(addr)/vm.stopPrank(): setsmsg.senderfor 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.
- 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
aliceusingvm.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.
- We simulate a call from
test_MintByOwner_IncrementsBalanceAndReturnsTokenId:- Simulates minting from the
owner. - Asserts token IDs start at 0 and increment.
- Checks
balanceOfandownerOfcorrectness.
- Simulates minting from the
- Burn
test_BurnByOwner_RemovesTokenAndDecrementsBalance:- Owner mints to
alice. aliceburns her token.- After burn,
ownerOf(0)must revert with OZ v5’sERC721NonexistentToken(uint256)custom error.
- Owner mints to
test_BurnRequiresOwnerOrApproved:- A non‑owner, non‑approved account (
bob) attempts to burn → expectERC721InsufficientApproval(address,uint256).
- A non‑owner, non‑approved account (
test_BurnByApprovedOperator_Succeedsandtest_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.
- Foundry will try many random addresses for
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
- 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.
- Always call
prankvsstartPrank: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).
viewtests:- Mark pure/read‑only tests as
viewfor clarity. It’s optional, but communicates intent.
- Mark pure/read‑only tests as
- Deterministic addresses:
makeAddr("label")is a nice pattern for self‑documenting tests.
- Fuzzing:
- Use
vm.assumeconstraints to avoid invalid inputs that would cause spurious reverts (e.g., zero address). - Keep fuzz tests independent of each other (no hidden global state).
- Use
- Test isolation:
setUp()runs before every test, so each test gets a fresh deployment and state.
- Naming:
- Descriptive names like
test_BurnByApprovedOperator_Succeedsmake failures easier to diagnose.
- Descriptive names like
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.