NFT Smart Contract Testing: ERC-721 and ERC-1155 Best Practices
NFT smart contracts look simple until they're not. An ERC-721 with a public mint function, a royalty mechanism, and an allowlist touches access control, financial logic, and third-party standards simultaneously. A bug in the royalty calculation means artists lose revenue on every secondary sale. An allowlist bypass means fair launches become exploitable. Testing NFT contracts thoroughly requires going beyond "does minting work" to cover every state transition and edge case users will hit on launch day.
ERC-721 vs ERC-1155: What You're Testing
ERC-721 is the standard for unique tokens. Each token ID exists exactly once, and ownership is 1:1. The key functions are mint, transfer, approve, safeTransfer, and the metadata functions tokenURI and name/symbol.
ERC-1155 is the multi-token standard. A single contract can hold fungible tokens (like in-game currency), semi-fungible tokens (like event tickets, each unique but in batches), and unique tokens. The key additions are batch operations (safeBatchTransferFrom, balanceOfBatch) and the shared uri(tokenId) metadata function.
Both standards define receiver interfaces — IERC721Receiver and IERC1155Receiver — that contracts must implement to receive tokens via the safe transfer variants. Testing that your contract correctly calls these hooks (and handles their absence) is often skipped and often exploitable.
Minting Tests
Basic minting correctness
contract ERC721MintTest is Test {
MyNFT nft;
address minter = address(0x1);
function setUp() public {
nft = new MyNFT("TestNFT", "TNFT", 10_000);
vm.deal(minter, 10 ether);
}
function test_mintAssignsCorrectOwner() public {
vm.prank(minter);
nft.mint{value: 0.08 ether}(1);
assertEq(nft.ownerOf(1), minter);
assertEq(nft.balanceOf(minter), 1);
}
function test_mintEmitsTransferEvent() public {
vm.prank(minter);
vm.expectEmit(true, true, true, true);
emit Transfer(address(0), minter, 1);
nft.mint{value: 0.08 ether}(1);
}
function test_mintFailsWithInsufficientPayment() public {
vm.prank(minter);
vm.expectRevert("Insufficient payment");
nft.mint{value: 0.07 ether}(1);
}
function test_mintFailsBeyondMaxSupply() public {
// Mint all 10,000 tokens
for (uint256 i = 0; i < 10_000; i++) {
address recipient = address(uint160(i + 1));
vm.deal(recipient, 1 ether);
vm.prank(recipient);
nft.mint{value: 0.08 ether}(1);
}
// 10,001st mint should fail
vm.prank(minter);
vm.expectRevert("Max supply reached");
nft.mint{value: 0.08 ether}(1);
}
function test_totalSupplyUpdatesOnMint() public {
assertEq(nft.totalSupply(), 0);
vm.prank(minter);
nft.mint{value: 0.08 ether}(3);
assertEq(nft.totalSupply(), 3);
}
}Allowlist minting
Allowlist mints use Merkle proofs to verify eligibility. Testing requires generating a real Merkle tree and valid/invalid proofs:
function test_allowlistMintWithValidProof() public {
bytes32[] memory proof = merkleTree.getProof(minter);
vm.prank(minter);
nft.allowlistMint{value: 0.06 ether}(1, proof);
assertEq(nft.ownerOf(1), minter);
}
function test_allowlistMintRevertsWithInvalidProof() public {
bytes32[] memory fakeProof = new bytes32[](1);
fakeProof[0] = bytes32(0);
vm.prank(minter);
vm.expectRevert("Invalid Merkle proof");
nft.allowlistMint{value: 0.06 ether}(1, fakeProof);
}
function test_allowlistAddressCannotMintTwice() public {
bytes32[] memory proof = merkleTree.getProof(minter);
vm.startPrank(minter);
nft.allowlistMint{value: 0.06 ether}(1, proof);
vm.expectRevert("Already minted");
nft.allowlistMint{value: 0.06 ether}(1, proof);
vm.stopPrank();
}Batch minting for ERC-1155
function test_batchMintAssignsCorrectBalances() public {
uint256[] memory ids = new uint256[](3);
ids[0] = 1; ids[1] = 2; ids[2] = 3;
uint256[] memory amounts = new uint256[](3);
amounts[0] = 100; amounts[1] = 50; amounts[2] = 1;
nft1155.mintBatch(minter, ids, amounts, "");
assertEq(nft1155.balanceOf(minter, 1), 100);
assertEq(nft1155.balanceOf(minter, 2), 50);
assertEq(nft1155.balanceOf(minter, 3), 1);
}Transfer Tests
Transfers are where ERC-721 bugs most commonly hide — especially the distinction between transferFrom (no receiver check) and safeTransferFrom (calls receiver hook).
function test_safeTransferToNonReceiverReverts() public {
// Deploy a contract that doesn't implement IERC721Receiver
NonReceiver nonReceiver = new NonReceiver();
vm.prank(owner);
nft.mint{value: 0.08 ether}(1);
vm.prank(owner);
vm.expectRevert("ERC721: transfer to non ERC721Receiver implementer");
nft.safeTransferFrom(owner, address(nonReceiver), 1);
}
function test_approvedAddressCanTransfer() public {
vm.prank(owner);
nft.mint{value: 0.08 ether}(1);
address operator = address(0x2);
vm.prank(owner);
nft.approve(operator, 1);
vm.prank(operator);
nft.transferFrom(owner, address(0x3), 1);
assertEq(nft.ownerOf(1), address(0x3));
// Approval should be cleared after transfer
assertEq(nft.getApproved(1), address(0));
}
function test_approvalClearedAfterTransfer() public {
vm.prank(owner);
nft.mint{value: 0.08 ether}(1);
vm.prank(owner);
nft.approve(address(0x2), 1);
vm.prank(owner);
nft.transferFrom(owner, address(0x3), 1);
assertEq(nft.getApproved(1), address(0));
}
function test_setApprovalForAllGrantsFullOperatorRights() public {
vm.prank(owner);
nft.mint{value: 0.08 ether}(1);
vm.prank(owner);
nft.mint{value: 0.08 ether}(2);
address operator = address(0x2);
vm.prank(owner);
nft.setApprovalForAll(operator, true);
vm.prank(operator);
nft.transferFrom(owner, address(0x3), 1);
vm.prank(operator);
nft.transferFrom(owner, address(0x3), 2);
assertEq(nft.ownerOf(1), address(0x3));
assertEq(nft.ownerOf(2), address(0x3));
}Royalty Testing (ERC-2981)
EIP-2981 defines a standard royalty interface that marketplaces like OpenSea and Blur use to calculate creator fees. Test both the royalty calculation and that the interface is correctly reported via supportsInterface.
function test_royaltyInfoReturnsCorrectAmount() public {
// Set 5% royalty to creator address
nft.setDefaultRoyalty(creator, 500); // 500 = 5% in basis points
(address receiver, uint256 royaltyAmount) = nft.royaltyInfo(1, 1 ether);
assertEq(receiver, creator);
assertEq(royaltyAmount, 0.05 ether); // 5% of 1 ETH sale
}
function test_royaltyBasisPointsMaximum() public {
// Cannot set royalty above 100%
vm.expectRevert("ERC2981: royalty fee exceeds salePrice");
nft.setDefaultRoyalty(creator, 10001); // 100.01%
}
function test_perTokenRoyaltyOverridesDefault() public {
nft.setDefaultRoyalty(creator, 500); // 5% default
nft.setTokenRoyalty(42, artist, 1000); // 10% for token 42
(, uint256 defaultRoyalty) = nft.royaltyInfo(1, 1 ether);
(, uint256 tokenRoyalty) = nft.royaltyInfo(42, 1 ether);
assertEq(defaultRoyalty, 0.05 ether);
assertEq(tokenRoyalty, 0.10 ether);
}
function test_supportsERC2981Interface() public {
bytes4 erc2981InterfaceId = type(IERC2981).interfaceId;
assertTrue(nft.supportsInterface(erc2981InterfaceId));
}Gas Optimization Testing
Gas costs directly affect user experience — high mint costs deter participation. Foundry's gas snapshots track per-function gas consumption across your test suite.
function test_mintGasUsage() public {
vm.prank(minter);
uint256 gasBefore = gasleft();
nft.mint{value: 0.08 ether}(1);
uint256 gasUsed = gasBefore - gasleft();
// Fail the test if minting costs more than 100k gas
assertLt(gasUsed, 100_000, "Mint is too gas expensive");
}For batch operations, test that gas scales linearly rather than exponentially:
function test_batchTransferGasScalesLinearly() public {
// Mint 100 tokens
uint256[] memory ids = _mintBatch(100);
// Transfer 10 tokens
uint256 gas10 = _measureBatchTransferGas(ids[:10]);
// Transfer 100 tokens
uint256 gas100 = _measureBatchTransferGas(ids[:100]);
// 100 transfers should use less than 15x the gas of 10 transfers
// (allowing for some overhead, but not exponential growth)
assertLt(gas100, gas10 * 15, "Batch transfer gas scales poorly");
}Common gas anti-patterns to test for
- Storing URIs as full strings on-chain (use base URI + tokenId concatenation instead)
- Using
_exists()checks that traverse mappings (use ownership directly) - Emitting events with full metadata (emit token IDs only)
- Using
EnumerableSetwhen you don't need on-chain enumeration
Fuzz Testing NFT Contracts
function testFuzz_mintAndTransfer(
address recipient,
uint256 quantity
) public {
// Bound inputs to valid ranges
vm.assume(recipient != address(0));
vm.assume(recipient.code.length == 0); // EOA only for simplicity
quantity = bound(quantity, 1, 20); // Max 20 per tx
uint256 cost = quantity * 0.08 ether;
vm.deal(minter, cost);
vm.prank(minter);
nft.mint{value: cost}(quantity);
assertEq(nft.balanceOf(minter), quantity);
// Transfer all to recipient
for (uint256 i = 1; i <= quantity; i++) {
vm.prank(minter);
nft.transferFrom(minter, recipient, i);
}
assertEq(nft.balanceOf(minter), 0);
assertEq(nft.balanceOf(recipient), quantity);
}Withdrawal Testing
NFT contracts hold ETH from mint proceeds. The withdrawal function is critical — a bug here loses all revenue:
function test_ownerCanWithdrawMintProceeds() public {
// Mint 10 tokens
for (uint256 i = 0; i < 10; i++) {
address buyer = address(uint160(i + 1));
vm.deal(buyer, 1 ether);
vm.prank(buyer);
nft.mint{value: 0.08 ether}(1);
}
uint256 contractBalance = address(nft).balance;
assertEq(contractBalance, 0.8 ether);
uint256 ownerBefore = owner.balance;
vm.prank(owner);
nft.withdraw();
assertEq(owner.balance, ownerBefore + 0.8 ether);
assertEq(address(nft).balance, 0);
}
function test_nonOwnerCannotWithdraw() public {
vm.prank(address(0x999));
vm.expectRevert("Ownable: caller is not the owner");
nft.withdraw();
}A complete NFT test suite covering these scenarios — minting, transfers, royalties, gas costs, and withdrawals — should reach 100% branch coverage before any mainnet deployment. The cost of thorough testing is measured in hours. The cost of a bug in production is measured in reputation and user funds.