Why Testing Smart Contracts is Critical
Smart contracts are immutable once deployed — you cannot fix bugs after deployment. They often hold significant financial value, and any vulnerability can be exploited by anyone. This makes thorough testing absolutely essential. Unlike traditional software where you can push a hotfix, a smart contract bug can mean permanent loss of funds.
Testing Strategy
- Unit tests: Test individual functions in isolation
- Integration tests: Test contract interactions and workflows
- Edge case tests: Test boundary conditions, zero values, overflows, and unauthorized access
- Gas optimization tests: Ensure operations stay within expected gas limits
- Fork tests: Test against real mainnet state for protocol integrations
Basic Test Structure
// test/Vault.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { Vault } from "../typechain-types";
describe("Vault", function () {
// Fixtures are deployed once and snapshots are reused — much faster than beforeEach
async function deployVaultFixture() {
const [owner, user1, user2] = await ethers.getSigners();
const Vault = await ethers.getContractFactory("Vault");
const vault = await Vault.deploy();
return { vault, owner, user1, user2 };
}
describe("Deployment", function () {
it("should set the correct owner", async function () {
const { vault, owner } = await loadFixture(deployVaultFixture);
expect(await vault.owner()).to.equal(owner.address);
});
it("should start with zero total deposits", async function () {
const { vault } = await loadFixture(deployVaultFixture);
expect(await vault.totalDeposits()).to.equal(0n);
});
});
describe("Deposits", function () {
it("should accept deposits and update balance", async function () {
const { vault, user1 } = await loadFixture(deployVaultFixture);
const depositAmount = ethers.parseEther("1.0");
await vault.connect(user1).deposit({ value: depositAmount });
expect(await vault.balances(user1.address)).to.equal(depositAmount);
expect(await vault.totalDeposits()).to.equal(depositAmount);
});
it("should emit Deposited event", async function () {
const { vault, user1 } = await loadFixture(deployVaultFixture);
const amount = ethers.parseEther("2.0");
await expect(vault.connect(user1).deposit({ value: amount }))
.to.emit(vault, "Deposited")
.withArgs(user1.address, amount);
});
it("should reject deposits of zero ETH", async function () {
const { vault, user1 } = await loadFixture(deployVaultFixture);
await expect(
vault.connect(user1).deposit({ value: 0 })
).to.be.revertedWith("Must send ETH");
});
it("should handle multiple deposits from same user", async function () {
const { vault, user1 } = await loadFixture(deployVaultFixture);
await vault.connect(user1).deposit({ value: ethers.parseEther("1.0") });
await vault.connect(user1).deposit({ value: ethers.parseEther("2.0") });
expect(await vault.balances(user1.address)).to.equal(
ethers.parseEther("3.0")
);
});
});
describe("Withdrawals", function () {
it("should allow withdrawal of deposited funds", async function () {
const { vault, user1 } = await loadFixture(deployVaultFixture);
const amount = ethers.parseEther("5.0");
await vault.connect(user1).deposit({ value: amount });
const balanceBefore = await ethers.provider.getBalance(user1.address);
const tx = await vault.connect(user1).withdraw(amount);
const receipt = await tx.wait();
const gasCost = receipt!.gasUsed * receipt!.gasPrice;
const balanceAfter = await ethers.provider.getBalance(user1.address);
expect(balanceAfter + gasCost - balanceBefore).to.equal(amount);
});
it("should revert on insufficient balance", async function () {
const { vault, user1 } = await loadFixture(deployVaultFixture);
await expect(
vault.connect(user1).withdraw(ethers.parseEther("1.0"))
).to.be.revertedWith("Insufficient balance");
});
it("should emit Withdrawn event", async function () {
const { vault, user1 } = await loadFixture(deployVaultFixture);
const amount = ethers.parseEther("1.0");
await vault.connect(user1).deposit({ value: amount });
await expect(vault.connect(user1).withdraw(amount))
.to.emit(vault, "Withdrawn")
.withArgs(user1.address, amount);
});
});
});
Testing ERC-20 Tokens
// test/MyToken.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers";
describe("MyToken", function () {
async function deployTokenFixture() {
const [owner, alice, bob] = await ethers.getSigners();
const Token = await ethers.getContractFactory("MyToken");
const token = await Token.deploy("Test Token", "TEST", 1000000);
return { token, owner, alice, bob };
}
it("should assign total supply to deployer", async function () {
const { token, owner } = await loadFixture(deployTokenFixture);
const total = await token.totalSupply();
expect(await token.balanceOf(owner.address)).to.equal(total);
});
it("should transfer tokens between accounts", async function () {
const { token, owner, alice } = await loadFixture(deployTokenFixture);
const amount = ethers.parseUnits("100", 18);
await token.transfer(alice.address, amount);
expect(await token.balanceOf(alice.address)).to.equal(amount);
});
it("should handle approve and transferFrom", async function () {
const { token, owner, alice, bob } = await loadFixture(deployTokenFixture);
const amount = ethers.parseUnits("50", 18);
// Owner approves Alice to spend 50 tokens
await token.approve(alice.address, amount);
expect(await token.allowance(owner.address, alice.address)).to.equal(amount);
// Alice transfers 50 from owner to Bob
await token.connect(alice).transferFrom(owner.address, bob.address, amount);
expect(await token.balanceOf(bob.address)).to.equal(amount);
expect(await token.allowance(owner.address, alice.address)).to.equal(0n);
});
it("should revert transferFrom without approval", async function () {
const { token, owner, alice, bob } = await loadFixture(deployTokenFixture);
await expect(
token.connect(alice).transferFrom(
owner.address,
bob.address,
ethers.parseUnits("10", 18)
)
).to.be.reverted;
});
});
Advanced Testing Patterns
import { time, mine } from "@nomicfoundation/hardhat-toolbox/network-helpers";
describe("Advanced Patterns", function () {
// Time manipulation
it("should respect time locks", async function () {
const lockDuration = 7 * 24 * 60 * 60; // 7 days
// ... deploy time-locked contract ...
// Fast-forward time
await time.increase(lockDuration + 1);
// Now the time lock should have expired
await expect(contract.unlock()).to.not.be.reverted;
});
// Block manipulation
it("should mine blocks", async function () {
await mine(100); // Mine 100 blocks instantly
});
// Testing reverts with custom errors
it("should revert with custom error", async function () {
await expect(vault.connect(user1).withdraw(amount))
.to.be.revertedWithCustomError(vault, "InsufficientBalance")
.withArgs(0n, amount);
});
// Checking ETH balance changes
it("should change ETH balances", async function () {
await expect(
vault.connect(user1).deposit({ value: ethers.parseEther("1.0") })
).to.changeEtherBalances(
[user1, vault],
[ethers.parseEther("-1.0"), ethers.parseEther("1.0")]
);
});
// Gas usage assertions
it("should use reasonable gas", async function () {
const tx = await vault.connect(user1).deposit({
value: ethers.parseEther("1.0"),
});
const receipt = await tx.wait();
expect(receipt!.gasUsed).to.be.lessThan(100000n);
});
});
// Run tests: npx hardhat test
// With coverage: npx hardhat coverage
// Gas report: REPORT_GAS=true npx hardhat test
Testing Best Practices
- Use fixtures: loadFixture snapshots state and resets between tests — much faster than redeploying
- Test access control: Verify that only authorized accounts can call restricted functions
- Test edge cases: Zero amounts, max uint256, empty arrays, zero address, reentrancy
- Check events: Events are the primary way frontends track state changes — verify they emit correctly
- Aim for 100% coverage: Use npx hardhat coverage and address all uncovered branches
- Test failure paths: Ensure reverts happen with the right error messages for all invalid inputs