Skip to content

Testing Smart Contracts with Hardhat — Complete Guide

DodaTech 8 min read

In this tutorial, you'll learn about Testing Smart Contracts with Hardhat. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

This tutorial teaches you to test Solidity smart contracts with Hardhat — you will write unit tests in JavaScript, simulate mainnet conditions with forking, measure gas costs, and use the built-in debugger to catch bugs before deployment.

Why It Matters

Smart contracts are immutable — bugs discovered after deployment cannot be patched without complex upgrade mechanisms. Testing is not optional. The most successful DeFi protocols invest heavily in test coverage, fuzz testing, and integration tests that simulate real-world conditions.

Real-World Use

A Uniswap V3 fork simulates thousands of trades across different price ranges before deployment. A lending protocol forks mainnet to test its liquidation engine with real market data. An NFT contract tests minting, trading, and royalty payments across multiple scenarios.

flowchart LR
    A[Write Contract] --> B[Unit Tests]
    B --> C[Integration Tests]
    C --> D[Fork Tests]
    D --> E[Gas Analysis]
    E --> F[Coverage Report]
    F --> G[Deploy]
    B --> H[Debugging]
    H --> A
    style A fill:#4CAF50,color:#fff
    style D fill:#FF9800,color:#fff
    style F fill:#2196F3,color:#fff
    style G fill:#4CAF50,color:#fff

Setting Up Hardhat

Initialize a Hardhat project with TypeScript support and the necessary plugins.

// hardhat.config.js
require('@nomicfoundation/hardhat-toolbox');

module.exports = {
  solidity: {
    version: '0.8.20',
    settings: {
      optimizer: { enabled: true, runs: 200 },
    },
  },
  networks: {
    hardhat: {
      chainId: 31337,
    },
    sepolia: {
      url: 'https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY',
      accounts: [process.env.PRIVATE_KEY],
    },
  },
  gasReporter: {
    enabled: true,
    currency: 'USD',
    coinmarketcap: process.env.COINMARKETCAP_API_KEY,
  },
};
# Initialize project
mkdir my-contract-test && cd my-contract-test
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init

# Expected output

Expected output:

888     d8b 888 888 888 888 888 888 888
888     Y8P 888 888 888 888 888 888 888
888         888 888 888 888 888 888 888
888888 8888 888 888 888 888 888 888 888 888

Creating a new Hardhat project... Done

Writing Unit Tests

Unit tests verify individual contract functions in isolation.

// test/Token.test.js
const { expect } = require('chai');
const { ethers } = require('hardhat');

describe('Token', function () {
  let Token;
  let token;
  let owner;
  let addr1;
  let addr2;

  beforeEach(async function () {
    [owner, addr1, addr2] = await ethers.getSigners();
    Token = await ethers.getContractFactory('Token');
    token = await Token.deploy('MyToken', 'MTK', 1000000);
  });

  describe('Deployment', function () {
    it('Should set the correct name and symbol', async function () {
      expect(await token.name()).to.equal('MyToken');
      expect(await token.symbol()).to.equal('MTK');
    });

    it('Should assign total supply to owner', async function () {
      const ownerBalance = await token.balanceOf(owner.address);
      expect(await token.totalSupply()).to.equal(ownerBalance);
    });
  });

  describe('Transfers', function () {
    it('Should transfer tokens between accounts', async function () {
      await token.transfer(addr1.address, 1000);
      const addr1Balance = await token.balanceOf(addr1.address);
      expect(addr1Balance).to.equal(1000);
    });

    it('Should fail if sender does not have enough balance', async function () {
      await expect(
        token.connect(addr1).transfer(owner.address, 1)
      ).to.be.revertedWith('Insufficient balance');
    });
  });
});
# Run tests
npx hardhat test

Expected output:

  Token
    Deployment
      ✓ Should set the correct name and symbol
      ✓ Should assign total supply to owner
    Transfers
      ✓ Should transfer tokens between accounts
      ✓ Should fail if sender does not have enough balance

  4 passing (823ms)

Mainnet Forking

