TechLead
Lesson 11 of 20
5 min read
Web3 & Blockchain

Testing Smart Contracts

Write comprehensive tests for Solidity smart contracts using Hardhat, Mocha, Chai, and ethers.js with coverage analysis

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

Continue Learning