Hardhat Guide: Automated Smart Contract Testing

September 22, 2024

Hardhat is a powerful development environment for Ethereum smart contracts that simplifies building, testing, and deploying. Here's what you need to know about automated testing with Hardhat:

  • Automated testing catches mistakes before contracts are deployed permanently
  • Hardhat provides a local blockchain for testing without spending real ETH
  • It integrates well with testing frameworks like Mocha and Chai
  • Hardhat offers tools to manipulate blockchain time for testing time-dependent logic
  • Gas usage can be measured and optimized using Hardhat Gas Reporter

Key steps for testing smart contracts with Hardhat:

  1. Set up your development environment
  2. Write test files using describe blocks, it blocks, and hooks
  3. Use fixtures to reuse setup code efficiently
  4. Test for errors and reverts
  5. Simulate time passage for time-dependent contracts
  6. Measure and optimize gas usage
  7. Integrate with CI/CD for automated testing on each code push

Best practices:

  • Create thorough test suites covering all contract parts
  • Keep tests independent by resetting state
  • Use console.log() for debugging
  • Aim for high code coverage
  • Test on multiple networks

Automated testing with Hardhat helps build more reliable and secure smart contracts by catching issues early in the development process.

Setting up your development environment

Let's get your Hardhat testing environment up and running. Here's what you need to do:

1. Install Hardhat and tools

First, make sure Node.js is installed. Then:

mkdir hardhat-testing
cd hardhat-testing
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox

2. Set up Hardhat

Run:

npx hardhat init

Choose "Create a JavaScript project" and follow the wizard.

3. Create project structure

Your project should now look like this:

Folder/File Purpose
contracts/ Smart contracts
scripts/ Deploy scripts
test/ Test files
hardhat.config.js Hardhat config