Forking lets you test against real protocol state without risking real funds.

// test/ForkTest.js
const { expect } = require('chai');
const { ethers, network } = require('hardhat');

describe('Mainnet Fork Tests', function () {
  const DAI_ADDRESS = '0x6B175474E89094C44Da98b954EedeAC495271d0F';
  const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
  const UNISWAP_ROUTER = '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D';

  before(async function () {
    await network.provider.request({
      method: 'hardhat_reset',
      params: [{
        forking: {
          jsonRpcUrl: 'https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY',
          blockNumber: 19500000,
        },
      }],
    });
  });

  it('Should read real DAI and USDC balances', async function () {
    const dai = await ethers.getContractAt('IERC20', DAI_ADDRESS);
    const usdc = await ethers.getContractAt('IERC20', USDC_ADDRESS);

    const whaleAddress = '0x...'; // known whale address
    const daiBalance = await dai.balanceOf(whaleAddress);
    const usdcBalance = await usdc.balanceOf(whaleAddress);

    console.log(`DAI balance: ${ethers.formatEther(daiBalance)}`);
    console.log(`USDC balance: ${ethers.formatUnits(usdcBalance, 6)}`);
  });

  it('Should simulate a swap on Uniswap', async function () {
    const dai = await ethers.getContractAt('IERC20', DAI_ADDRESS);
    const router = await ethers.getContractAt(
      'IUniswapV2Router02', UNISWAP_ROUTER
    );

    const amountIn = ethers.parseEther('1000');
    const amounts = await router.getAmountsOut(amountIn, [
      DAI_ADDRESS, USDC_ADDRESS,
    ]);
    console.log(`1000 DAI swaps to ${ethers.formatUnits(amounts[1], 6)} USDC`);
    expect(amounts[1]).to.be.gt(0);
  });
});
npx hardhat test test/ForkTest.js --network hardhat

Expected output:

  Mainnet Fork Tests
DAI balance: 5000000.0
USDC balance: 8000000.0
1000 DAI swaps to 999.5 USDC
    ✓ Should read real DAI and USDC balances
    ✓ Should simulate a swap on Uniswap

  2 passing (4.2s)

The fork loads Ethereum state at block 19,500,000. You control all accounts and can impersonate any address without holding its private key.

Gas Analysis

Measure and optimize gas consumption before deployment.

// test/GasTest.js
const { expect } = require('chai');
const { ethers } = require('hardhat');

describe('Gas Analysis', function () {
  it('Should measure deployment gas', async function () {
    const factory = await ethers.getContractFactory('Token');
    const tx = await factory.getDeployTransaction('GasToken', 'GAS', 1000000);
    const estimatedGas = await ethers.provider.estimateGas(tx);
    console.log(`Deployment estimated gas: ${estimatedGas}`);
  });

  it('Should measure transfer gas costs', async function () {
    const [owner, addr1] = await ethers.getSigners();
    const Token = await ethers.getContractFactory('Token');
    const token = await Token.deploy('GasToken', 'GAS', 1000000);
    await token.waitForDeployment();

    // Measure a single transfer
    const tx = await token.transfer(addr1.address, 1000);
    const receipt = await tx.wait();
    console.log(`Transfer gas used: ${receipt.gasUsed}`);

    // Batch transfers for average
    let totalGas = 0n;
    const count = 10;
    for (let i = 0; i < count; i++) {
      const t = await token.transfer(addr1.address, 1);
      const r = await t.wait();
      totalGas += r.gasUsed;
    }
    const average = totalGas / BigInt(count);
    console.log(`Average transfer gas: ${average}`);
  });
});
npx hardhat test test/GasTest.js --gas

Expected output:

  Gas Analysis
Deployment estimated gas: 512345
Transfer gas used: 51234
Average transfer gas: 49876
    ✓ Should measure deployment gas
    ✓ Should measure transfer gas costs

  2 passing (1.2s)

Debugging with console.log

Hardhat's built-in console.log works in Solidity during tests.

// contracts/DebugToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "hardhat/console.sol";

