Testing your smart contracts with Ethers and Mocha
This guide shows you how to test contracts in Hardhat using Ethers and Mocha.
You’ll also learn how to use our Chai matchers and hardhat-network-helpers plugins to write clean test code.
If you have already initialized an ethers-based project using hardhat --init, you don’t need to do anything else.
If you want to add the required plugins manually, follow these steps:
-
Install the packages:
Terminal window npm add --save-dev @nomicfoundation/hardhat-ethers @nomicfoundation/hardhat-typechain @nomicfoundation/hardhat-mocha @nomicfoundation/hardhat-ethers-chai-matchers @nomicfoundation/hardhat-network-helpers ethers mocha @types/mocha chai @types/chaiTerminal window pnpm add --save-dev @nomicfoundation/hardhat-ethers @nomicfoundation/hardhat-typechain @nomicfoundation/hardhat-mocha @nomicfoundation/hardhat-ethers-chai-matchers @nomicfoundation/hardhat-network-helpers ethers mocha @types/mocha chai @types/chaiTerminal window yarn add --dev @nomicfoundation/hardhat-ethers @nomicfoundation/hardhat-typechain @nomicfoundation/hardhat-mocha @nomicfoundation/hardhat-ethers-chai-matchers @nomicfoundation/hardhat-network-helpers ethers mocha @types/mocha chai @types/chai -
Add them to the list of plugins in your Hardhat configuration:
hardhat.config.ts // ... other imports...import hardhatEthers from "@nomicfoundation/hardhat-ethers";import hardhatTypechain from "@nomicfoundation/hardhat-typechain";import hardhatMocha from "@nomicfoundation/hardhat-mocha";import hardhatEthersChaiMatchers from "@nomicfoundation/hardhat-ethers-chai-matchers";import hardhatNetworkHelpers from "@nomicfoundation/hardhat-network-helpers";export default defineConfig({plugins: [hardhatEthers,hardhatTypechain,hardhatMocha,hardhatEthersChaiMatchers,hardhatNetworkHelpers,// ...other plugins...],// ...other config...});
Type-safe contract interactions
Section titled “Type-safe contract interactions”Hardhat integrates with Typechain to provide type-safe contract interactions:
// doesn't compile if incBy expects a number but receives a boolean:await counter.incBy(true);The hardhat-typechain plugin handles this automatically when you use its helpers like the helpers exposed by hardhat-ethers, as we’ll see below.
Troubleshooting contract type errors
Section titled “Troubleshooting contract type errors”Contract types are updated when the project is compiled. If you’re getting a compilation error that you don’t expect, make sure you’ve run
npx hardhat buildpnpm hardhat buildyarn hardhat buildNote that VSCode may not always pick up the type updates automatically. If you’re still getting unexpected TypeScript errors after compiling the project, open the Command Palette and run TypeScript: Reload Project.
A simple test
Section titled “A simple test”Let’s write some tests for the Counter contract that comes with the sample project. If you haven’t read it yet, take a look at the contracts/Counter.sol file.
For our first test, we’ll deploy the Counter contract and assert that the Increment event is emitted when we call the inc() function.
Make sure your test/Counter.ts file looks like this:
import { expect } from "chai";import hre from "hardhat";
const { ethers, networkHelpers } = await hre.network.connect();
describe("Counter", function () { it("Should emit the Increment event when calling the inc() function", async function () { const counter = await ethers.deployContract("Counter");
await expect(counter.inc()).to.emit(counter, "Increment").withArgs(1n); });});First, we import the things we’re going to use:
- The
expectfunction fromchaito write our assertions - The Hardhat Runtime Environment, or
hre, which exposes all of Hardhat’s functionality
Then, we call hre.network.connect() to create a new local simulation of an Ethereum blockchain to run our test on. It returns an object with an instance of ethers and networkHelpers already connected to that simulation.
After that, we use the describe and it functions, which are global Mocha functions used to define and group your tests. You can read more about Mocha here.
The test itself is what’s inside the callback argument to the it function. First, we deploy our Counter contract using the ethers instance returned by our hre.network.connect() call.
Finally, we call the inc() function of the contract and assert that the Increment event is emitted with the correct argument. The .to.emit matcher is provided by the hardhat-ethers-chai-matchers plugin.
Running your test
Section titled “Running your test”To run all the tests in a Hardhat project, you can run:
npx hardhat testpnpm hardhat testyarn hardhat testYou can also run only the Mocha test with:
npx hardhat test mochapnpm hardhat test mochayarn hardhat test mochaOr a single file with:
npx hardhat test test/Counter.tspnpm hardhat test test/Counter.tsyarn hardhat test test/Counter.tsTesting a function that reverts
Section titled “Testing a function that reverts”In the previous test, we checked that a function emitted an event with the correct value. For that example, we know there’s no way the inc() function could possibly revert on chain since it’s merely incrementing a number and emitting an event with the result.
However, smart contracts are rarely that simple, and functions often have preconditions that must be met before they can be executed successfully.
For example, consider the following modifications to our Counter contract:
// SPDX-License-Identifier: UNLICENSEDpragma solidity ^0.8.28;
contract Counter { uint public x; address public owner;
constructor() { owner = msg.sender; }
event Increment(uint by);
function inc() public { require(msg.sender == owner, "only the owner can increment the counter"); x++; emit Increment(1); }
function incBy(uint by) public { require(by > 0, "incBy: increment should be positive"); x += by; emit Increment(by); }}Now the inc() function can only be called by the address that deployed the contract (the owner). If any other address tries to call it, the function will revert.
With these changes, our previous test will still pass because by default the contract is deployed and called by the same address (the first account configured in Hardhat). However, we should also add a test to check that calling inc() from a non-owner address causes the transaction to revert. Here’s how:
12 collapsed lines
import { expect } from "chai";import hre from "hardhat";
const { ethers, networkHelpers } = await hre.network.connect();
describe("Counter", function () { it("Should emit the Increment event when calling the inc() function", async function () { const counter = await ethers.deployContract("Counter");
await expect(counter.inc()).to.emit(counter, "Increment").withArgs(1n); });
it("Should allow the owner to increment and revert for non-owners", async function () { const counter = await ethers.deployContract("Counter");
const nonOwnerAddress = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
// Impersonate the non-owner account await networkHelpers.impersonateAccount(nonOwnerAddress);
// Fund the non-owner account with some ETH to pay for gas await networkHelpers.setBalance(nonOwnerAddress, ethers.parseEther("1.0"));
// Get a signer for the non-owner account const nonOwnerSigner = await ethers.getSigner(nonOwnerAddress);
// Call inc() as the owner - should succeed await expect(counter.inc()).to.emit(counter, "Increment").withArgs(1n);
// Call inc() as a non-owner - should revert await expect(counter.connect(nonOwnerSigner).inc()).to.be.revertedWith( "only the owner can increment the counter", ); });1 collapsed line
});Here we’re using .to.be.revertedWith, which asserts that a transaction reverts and that the revert reason matches the given string. The .to.be.revertedWith matcher is not part of Chai itself; instead, like .to.emit, it’s added by the hardhat-ethers-chai-matchers plugin.
Additionally, we’re using some hardhat-network-helpers features to test our function with a non-default account. We use impersonateAccount to tell Hardhat to allow us to send transactions from that address, and setBalance to give it some ETH so it can pay for gas.
Finally, we use ethers.getSigner to get a signer object for that address, which we use to connect to the contract and call the inc() function.
Using fixtures
Section titled “Using fixtures”So far, we’ve deployed the Counter contract in each test. This might be fine for a single contract, but if you have a more complicated setup, each test will need several lines at the beginning to set up the desired state, and most of the time these lines will be the same.
In a typical Mocha test, this duplication of code is handled with a beforeEach hook:
describe("Counter", function () { let counter: any;
beforeEach(async function () { counter = await ethers.deployContract("Counter"); });
it("some test", async function () { // use the deployed contract });});However, there are two problems with this approach:
- If you have to deploy many contracts, your tests will be slower because each one has to send multiple transactions as part of its setup.
- Sharing the variables like this between the
beforeEachhook and your tests is awkward and error-prone.
The loadFixture helper in the hardhat-network-helpers plugin fixes both of these problems. It receives a fixture, a function that sets up the chain to some desired state.
The first time loadFixture is called, the fixture is executed. But the second time, instead of executing the fixture again, loadFixture will reset the state of the blockchain to the point where it was right after the fixture was executed. This is faster, and it undoes any state changes done by the previous test.
This is what our tests look like when a fixture is used:
import { expect } from "chai";import { network } from "hardhat";
const { ethers, networkHelpers } = await network.connect();
describe("Counter", function () { async function deployCounterFixture() { const counter = await ethers.deployContract("Counter"); return { counter }; }
it("Should emit the Increment event when calling the inc() function", async function () { const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
await expect(counter.inc()).to.emit(counter, "Increment").withArgs(1n); });
it("Should allow the owner to increment and revert for non-owners", async function () { const { counter } = await networkHelpers.loadFixture(deployCounterFixture);
const nonOwnerAddress = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
// Impersonate the non-owner account await networkHelpers.impersonateAccount(nonOwnerAddress);
// Fund the non-owner account with some ETH to pay for gas await networkHelpers.setBalance(nonOwnerAddress, ethers.parseEther("1.0")); const nonOwnerSigner = await ethers.getSigner(nonOwnerAddress);
// Call inc() as the owner - should succeed await expect(counter.inc()).to.emit(counter, "Increment").withArgs(1n);
// Call inc() as a non-owner - should revert await expect(counter.connect(nonOwnerSigner).inc()).to.be.revertedWith( "only the owner can increment the counter", ); });});The fixture function can return anything you want, and loadFixture will return it. We recommend returning an object like we did here so you can extract only the values you need for each test. To learn more about fixtures, you can read their reference documentation here.
Learn more
Section titled “Learn more”We just covered the basics of testing with ethers.js, Mocha, and our plugins. To learn more about this and other topics, you can read these guides:
- To learn more about how to use ethers in Hardhat, read the
hardhat-ethersdocumentation. - To learn how to use contracts from an npm dependency with ethers, read this guide.
- To learn more about the
hardhat-ethers-chai-matchersplugin and the methods it offers, read its documentation. - To learn more about
hardhat-network-helpers, read its documentation.