Hashtag Web3 Logo

Testing Smart Contracts

9 min
intermediate

Why Smart Contract Testing Is Different

Tests check a contract's behavior before and after changes. Include permissions, accounting, external calls, and failure cases, especially where a defect could affect user assets.

Recovery after deployment depends on the contract's design and permissions. An upgrade or pause mechanism is itself something to test.

Use tests alongside code review, deployment checks, and monitoring.

๐Ÿงช Unit Tests Single functions Hardhat / Foundry ๐ŸŽฒ Fuzz Tests Random inputs Foundry native ๐Ÿ”— Integration Multi-contract Fork mainnet ๐Ÿ›ก๏ธ Audit External review Before mainnet

Testing with Hardhat (JavaScript)

Hardhat is the most popular development framework for Ethereum. Tests are written in JavaScript or TypeScript using Mocha and Chai.

A Simple Token Test

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("SimpleToken", function () {
 let token, owner, alice;

 beforeEach(async function () {
 [owner, alice] = await ethers.getSigners();
 const Token = await ethers.getContractFactory("SimpleToken");
 token = await Token.deploy(1000); // mint 1000 tokens to deployer
 });

 it("should assign total supply to the owner", async function () {
 const balance = await token.balanceOf(owner.address);
 expect(balance).to.equal(1000);
 });

 it("should transfer tokens between accounts", async function () {
 await token.transfer(alice.address, 100);
 expect(await token.balanceOf(alice.address)).to.equal(100);
 expect(await token.balanceOf(owner.address)).to.equal(900);
 });

 it("should revert when sender has insufficient balance", async function () {
 await expect(
 token.connect(alice).transfer(owner.address, 1)
 ).to.be.revertedWith("Insufficient balance");
 });
});

What this tests:

  1. Initial state is correct (deployer gets all tokens).
  2. The core function works (transfer moves tokens).
  3. Error handling works (transferring more than you have reverts).

Run with: npx hardhat test

Testing with Foundry (Solidity)

Foundry supports Solidity tests, generated-input testing, and controls for simulating accounts, time, and other execution conditions. Runtime performance depends on the test suite and configuration.

The Same Test in Foundry

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/SimpleToken.sol";

contract SimpleTokenTest is Test {
 SimpleToken token;
 address alice = makeAddr("alice");

 function setUp() public {
 token = new SimpleToken(1000);
 }

 function test_OwnerGetsInitialSupply() public view {
 assertEq(token.balanceOf(address(this)), 1000);
 }

 function test_Transfer() public {
 token.transfer(alice, 100);
 assertEq(token.balanceOf(alice), 100);
 assertEq(token.balanceOf(address(this)), 900);
 }

 function test_RevertOnInsufficientBalance() public {
 vm.prank(alice); // next call comes from alice
 vm.expectRevert("Insufficient balance");
 token.transfer(address(this), 1);
 }
}

Run with: forge test

Fuzz Testing

Fuzz testing is where Foundry excels. Instead of testing with hardcoded values, you let the framework generate random inputs.

function testFuzz_TransferNeverExceedsSupply(uint256 amount) public {
 // Bound the amount to something reasonable
 amount = bound(amount, 0, token.totalSupply());

 token.transfer(alice, amount);

 // Invariant: total supply should never change
 assertEq(
 token.balanceOf(address(this)) + token.balanceOf(alice),
 token.totalSupply()
 );
}

Foundry will run this function with hundreds of random amount values - including 0, 1, max uint256, and everything in between. If any input breaks the invariant, Foundry reports the exact failing case.

What to Test: A Checklist

Category What to verify Example
Access control Only authorized addresses can call admin functions onlyOwner modifier works; random users get reverted
State changes Functions modify storage correctly After deposit, balance increases by exact amount
Edge cases Zero amounts, max values, empty arrays Transferring 0 tokens doesn't break anything
Reverts Invalid operations fail cleanly Withdrawing more than your balance reverts
Reentrancy External calls don't allow re-entry Callback during withdrawal can't drain the contract
Events Correct events are emitted Transfer event fires with correct from, to, amount

Key takeaways

  • Test expected behavior and failure cases before deployment and after every change.
  • Hardhat uses JavaScript tests (familiar, good ecosystem). Foundry uses Solidity tests (faster, native fuzz testing).
  • Fuzz testing automatically generates random inputs to catch edge cases you would never think of manually.
  • Always test access control first - who can call what, and what happens when unauthorized users try.

Quiz: Testing Smart Contracts

1 / 5

Why test a contract's failure cases before deployment?