contract DebugToken {
    string public name;
    mapping(address => uint256) public balances;

    constructor(string memory _name) {
        name = _name;
        console.log("Deploying DebugToken:", _name);
        console.log("Deployer balance:", msg.sender.balance);
    }

    function transfer(address to, uint256 amount) public returns (bool) {
        console.log("Transferring", amount, "from", msg.sender, "to", to);
        console.log("Sender balance before:", balances[msg.sender]);

        require(balances[msg.sender] >= amount, "Insufficient balance");

        balances[msg.sender] -= amount;
        balances[to] += amount;

        console.log("Sender balance after:", balances[msg.sender]);
        console.log("Receiver balance after:", balances[to]);
        return true;
    }
}

Expected output during test run:

Deploying DebugToken: DebugToken
Deployer balance: 10000000000000000000
Transferring 500 from 0xf39Fd6e51aad88F6F4ce6aB882a7279cffFb92266 to 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
Sender balance before: 1000
Sender balance after: 500
Receiver balance after: 500

The console.log output appears in the terminal during npx hardhat test. This is invaluable for debugging complex state changes without writing separate test assertions.

Common Errors

  1. Testing only the happy path — Most bugs appear in edge cases: zero amounts, maximum values, reentrancy, and unexpected caller addresses. Test every require and revert condition.
  2. Not using fixtures for expensive setup — Deploying contracts in every test is slow. Use Hardhat's loadFixture to snapshot and restore state.
  3. Ignoring time-dependent behavior — Many contracts use block.timestamp or block.number. Use evm_increaseTime and evm_mine to test time-dependent logic.
  4. Testing without coverage reporting — Run npx hardhat coverage to see which lines are untested. Aim for 95%+ branch coverage for core logic.
  5. Not testing on a mainnet fork — Real-world tokenomics, oracle behavior, and protocol interactions cannot be simulated with mock data alone. Fork mainnet for integration tests.

Practice Questions

  1. What is the advantage of Hardhat Network over Ganache for testing? Hardhat Network has built-in mainnet forking, console.log debugging, stack traces for reverts, and faster execution. It also supports evm_mine for time manipulation and runs Solidity 0.8.x natively without workarounds.

  2. How does mainnet forking help with DeFi Integration Testing? Forking loads real Uniswap pools, DAI/USDC reserves, and oracle prices. Tests can verify that your contract correctly interacts with production protocols, handles real-world token decimals, and works with actual staking contracts — without spending real gas or risking funds.

  3. What is the purpose of gas analysis in Contract Testing? Gas analysis identifies expensive operations before deployment. A function costing 500,000 gas might be too expensive for users. Optimizations like caching storage reads, using unchecked blocks safely, and batching operations can reduce costs. Gas reports also help estimate deployment and interaction costs.

Frequently Asked Questions

{{< faq question="Can I test contracts that depend on oracles or external data?">}} Yes. Use Hardhat's mainnet forking to use real Chainlink oracles, or set mock oracle values using hardhat_setStorageAt to write arbitrary state. For time-sensitive oracles, use evm_increaseTime to advance the chain to the oracle's next update window. The DeFi guide covers testing with live oracle data. {{< /faq >}}

{{< faq question="How do I test upgradeable proxy contracts?">}} Deploy the proxy, implementation, and admin contracts in your test fixture. Test interactions through the proxy to verify delegation works correctly. Then deploy a new implementation and test that upgrading preserves state. Use loadFixture to avoid re-deploying the proxy setup in every test. {{< /faq >}}

{{< faq question="How do I handle large test suites with many contract interactions?">}} Use Hardhat's loadFixture to snapshot state after expensive setup operations. Organize tests by contract and feature area. Run tests in parallel with --parallel flag. Use TypeScript for better type safety with complex return values. Consider separating unit tests (fast) from fork tests (slow) into different test scripts.{{< /faq >}}

Next Steps

Smart Contract Security Audits — Prepare your tested contracts for professional auditing.

Solidity Design Patterns — Write contracts that are easier to test and verify.

Building Web3 Frontends with ethers.js — Connect your tested contracts to a frontend.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro