ERC-20 Token Testing: Complete Patterns for Solidity Developers
The ERC-20 standard is deceptively simple. Nine functions, two events, a few rules about return values — it reads like a two-hour implementation job. But a production ERC-20 token with extensions (burnable, mintable, permit, votes, pausable) touches enough edge cases that incomplete test suites routinely ship tokens with broken allowances, silent transfer failures, or vulnerable approval patterns.
This guide covers comprehensive ERC-20 testing: the full EIP-20 compliance suite, common extensions from OpenZeppelin, known vulnerability patterns, and the edge cases that most token test suites skip.
What EIP-20 Actually Requires
Before writing tests, understand exactly what the standard mandates. EIP-20 specifies:
transfer(to, amount)— moves tokens from caller toto. MUST returnbool. MUST revert if caller has insufficient balance. MUST emitTransfer.transferFrom(from, to, amount)— moves tokens with allowance. MUST revert if allowance or balance insufficient. MUST emitTransfer.approve(spender, amount)— sets allowance. MUST returnbool. MUST emitApproval. Setting to 0 revokes.allowance(owner, spender)— view function.balanceOf(address)— view function.totalSupply()— view function.name(),symbol(),decimals()— optional metadata functions.
The spec also says: transfers of 0 value MUST be treated as normal transfers and MUST emit Transfer. This requirement trips up many implementations.
Setting Up the Test Suite
import { expect } from "chai";
import { ethers } from "hardhat";
import { MyToken } from "../typechain-types";
describe("MyToken — EIP-20 compliance", () => {
let token: MyToken;
let owner: any;
let alice: any;
let bob: any;
let carol: any;
const INITIAL_SUPPLY = ethers.parseEther("1000000"); // 1M tokens
beforeEach(async () => {
[owner, alice, bob, carol] = await ethers.getSigners();
const MyToken = await ethers.getContractFactory("MyToken");
token = await MyToken.deploy("My Token", "MTK", INITIAL_SUPPLY);
// Give alice and bob some tokens for testing
await token.transfer(alice.address, ethers.parseEther("1000"));
await token.transfer(bob.address, ethers.parseEther("1000"));
});Testing the Core Transfer Function
describe("transfer()", () => {
it("transfers tokens between accounts", async () => {
const amount = ethers.parseEther("100");
const aliceBefore = await token.balanceOf(alice.address);
const bobBefore = await token.balanceOf(bob.address);
await token.connect(alice).transfer(bob.address, amount);
expect(await token.balanceOf(alice.address)).to.equal(aliceBefore - amount);
expect(await token.balanceOf(bob.address)).to.equal(bobBefore + amount);
});
it("emits Transfer event", async () => {
const amount = ethers.parseEther("100");
await expect(token.connect(alice).transfer(bob.address, amount))
.to.emit(token, "Transfer")
.withArgs(alice.address, bob.address, amount);
});
it("reverts if sender has insufficient balance", async () => {
const balance = await token.balanceOf(alice.address);
await expect(
token.connect(alice).transfer(bob.address, balance + 1n)
).to.be.revertedWithCustomError(token, "ERC20InsufficientBalance");
});
it("allows transfer of zero value (EIP-20 requirement)", async () => {
await expect(token.connect(alice).transfer(bob.address, 0))
.to.emit(token, "Transfer")
.withArgs(alice.address, bob.address, 0);
});
it("allows transfer to self", async () => {
const balanceBefore = await token.balanceOf(alice.address);
await token.connect(alice).transfer(alice.address, ethers.parseEther("100"));
expect(await token.balanceOf(alice.address)).to.equal(balanceBefore);
});
it("reverts on transfer to zero address", async () => {
await expect(
token.connect(alice).transfer(ethers.ZeroAddress, ethers.parseEther("100"))
).to.be.revertedWithCustomError(token, "ERC20InvalidReceiver");
});
it("totalSupply does not change on transfer", async () => {
const supplyBefore = await token.totalSupply();
await token.connect(alice).transfer(bob.address, ethers.parseEther("100"));
expect(await token.totalSupply()).to.equal(supplyBefore);
});
});Testing Approve and TransferFrom
The allowance mechanism has more edge cases than transfer:
describe("approve() and allowance()", () => {
it("sets allowance for spender", async () => {
const amount = ethers.parseEther("500");
await token.connect(alice).approve(bob.address, amount);
expect(await token.allowance(alice.address, bob.address)).to.equal(amount);
});
it("emits Approval event", async () => {
const amount = ethers.parseEther("500");
await expect(token.connect(alice).approve(bob.address, amount))
.to.emit(token, "Approval")
.withArgs(alice.address, bob.address, amount);
});
it("overwrites existing allowance (does not add)", async () => {
await token.connect(alice).approve(bob.address, ethers.parseEther("500"));
await token.connect(alice).approve(bob.address, ethers.parseEther("200"));
expect(await token.allowance(alice.address, bob.address))
.to.equal(ethers.parseEther("200"));
});
it("setting allowance to 0 revokes", async () => {
await token.connect(alice).approve(bob.address, ethers.parseEther("500"));
await token.connect(alice).approve(bob.address, 0);
expect(await token.allowance(alice.address, bob.address)).to.equal(0);
});
it("reverts approve to zero address", async () => {
await expect(
token.connect(alice).approve(ethers.ZeroAddress, ethers.parseEther("100"))
).to.be.revertedWithCustomError(token, "ERC20InvalidSpender");
});
});
describe("transferFrom()", () => {
beforeEach(async () => {
// Alice approves Bob to spend 500 tokens
await token.connect(alice).approve(bob.address, ethers.parseEther("500"));
});
it("transfers on behalf of owner", async () => {
const amount = ethers.parseEther("200");
await token.connect(bob).transferFrom(alice.address, carol.address, amount);
expect(await token.balanceOf(carol.address)).to.equal(amount);
});
it("decrements allowance after transferFrom", async () => {
const allowanceBefore = await token.allowance(alice.address, bob.address);
const amount = ethers.parseEther("200");
await token.connect(bob).transferFrom(alice.address, carol.address, amount);
expect(await token.allowance(alice.address, bob.address))
.to.equal(allowanceBefore - amount);
});
it("does not decrement infinite allowance", async () => {
await token.connect(alice).approve(bob.address, ethers.MaxUint256);
await token.connect(bob).transferFrom(
alice.address,
carol.address,
ethers.parseEther("100")
);
expect(await token.allowance(alice.address, bob.address))
.to.equal(ethers.MaxUint256);
});
it("reverts if allowance is insufficient", async () => {
await expect(
token.connect(bob).transferFrom(
alice.address,
carol.address,
ethers.parseEther("600") // exceeds 500 allowance
)
).to.be.revertedWithCustomError(token, "ERC20InsufficientAllowance");
});
it("reverts if owner has insufficient balance", async () => {
// Give bob MAX allowance
await token.connect(alice).approve(bob.address, ethers.MaxUint256);
const balance = await token.balanceOf(alice.address);
await expect(
token.connect(bob).transferFrom(alice.address, carol.address, balance + 1n)
).to.be.revertedWithCustomError(token, "ERC20InsufficientBalance");
});
it("emits Transfer event with correct from address", async () => {
await expect(
token.connect(bob).transferFrom(
alice.address,
carol.address,
ethers.parseEther("100")
)
).to.emit(token, "Transfer").withArgs(alice.address, carol.address, ethers.parseEther("100"));
});
});Testing ERC20Burnable
describe("ERC20Burnable", () => {
it("burns tokens from own balance", async () => {
const burnAmount = ethers.parseEther("100");
const balanceBefore = await token.balanceOf(alice.address);
const supplyBefore = await token.totalSupply();
await token.connect(alice).burn(burnAmount);
expect(await token.balanceOf(alice.address)).to.equal(balanceBefore - burnAmount);
expect(await token.totalSupply()).to.equal(supplyBefore - burnAmount);
});
it("emits Transfer to zero address on burn", async () => {
await expect(token.connect(alice).burn(ethers.parseEther("100")))
.to.emit(token, "Transfer")
.withArgs(alice.address, ethers.ZeroAddress, ethers.parseEther("100"));
});
it("reverts burn if balance insufficient", async () => {
const balance = await token.balanceOf(alice.address);
await expect(
token.connect(alice).burn(balance + 1n)
).to.be.revertedWithCustomError(token, "ERC20InsufficientBalance");
});
it("burnFrom burns with allowance and decrements it", async () => {
const amount = ethers.parseEther("100");
await token.connect(alice).approve(bob.address, amount);
const supplyBefore = await token.totalSupply();
await token.connect(bob).burnFrom(alice.address, amount);
expect(await token.totalSupply()).to.equal(supplyBefore - amount);
expect(await token.allowance(alice.address, bob.address)).to.equal(0);
});
it("burnFrom reverts without allowance", async () => {
await expect(
token.connect(bob).burnFrom(alice.address, ethers.parseEther("100"))
).to.be.revertedWithCustomError(token, "ERC20InsufficientAllowance");
});
});Testing ERC20Permit (EIP-2612)
ERC20Permit allows approvals via signatures, enabling gasless token approvals:
describe("ERC20Permit", () => {
it("approves via off-chain signature", async () => {
const amount = ethers.parseEther("500");
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour
const nonce = await token.nonces(alice.address);
// Build the EIP-712 typed data
const domain = {
name: await token.name(),
version: "1",
chainId: (await ethers.provider.getNetwork()).chainId,
verifyingContract: token.target as string,
};
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const value = {
owner: alice.address,
spender: bob.address,
value: amount,
nonce: nonce,
deadline: deadline,
};
const signature = await alice.signTypedData(domain, types, value);
const { v, r, s } = ethers.Signature.from(signature);
// Bob submits the permit (alice pays no gas)
await token.connect(bob).permit(
alice.address,
bob.address,
amount,
deadline,
v, r, s
);
expect(await token.allowance(alice.address, bob.address)).to.equal(amount);
});
it("rejects expired permit", async () => {
const deadline = Math.floor(Date.now() / 1000) - 1; // Already expired
// ... build signature with expired deadline ...
await expect(
token.permit(alice.address, bob.address, 100n, deadline, 27, ethers.ZeroHash, ethers.ZeroHash)
).to.be.revertedWithCustomError(token, "ERC2612ExpiredSignature");
});
it("increments nonce after permit use", async () => {
const nonceBefore = await token.nonces(alice.address);
// ... use permit ...
expect(await token.nonces(alice.address)).to.equal(nonceBefore + 1n);
});
it("rejects replayed permit (nonce mismatch)", async () => {
// Use permit once — nonce increments
// Try to use same signature again — should fail
const nonce = await token.nonces(alice.address);
// ... build and use permit ...
// Attempting to reuse the signature with the now-stale nonce
await expect(
token.permit(/* same signature */ alice.address, bob.address, 100n, deadline, v, r, s)
).to.be.revertedWithCustomError(token, "ERC2612InvalidSigner");
});
});Testing ERC20Votes
ERC20Votes adds governance functionality. These tests ensure delegation and checkpoint behavior is correct:
describe("ERC20Votes", () => {
it("has zero voting power before delegation", async () => {
// Tokens exist but voting power requires self-delegation
expect(await token.getVotes(alice.address)).to.equal(0);
});
it("gains voting power after self-delegation", async () => {
await token.connect(alice).delegate(alice.address);
expect(await token.getVotes(alice.address))
.to.equal(await token.balanceOf(alice.address));
});
it("delegates voting power to another address", async () => {
await token.connect(alice).delegate(bob.address);
expect(await token.getVotes(bob.address))
.to.equal(await token.balanceOf(alice.address));
expect(await token.getVotes(alice.address)).to.equal(0);
});
it("voting power updates on transfer", async () => {
await token.connect(alice).delegate(alice.address);
await token.connect(bob).delegate(bob.address);
const transferAmount = ethers.parseEther("200");
const aliceVotesBefore = await token.getVotes(alice.address);
await token.connect(alice).transfer(bob.address, transferAmount);
expect(await token.getVotes(alice.address))
.to.equal(aliceVotesBefore - transferAmount);
expect(await token.getVotes(bob.address))
.to.equal((await token.balanceOf(bob.address)));
});
it("getPastVotes returns historical voting power", async () => {
await token.connect(alice).delegate(alice.address);
const blockNumber = await ethers.provider.getBlockNumber();
// Move to next block
await ethers.provider.send("evm_mine", []);
// Transfer reduces current votes
await token.connect(alice).transfer(bob.address, ethers.parseEther("500"));
// Historical query returns pre-transfer votes
const pastVotes = await token.getPastVotes(alice.address, blockNumber);
expect(pastVotes).to.equal(await token.balanceOf(alice.address) + ethers.parseEther("500"));
});
});Testing Common Vulnerabilities
Approval Front-Running
The classic ERC-20 approval race condition: if Alice has approved Bob for 100 tokens and wants to change it to 200, Bob can front-run the second approve to spend 100 first, then spend 200 after — getting 300 total. OpenZeppelin's increaseAllowance/decreaseAllowance (now safeIncreaseAllowance in newer versions) mitigates this:
describe("Approval race condition mitigation", () => {
it("increaseAllowance adds to existing allowance atomically", async () => {
await token.connect(alice).approve(bob.address, ethers.parseEther("100"));
await token.connect(alice).increaseAllowance(bob.address, ethers.parseEther("50"));
expect(await token.allowance(alice.address, bob.address))
.to.equal(ethers.parseEther("150"));
});
it("decreaseAllowance reduces atomically", async () => {
await token.connect(alice).approve(bob.address, ethers.parseEther("100"));
await token.connect(alice).decreaseAllowance(bob.address, ethers.parseEther("30"));
expect(await token.allowance(alice.address, bob.address))
.to.equal(ethers.parseEther("70"));
});
it("decreaseAllowance reverts on underflow", async () => {
await token.connect(alice).approve(bob.address, ethers.parseEther("100"));
await expect(
token.connect(alice).decreaseAllowance(bob.address, ethers.parseEther("200"))
).to.be.reverted;
});
});Reentrancy in Hooks
ERC-20 tokens with hooks (ERC777-style _beforeTokenTransfer, _afterTokenTransfer) can be vulnerable to reentrancy if those hooks call external contracts:
describe("Reentrancy protection", () => {
it("cannot reenter transfer via malicious recipient hook", async () => {
const MaliciousReceiver = await ethers.getContractFactory("ReentrancyAttacker");
const attacker = await MaliciousReceiver.deploy(token.target);
await token.transfer(attacker.target, ethers.parseEther("100"));
// Attempt reentrancy attack during transfer
await expect(
attacker.attack(ethers.parseEther("50"))
).to.be.revertedWithCustomError(token, "ERC20InsufficientBalance");
// The second reentrant transfer should fail — balance already deducted
});
});Overflow and Underflow
Modern Solidity (0.8+) reverts on overflow/underflow by default. But test it explicitly for tokens with custom math:
describe("Arithmetic safety", () => {
it("reverts on transfer exceeding uint256 total supply", async () => {
// This tests that totalSupply + minted never overflows
// Only relevant for mintable tokens
const maxSupply = await token.MAX_SUPPLY?.();
if (maxSupply) {
const currentSupply = await token.totalSupply();
await expect(
token.mint(owner.address, maxSupply - currentSupply + 1n)
).to.be.reverted;
}
});
});Testing Edge Cases Most Suites Miss
describe("Edge cases", () => {
it("handles maximum uint256 approval correctly", async () => {
await token.connect(alice).approve(bob.address, ethers.MaxUint256);
// Transfer should succeed and NOT overflow the allowance subtraction
await token.connect(bob).transferFrom(
alice.address,
carol.address,
ethers.parseEther("100")
);
// Infinite allowance remains infinite
expect(await token.allowance(alice.address, bob.address))
.to.equal(ethers.MaxUint256);
});
it("transfer of full balance leaves zero balance", async () => {
const balance = await token.balanceOf(alice.address);
await token.connect(alice).transfer(bob.address, balance);
expect(await token.balanceOf(alice.address)).to.equal(0);
});
it("can transfer to and from contract addresses", async () => {
// Some tokens incorrectly block transfers to contracts
const contractAddr = token.target; // transfer to the token contract itself
// This should work (may be weird, but EIP-20 doesn't restrict it)
await expect(
token.connect(alice).transfer(contractAddr, ethers.parseEther("1"))
).to.not.be.reverted;
});
it("decimals returns expected value", async () => {
expect(await token.decimals()).to.equal(18);
});
it("name and symbol return correct values", async () => {
expect(await token.name()).to.equal("My Token");
expect(await token.symbol()).to.equal("MTK");
});
});Fuzz Testing with Foundry
Foundry's fuzzer is excellent for catching arithmetic edge cases in token math:
// test/MyTokenFuzz.t.sol
contract MyTokenFuzzTest is Test {
MyToken token;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
token = new MyToken("My Token", "MTK", 1_000_000 ether);
token.transfer(alice, 1_000 ether);
token.transfer(bob, 1_000 ether);
}
function testFuzz_transferNeverExceedsBalance(uint256 amount) public {
amount = bound(amount, 0, token.balanceOf(alice));
uint256 bobBefore = token.balanceOf(bob);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
token.transfer(bob, amount);
assertEq(token.balanceOf(alice), aliceBefore - amount);
assertEq(token.balanceOf(bob), bobBefore + amount);
}
function testFuzz_totalSupplyInvariant(uint256 transferAmount) public {
transferAmount = bound(transferAmount, 0, token.balanceOf(alice));
uint256 supplyBefore = token.totalSupply();
vm.prank(alice);
token.transfer(bob, transferAmount);
// Transfer must never change total supply
assertEq(token.totalSupply(), supplyBefore);
}
function testFuzz_allowanceDecrement(uint256 allowance, uint256 spend) public {
allowance = bound(allowance, 0, type(uint256).max - 1); // Avoid MaxUint256
spend = bound(spend, 0, allowance);
// Cap spend to alice's balance
uint256 balance = token.balanceOf(alice);
spend = bound(spend, 0, balance < allowance ? balance : allowance);
vm.prank(alice);
token.approve(bob, allowance);
vm.prank(bob);
token.transferFrom(alice, carol, spend);
assertEq(token.allowance(alice, bob), allowance - spend);
}
}Continuous Monitoring for Token Contracts
A deployed ERC-20 token is a live system. Allowance patterns change, whales move tokens, and integrations with DeFi protocols create unexpected interaction paths. Your test suite verifies correctness at deploy time, but post-deployment behavior requires ongoing observation.
HelpMeTest can monitor deployed token contracts continuously — watching for unexpected minting, unusual allowance patterns, or large transfers that suggest integration issues or attempted exploits. It's the runtime companion to the pre-deployment test coverage this guide provides.
Summary
Comprehensive ERC-20 testing covers four layers:
- EIP-20 compliance: Every function, every event, zero-value transfers, edge cases
- Extensions: Burn, permit, votes — each with their own invariants
- Vulnerabilities: Approval race conditions, reentrancy in hooks, overflow
- Edge cases: Max uint256 allowances, zero-address behavior, self-transfers
The test patterns in this guide follow the real behavior of the EIP-20 specification, not just the happy path. A token that passes all of these tests is a token that behaves correctly under adversarial conditions — which is the only standard that matters for production Solidity code.