Testing ERC-20 and ERC-721 Token Contracts: The Complete Guide
Token contracts are the most deployed category of smart contracts on Ethereum. They're also where bugs are most expensive—a flawed approval mechanism or an unchecked transfer function directly puts user funds at risk.
ERC-20 and ERC-721 are both defined by the Ethereum Improvement Proposal process. The interfaces are simple, but the edge cases are not. This guide covers how to test both standards thoroughly, including approval flows, transfer restrictions, metadata, and the subtle differences between token standards.
What Makes Token Testing Different
Standard library contracts (OpenZeppelin) handle the base implementation. What needs testing is your code: custom mint restrictions, burn mechanics, royalty logic, transfer hooks, and access control layered on top. That's where bugs hide.
The testing philosophy for tokens:
- Test every state transition (mint, burn, transfer, approve)
- Test every permission boundary (who can call what)
- Test every numerical edge case (zero amounts, max supply, overflow)
- Test events (downstream systems depend on them)
- Test revert conditions (what must NOT be allowed)
ERC-20 Testing with Hardhat
The Contract
// contracts/StakedToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
contract StakedToken is ERC20, ERC20Burnable, ERC20Permit, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
uint256 public immutable maxSupply;
bool public transfersPaused;
error TransfersPaused();
error MaxSupplyExceeded(uint256 requested, uint256 available);
constructor(
string memory name,
string memory symbol,
uint256 _maxSupply,
address admin
) ERC20(name, symbol) ERC20Permit(name) {
maxSupply = _maxSupply;
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(MINTER_ROLE, admin);
_grantRole(PAUSER_ROLE, admin);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
uint256 available = maxSupply - totalSupply();
if (amount > available) revert MaxSupplyExceeded(amount, available);
_mint(to, amount);
}
function pause() external onlyRole(PAUSER_ROLE) {
transfersPaused = true;
}
function unpause() external onlyRole(PAUSER_ROLE) {
transfersPaused = false;
}
function _update(
address from,
address to,
uint256 value
) internal override {
// Allow minting and burning even when paused
if (transfersPaused && from != address(0) && to != address(0)) {
revert TransfersPaused();
}
super._update(from, to, value);
}
}The Test File
// test/StakedToken.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { loadFixture } = require("@nomicfoundation/hardhat-toolbox/network-helpers");
describe("StakedToken (ERC-20)", function () {
async function deployFixture() {
const [admin, minter, pauser, alice, bob, charlie] = await ethers.getSigners();
const MAX_SUPPLY = ethers.parseEther("10000000"); // 10M tokens
const StakedToken = await ethers.getContractFactory("StakedToken");
const token = await StakedToken.deploy(
"Staked Token",
"sTKN",
MAX_SUPPLY,
admin.address
);
const MINTER_ROLE = await token.MINTER_ROLE();
const PAUSER_ROLE = await token.PAUSER_ROLE();
return { token, admin, minter, pauser, alice, bob, charlie, MAX_SUPPLY, MINTER_ROLE, PAUSER_ROLE };
}
describe("Transfer mechanics", function () {
it("standard transfer moves balance", async function () {
const { token, admin, alice, bob } = await loadFixture(deployFixture);
const amount = ethers.parseEther("1000");
await token.connect(admin).mint(alice.address, amount);
await expect(token.connect(alice).transfer(bob.address, amount))
.to.changeTokenBalances(token, [alice, bob], [-amount, amount]);
});
it("transferFrom requires prior approval", async function () {
const { token, admin, alice, bob, charlie } = await loadFixture(deployFixture);
const amount = ethers.parseEther("500");
await token.connect(admin).mint(alice.address, amount);
// No approval yet — should fail
await expect(
token.connect(bob).transferFrom(alice.address, charlie.address, amount)
).to.be.revertedWithCustomError(token, "ERC20InsufficientAllowance");
// Approve then transfer
await token.connect(alice).approve(bob.address, amount);
await expect(
token.connect(bob).transferFrom(alice.address, charlie.address, amount)
).to.changeTokenBalance(token, charlie, amount);
});
it("approval is consumed on transferFrom", async function () {
const { token, admin, alice, bob, charlie } = await loadFixture(deployFixture);
const amount = ethers.parseEther("200");
await token.connect(admin).mint(alice.address, amount * 2n);
await token.connect(alice).approve(bob.address, amount);
await token.connect(bob).transferFrom(alice.address, charlie.address, amount);
expect(await token.allowance(alice.address, bob.address)).to.equal(0n);
});
it("approve emits Approval event", async function () {
const { token, alice, bob } = await loadFixture(deployFixture);
const amount = ethers.parseEther("100");
await expect(token.connect(alice).approve(bob.address, amount))
.to.emit(token, "Approval")
.withArgs(alice.address, bob.address, amount);
});
});
describe("Pause functionality", function () {
it("pausing blocks user-to-user transfers", async function () {
const { token, admin, alice, bob } = await loadFixture(deployFixture);
const amount = ethers.parseEther("100");
await token.connect(admin).mint(alice.address, amount);
await token.connect(admin).pause();
await expect(
token.connect(alice).transfer(bob.address, amount)
).to.be.revertedWithCustomError(token, "TransfersPaused");
});
it("minting still works when paused", async function () {
const { token, admin, alice } = await loadFixture(deployFixture);
await token.connect(admin).pause();
await expect(token.connect(admin).mint(alice.address, ethers.parseEther("100")))
.to.changeTokenBalance(token, alice, ethers.parseEther("100"));
});
it("burning still works when paused", async function () {
const { token, admin, alice } = await loadFixture(deployFixture);
const amount = ethers.parseEther("100");
await token.connect(admin).mint(alice.address, amount);
await token.connect(admin).pause();
await expect(token.connect(alice).burn(amount))
.to.changeTokenBalance(token, alice, -amount);
});
it("unpausing restores transfers", async function () {
const { token, admin, alice, bob } = await loadFixture(deployFixture);
const amount = ethers.parseEther("100");
await token.connect(admin).mint(alice.address, amount);
await token.connect(admin).pause();
await token.connect(admin).unpause();
await expect(token.connect(alice).transfer(bob.address, amount))
.to.not.be.reverted;
});
});
describe("ERC-20 Permit (gasless approvals)", function () {
it("permit sets allowance with valid signature", async function () {
const { token, alice, bob } = await loadFixture(deployFixture);
const amount = ethers.parseEther("100");
const deadline = Math.floor(Date.now() / 1000) + 3600;
const nonce = await token.nonces(alice.address);
const domain = {
name: await token.name(),
version: "1",
chainId: (await ethers.provider.getNetwork()).chainId,
verifyingContract: await token.getAddress(),
};
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const signature = await alice.signTypedData(domain, types, {
owner: alice.address,
spender: bob.address,
value: amount,
nonce,
deadline,
});
const { v, r, s } = ethers.Signature.from(signature);
await token.permit(alice.address, bob.address, amount, deadline, v, r, s);
expect(await token.allowance(alice.address, bob.address)).to.equal(amount);
});
});
});ERC-721 (NFT) Testing
NFT contracts add token ownership by ID, metadata URIs, and often royalty logic. The approval model is also different—per-token approval vs. operator approval for all tokens.
// contracts/ArtNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Royalty.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract ArtNFT is ERC721URIStorage, ERC721Royalty, Ownable {
uint256 private _nextTokenId;
uint256 public mintPrice = 0.05 ether;
uint256 public maxSupply = 10_000;
error MaxSupplyReached();
error InsufficientPayment(uint256 sent, uint256 required);
error WithdrawFailed();
constructor(address initialOwner)
ERC721("Art NFT", "ARTNFT")
Ownable(initialOwner)
{
// Set 5% royalty to the owner
_setDefaultRoyalty(initialOwner, 500); // 500 = 5%
}
function mint(address to, string memory tokenURI) external payable returns (uint256) {
if (_nextTokenId >= maxSupply) revert MaxSupplyReached();
if (msg.value < mintPrice) revert InsufficientPayment(msg.value, mintPrice);
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, tokenURI);
return tokenId;
}
function ownerMint(address to, string memory tokenURI) external onlyOwner returns (uint256) {
if (_nextTokenId >= maxSupply) revert MaxSupplyReached();
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, tokenURI);
return tokenId;
}
function withdraw() external onlyOwner {
(bool success, ) = owner().call{value: address(this).balance}("");
if (!success) revert WithdrawFailed();
}
// Required overrides for multiple inheritance
function tokenURI(uint256 tokenId) public view override(ERC721, ERC721URIStorage) returns (string memory) {
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId) public view override(ERC721URIStorage, ERC721Royalty) returns (bool) {
return super.supportsInterface(interfaceId);
}
function _update(address to, uint256 tokenId, address auth) internal override(ERC721, ERC721URIStorage) returns (address) {
return super._update(to, tokenId, auth);
}
function _increaseBalance(address account, uint128 value) internal override(ERC721, ERC721URIStorage) {
super._increaseBalance(account, value);
}
}// test/ArtNFT.test.js
describe("ArtNFT (ERC-721)", function () {
async function deployNFTFixture() {
const [owner, alice, bob, marketplace] = await ethers.getSigners();
const ArtNFT = await ethers.getContractFactory("ArtNFT");
const nft = await ArtNFT.deploy(owner.address);
const mintPrice = await nft.mintPrice();
return { nft, owner, alice, bob, marketplace, mintPrice };
}
describe("Minting", function () {
it("mints NFT with correct payment", async function () {
const { nft, alice, mintPrice } = await loadFixture(deployNFTFixture);
const tx = await nft.connect(alice).mint(
alice.address,
"ipfs://QmExampleHash1",
{ value: mintPrice }
);
const receipt = await tx.wait();
const tokenId = 0n;
expect(await nft.ownerOf(tokenId)).to.equal(alice.address);
expect(await nft.tokenURI(tokenId)).to.equal("ipfs://QmExampleHash1");
});
it("reverts on insufficient payment", async function () {
const { nft, alice, mintPrice } = await loadFixture(deployNFTFixture);
await expect(
nft.connect(alice).mint(alice.address, "ipfs://test", {
value: mintPrice - 1n,
})
).to.be.revertedWithCustomError(nft, "InsufficientPayment")
.withArgs(mintPrice - 1n, mintPrice);
});
it("emits Transfer event on mint", async function () {
const { nft, alice, mintPrice } = await loadFixture(deployNFTFixture);
await expect(
nft.connect(alice).mint(alice.address, "ipfs://test", { value: mintPrice })
).to.emit(nft, "Transfer")
.withArgs(ethers.ZeroAddress, alice.address, 0n);
});
});
describe("Approvals and transfers", function () {
async function mintedFixture() {
const base = await deployNFTFixture();
await base.nft.connect(base.alice).mint(
base.alice.address,
"ipfs://QmToken0",
{ value: base.mintPrice }
);
return base;
}
it("owner can approve another address for a specific token", async function () {
const { nft, alice, bob } = await loadFixture(mintedFixture);
await nft.connect(alice).approve(bob.address, 0n);
expect(await nft.getApproved(0n)).to.equal(bob.address);
});
it("approved address can transfer the token", async function () {
const { nft, alice, bob } = await loadFixture(mintedFixture);
await nft.connect(alice).approve(bob.address, 0n);
await nft.connect(bob).transferFrom(alice.address, bob.address, 0n);
expect(await nft.ownerOf(0n)).to.equal(bob.address);
// Approval is cleared after transfer
expect(await nft.getApproved(0n)).to.equal(ethers.ZeroAddress);
});
it("setApprovalForAll grants operator rights over all tokens", async function () {
const { nft, alice, marketplace, bob } = await loadFixture(mintedFixture);
await nft.connect(alice).setApprovalForAll(marketplace.address, true);
expect(await nft.isApprovedForAll(alice.address, marketplace.address)).to.be.true;
// Marketplace can transfer Alice's tokens
await nft.connect(marketplace).transferFrom(alice.address, bob.address, 0n);
expect(await nft.ownerOf(0n)).to.equal(bob.address);
});
it("non-approved address cannot transfer", async function () {
const { nft, alice, bob } = await loadFixture(mintedFixture);
await expect(
nft.connect(bob).transferFrom(alice.address, bob.address, 0n)
).to.be.revertedWithCustomError(nft, "ERC721InsufficientApproval");
});
});
describe("Royalties (ERC-2981)", function () {
it("reports correct royalty info", async function () {
const { nft, owner, alice, mintPrice } = await loadFixture(deployNFTFixture);
await nft.connect(alice).mint(alice.address, "ipfs://test", { value: mintPrice });
const salePrice = ethers.parseEther("1");
const [receiver, royaltyAmount] = await nft.royaltyInfo(0n, salePrice);
expect(receiver).to.equal(owner.address);
// 5% of 1 ETH = 0.05 ETH
expect(royaltyAmount).to.equal(ethers.parseEther("0.05"));
});
});
describe("Withdrawal", function () {
it("owner can withdraw contract balance", async function () {
const { nft, owner, alice, mintPrice } = await loadFixture(deployNFTFixture);
await nft.connect(alice).mint(alice.address, "ipfs://test", { value: mintPrice });
await expect(nft.connect(owner).withdraw())
.to.changeEtherBalance(owner, mintPrice);
});
it("non-owner cannot withdraw", async function () {
const { nft, alice } = await loadFixture(deployNFTFixture);
await expect(nft.connect(alice).withdraw())
.to.be.revertedWithCustomError(nft, "OwnableUnauthorizedAccount");
});
});
describe("Interface support", function () {
it("supports ERC-721 interface", async function () {
const { nft } = await loadFixture(deployNFTFixture);
expect(await nft.supportsInterface("0x80ac58cd")).to.be.true; // ERC721
});
it("supports ERC-2981 royalty interface", async function () {
const { nft } = await loadFixture(deployNFTFixture);
expect(await nft.supportsInterface("0x2a55205a")).to.be.true; // ERC2981
});
});
});Key Differences: ERC-20 vs ERC-721 Testing
| Aspect | ERC-20 | ERC-721 |
|---|---|---|
| Ownership unit | Balance (fungible amount) | Token ID (non-fungible) |
| Approval scope | Amount approved | Per-token or operator-for-all |
| Transfer events | Transfer(from, to, value) |
Transfer(from, to, tokenId) |
| Burn pattern | burn(amount) |
burn(tokenId) |
| Enumeration | Not required | ERC-721 Enumerable extension |
Testing for Zero Address Edge Cases
Both standards must reject zero-address transfers:
it("cannot transfer to zero address", async function () {
const { token, admin, alice } = await loadFixture(deployFixture);
await token.connect(admin).mint(alice.address, ethers.parseEther("100"));
await expect(
token.connect(alice).transfer(ethers.ZeroAddress, ethers.parseEther("100"))
).to.be.revertedWithCustomError(token, "ERC20InvalidReceiver");
});Gas Usage Assertions
Track gas costs in your tests to catch regressions:
it("mint gas cost is within budget", async function () {
const { nft, alice, mintPrice } = await loadFixture(deployNFTFixture);
const tx = await nft.connect(alice).mint(
alice.address,
"ipfs://QmTest",
{ value: mintPrice }
);
const receipt = await tx.wait();
// Alert if mint becomes more expensive than 150k gas
expect(receipt.gasUsed).to.be.lessThan(150_000n);
});Summary
Testing token contracts requires covering the full surface area: minting, burning, transfers, approvals, access control, event emissions, and numerical edge cases. The patterns shown here—fixtures, revert assertions, event matching, and balance change helpers—apply to any ERC-20 or ERC-721 contract.
The Hardhat Chai matchers (changeTokenBalance, revertedWithCustomError, emit) make these assertions readable and maintainable. Write them before you write the contract, and you'll ship tokens you can stand behind.