4. Clean up and prepare

  • Delete scripts/deploy.js and contracts/Lock.sol
  • Create a .env file (don't share this!):
ALCHEMY_HTTPS_ENDPOINT=your_endpoint
METAMASK_PRIVATE_KEY=your_key
  • Install dotenv:
npm install dotenv

Now you're all set to start testing smart contracts with Hardhat!

2. Writing your first smart contract test

Let's create a test for a simple Faucet contract using Hardhat.

2.1. Test file structure

Rename /test/Lock.js to FaucetTests.js and replace its contents:

const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { expect } = require('chai');

describe('Faucet', function () {
  async function deploySetup() {
    const Faucet = await ethers.getContractFactory('Faucet');
    const faucet = await Faucet.deploy();
    const [owner] = await ethers.getSigners();
    return { faucet, owner };
  }

  it('sets the owner correctly', async function () {
    const { faucet, owner } = await loadFixture(deploySetup);
    expect(await faucet.owner()).to.equal(owner.address);
  });
});

This includes:

  • Imports
  • Test suite
  • Setup function
  • Test case

2.2. Test fixtures

Fixtures help reuse setup code. Our deploySetup fixture:

  1. Deploys Faucet
  2. Gets owner's address
  3. Returns both for tests

More tests using this fixture:

it('blocks withdrawals above .1 ETH', async function () {
  const { faucet } = await loadFixture(deploySetup);
  const bigAmount = ethers.utils.parseEther("0.2");
  await expect(faucet.withdraw(bigAmount)).to.be.reverted;
});

it('allows owner to destroy faucet', async function () {
  const { faucet, owner } = await loadFixture(deploySetup);
  await expect(faucet.connect(owner).destroyFaucet()).to.not.be.reverted;
});

Run tests with:

npx hardhat test

This executes all tests and shows results.

3. Key parts of automated testing

Automated testing in Hardhat boils down to three main parts: describe blocks, it blocks, and hooks. Let's break them down.

3.1. Grouping tests with describe blocks

Think of describe blocks as folders for your tests. They group related tests together:

describe('Faucet', function () {
    // Your tests go here
});

This keeps your tests organized and easy to follow.

3.2. Writing individual tests with it blocks

it blocks are where the magic happens. Each one is a single test:

it('sets the owner correctly', async function () {
    const { faucet, owner } = await loadFixture(deploySetup);
    expect(await faucet.owner()).to.equal(owner.address);
});

This test checks if the Faucet contract sets the owner right when it's deployed.

3.3. Using before and after hooks

Hooks are like setup and cleanup crews for your tests. They run at specific times:

  • before(): Once before all tests
  • after(): Once after all tests
  • beforeEach(): Before each test
  • afterEach(): After each test

Here's a beforeEach hook in action:

describe('Faucet', function () {
    let faucet;
    let owner;

    beforeEach(async function () {
        const Faucet = await ethers.getContractFactory('Faucet');
        faucet = await Faucet.deploy();
        [owner] = await ethers.getSigners();
    });

    // Tests go here
});

This deploys a fresh contract before each test, keeping your tests independent.

4. Advanced testing methods

Let's dive into some advanced testing techniques for your smart contracts.

4.1. Testing contract deployment and functions

When testing contract deployment, focus on these key areas:

  1. State variables are set up right
  2. Constructor runs smoothly
  3. All expected functions are there

Here's a quick example:

it("Should set the right unlockTime", async function () {
    const lockedAmount = 1_000_000_000;
    const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
    const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS;
    const lock = await hre.ethers.deployContract("Lock", [unlockTime], { value: lockedAmount });
    expect(await lock.unlockTime()).to.equal(unlockTime);
});

This test makes sure unlockTime is set correctly when the contract is deployed.

For function testing, remember to:

  • Test both normal scenarios and edge cases
  • Check function modifiers
  • Confirm state changes

4.2. Testing different accounts and permissions

Smart contracts often behave differently based on who's calling. Here's how to test that:

  1. Use Hardhat's built-in accounts
  2. Set up custom accounts for specific roles
  3. Try functions with different accounts

Check out this example:

it("Should revert when non-owner tries to withdraw", async function () {
    const [owner, otherAccount] = await hre.ethers.getSigners();
    await time.increaseTo(unlockTime);
    await expect(lock.connect(otherAccount).withdraw()).to.be.revertedWith("You aren't the owner");
});

This test checks if the withdraw function stops non-owners from withdrawing.

To make your tests run smoother:

  • Use loadFixture to reset the network after each test
  • Use Hardhat's time manipulation for time-dependent functions

Here's how to test a time-dependent function:

it("Should allow withdrawal after unlock time", async function () {
    await time.increaseTo(unlockTime);
    await expect(lock.withdraw()).not.to.be.reverted;
});

This test uses time.increaseTo to fast-forward time without actually waiting.

5. Handling async operations in tests

Testing smart contracts? You'll need to deal with async operations. Here's how to do it right in Hardhat tests.

5.1. Using async/await with contracts

Async/await is your friend when interacting with the blockchain in tests. It makes your code cleaner and easier to read.

Check out this example:

it("Should start with a favorite number of 0", async function () {
    const simpleStorage = await ethers.getContractFactory("SimpleStorage");
    const contract = await simpleStorage.deploy();
    const currentValue = await contract.retrieve();
    assert.equal(currentValue.toString(), "0");
});

This test deploys a contract, calls a function, and checks the return value. All with async/await.

5.2. Managing transaction confirmations

Want smooth test execution? Handle transaction confirmations like a pro:

1. Always use await for transactions. It's a must.

2. For crucial transactions, double-check the receipt:

const tx = await contract.someFunction();
const receipt = await tx.wait();
assert.equal(receipt.status, 1, "Transaction failed");

3. Got time-sensitive tests? Use Hardhat's time manipulation tools:

const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60;
await ethers.provider.send("evm_increaseTime", [ONE_YEAR_IN_SECS]);
await ethers.provider.send("evm_mine");

This code fast-forwards the blockchain by a year. Perfect for testing time-dependent functions without the wait.

6. Testing for errors and reverts

Let's look at how to test for errors and reverts in Hardhat. It's crucial for smart contracts to handle errors properly.

6.1. Checking for specific error messages

Want to make sure your function reverts with the right message? Here's how:

it("Should revert with the right message if called too soon", async function () {
  await expect(lock.withdraw()).to.be.revertedWith("You can't withdraw yet");
});

This checks if withdraw() reverts with "You can't withdraw yet" when called early.

For custom errors:

await expect(contract.call()).to.be.revertedWithCustomError(contract, "SomeCustomError");

6.2. Testing function modifiers and access control

Here's how to test access control modifiers:

it("Should revert if called by non-owner", async function () {
  const [owner, otherAccount] = await ethers.getSigners();
  await expect(lock.connect(otherAccount).withdraw()).to.be.revertedWith("You aren't the owner");
});

This checks if withdraw() reverts when a non-owner calls it.

Testing for panic codes:

it("Should revert on division by zero", async function () {
  await expect(contract.divideBy(0)).to.be.revertedWithPanic(PANIC_CODES.DIVISION_BY_ZERO);
});

Checking for reverts without reasons:

await expect(contract.call()).to.be.revertedWithoutReason();

These tests help ensure your smart contract behaves correctly under different conditions.

sbb-itb-a178b04

7. Simulating time in smart contract tests

Testing time-based smart contracts can be a pain. But don't worry - Hardhat's got your back with tools to manipulate blockchain time.

7.1. Using Hardhat's time tools

Hardhat

Here's how to bend time to your will in your tests:

1. Install Hardhat Network Helpers

First things first:

npm install @nomicfoundation/hardhat-network-helpers

2. Import the helpers

In your test file:

const { time } = require("@nomicfoundation/hardhat-network-helpers");

3. Control time

Now you're a time lord. Here's what you can do:

await time.increase(3600); // Skip ahead 1 hour
await time.setNextBlockTimestamp(1625097600); // Next block: July 1, 2021
await time.increaseTo(unlockTime); // Jump to a specific time

Let's see it in action with a time-locked contract:

const Lock = await ethers.getContractFactory("Lock");
const unlockTime = (await time.latest()) + 60 * 60 * 24; // Tomorrow
const lock = await Lock.deploy(unlockTime, { value: lockedAmount });

// Try to withdraw early
await expect(lock.withdraw()).to.be.revertedWith("You can't withdraw yet");

// Fast-forward to unlock time
await time.increaseTo(unlockTime);

// Now you can withdraw
await lock.withdraw();

This way, you can test time-dependent logic without twiddling your thumbs.

Pro tips:

  • Reset Hardhat Network between tests for a clean slate.
  • Use loadFixture for efficient setup.
  • Remember: Hardhat keeps timestamps realistic, so no setting the date to 3022!

8. Testing gas usage

Gas costs can make or break your smart contract. Here's how to measure and cut down on gas usage:

8.1. Measuring function gas costs

To keep your users happy, you need to know how much gas your functions use. Here's how:

1. Install Hardhat Gas Reporter

npm i hardhat-gas-reporter

2. Set up in Hardhat config

In hardhat.config.js:

require("hardhat-gas-reporter");

module.exports = {
  gasReporter: {
    enabled: (process.env.REPORT_GAS) ? true : false
  }
};

3. Run tests with gas reporting

REPORT_GAS=true npx hardhat test

You'll get a gas usage report like this:

Contract Method Min Max Avg # calls
MyToken mint 51975 68975 60475 10
MyToken transfer 21000 36000 28500 5

The "Avg" column shows typical usage.

Gas-saving tips:

  • Avoid loops (they're gas hogs)
  • Use uint256 instead of smaller sizes
  • Pack similar-sized variables together
  • Use calldata for read-only function arguments
  • Use unchecked blocks when overflow isn't a concern

Here's a gas optimization example:

// Before: 37,628 gas
function sumArray(uint[] memory arr) public pure returns (uint) {
    uint sum = 0;
    for (uint i = 0; i < arr.length; i++) {
        sum += arr[i];
    }
    return sum;
}

// After: 32,343 gas
function sumArray(uint[] memory arr) public pure returns (uint) {
    uint sum = 0;
    uint length = arr.length;
    for (uint i = 0; i < length;) {
        sum += arr[i];
        unchecked { i++; }
    }
    return sum;
}

9. Using other testing tools with Hardhat

Hardhat plays nice with other testing tools. Let's check out Chai and Waffle.

9.1. Using Chai for assertions

Chai

Chai makes your tests easier to read. Here's how to use it:

  1. Install Chai:
npm install --save-dev chai
  1. Import it:
const { expect } = require("chai");
  1. Write clearer tests:
describe("MyToken", function () {
  it("should have the correct name", async function () {
    const MyToken = await ethers.getContractFactory("MyToken");
    const myToken = await MyToken.deploy();
    expect(await myToken.name()).to.equal("MyToken");
  });
});

9.2. Using Waffle for more test options

Waffle

Waffle adds extra Ethereum-specific testing features:

  1. Install Waffle:
npm install --save-dev @nomiclabs/hardhat-waffle ethereum-waffle @nomiclabs/hardhat-ethers ethers
  1. Add to your Hardhat config:
require("@nomiclabs/hardhat-waffle");
  1. Use Waffle's matchers:
const { expect } = require("chai");

describe("Token", function() {
  it("should transfer tokens between accounts", async function() {
    const Token = await ethers.getContractFactory("Token");
    const token = await Token.deploy();
    const [owner, addr1, addr2] = await ethers.getSigners();

    await token.transfer(addr1.address, 50);
    expect(await token.balanceOf(addr1.address)).to.equal(50);

    await token.connect(addr1).transfer(addr2.address, 50);
    expect(await token.balanceOf(addr2.address)).to.equal(50);
  });
});

Waffle's matchers work great with Ethereum data types, making your tests more precise.

10. Best practices for smart contract testing

Testing smart contracts is crucial. Here's how to do it right:

10.1. Creating thorough test suites

Test everything:

  • All contract parts, including edge cases
  • Different user interactions
  • Common attacks (reentrancy, integer overflow)

Example for a voting contract:

it("should record votes correctly", async function() {
  const VotingContract = await ethers.getContractFactory("Voting");
  const voting = await VotingContract.deploy();
  await voting.vote(1);
  expect(await voting.getVotes(1)).to.equal(1);
});

10.2. Keeping tests independent

Each test should stand alone:

  • Reset contract state before tests
  • Don't rely on test order
  • Use fresh contract instances

Reset state in Hardhat:

beforeEach(async function() {
  await network.provider.send("hardhat_reset");
});

10.3. Avoiding common test mistakes

Watch out for:

  • Gas limits: Break up long tests
  • Time management: Use Hardhat's time tools
  • Error checking: Test for expected errors

"If you have done any sort of programming, you know how helpful it is to be able to read your variables during code execution." - Elim Poon, Author at Coinmonks

Debug with console.log():

import "hardhat/console.sol";

contract MyContract {
  function myFunction() public {
    console.log("This will print during tests");
  }
}

11. Running tests automatically

Let's set up automatic testing for your smart contracts. It's a game-changer.

11.1. Setting up continuous integration

CI tools run your tests every time you push code. It's like having a robot assistant catch bugs for you.

Here's how to set it up with GitHub Actions:

1. Make a .github/workflows folder in your project

2. Drop this into a ci.yml file:

name: Continuous Integration
on:
  push:
    branches: [ main ]
  pull_request:
jobs:
  build-n-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Install dependencies
        run: yarn install
      - name: Compile smart contract
        run: yarn compile
      - name: Run linting checks
        run: yarn lint
      - name: Run tests
        run: yarn test

Now, every push to main or pull request triggers your tests. Magic!

11.2. Creating NPM scripts for tests

NPM scripts make running tests a breeze. Add these to your package.json:

"scripts": {
  "lint": "solhint --max-warnings 0 \"contracts/**/*.sol\"",
  "compile": "hardhat compile",
  "test": "hardhat test",
  "coverage": "hardhat coverage"
}

Now you can run tests with a simple:

npm run test

This command does it all: compiles contracts, fires up a local Ethereum node, deploys contracts, and runs your tests.

"If you have done any sort of programming, you know how helpful it is to be able to read your variables during code execution." - Elim Poon, Author at Coinmonks

Want to debug during tests? Use console.log() in your Solidity code:

import "hardhat/console.sol";

contract MyContract {
  function myFunction() public {
    console.log("This will print during tests");
  }
}

It's like having X-ray vision for your smart contracts.

12. Fixing common test problems

12.1. Finding and fixing test errors

Testing smart contracts? You'll hit some snags. Here's how to deal:

1. Use console.log()

Drop these into your Solidity code for test insights:

import "hardhat/console.sol";

contract MyContract {
  function myFunction() public {
    console.log("This will print during tests");
  }
}

2. Check your assumptions

Write unit tests to verify your contract's behavior. Failed test? You might've found a bug.

3. Measure code coverage

Use solidity-coverage to find untested parts. Aim for near 100% coverage.

4. Test on multiple networks

Catch network-specific issues. Use network forks for faster testing.

12.2. Dealing with network issues

Network problems can mess up tests. Here's how to handle them:

1. Check your Hardhat config

Make sure network settings are correct. Watch for these errors:

  • #HH100: Selected network doesn't exist
  • #HH108: Cannot connect to the network
  • #HH109: Network timeout

2. Use fixtures

The loadFixture helper sets up network state efficiently:

const { loadFixture } = require("@nomicfoundation/hardhat-network-helpers");

describe("MyContract", function () {
  async function deployFixture() {
    // Set up contract, accounts, etc.
  }

  it("Should do something", async function () {
    const { myContract, owner } = await loadFixture(deployFixture);
    // Your test here
  }
});

3. Monitor gas usage

Use eth-gas-reporter to track gas costs during tests. It helps spot inefficient code that might cause issues on real networks.

Thorough testing prevents costly post-deployment fixes. As Rostyslav Bortman, Head of Blockchain at IdeaSoft, says:

"For rigorous debugging, we recommend using Tenderly, which is so far one of the most efficient tools used for smart contracts."

Conclusion

Smart contract testing isn't optional. It's a must. Why?

  • You can't change smart contracts after deployment.
  • Bugs can cost big money.
  • Automated tests save time and cash.

Hardhat makes testing easier:

  • It gives you a local Ethereum blockchain.
  • It works with Mocha and Chai.
  • You control the whole testing setup.

To test smart contracts like a pro:

1. Mix automated and manual testing.

2. Test from day one.

3. Use Hardhat's local blockchain.

4. Try tools like ethers.js and OpenZeppelin Test Helpers.

5. Aim for 100% code coverage.

Good testing isn't just about finding bugs. It's about being sure your contract works and is secure.

"Master Hardhat's testing tools. You'll build safer, more reliable smart contracts." - Solidity Academy

Related posts

Recent posts