#Testing with ethers.js & Waffle
Writing smart contract tests in Hardhat is done using JavaScript or TypeScript.
In this guide, we'll show you how to use Ethers.js, a JavaScript library to interact with Ethereum, and Waffle a simple smart contract testing library built on top of it. This is our recommended choice for testing.
Let's see how to use it going through Hardhat's sample project.
TIP
Ethers and Waffle support TypeScript. Learn how to set up Hardhat with TypeScript here.
# Setting up
Install Hardhat on an empty directory. When done, run npx hardhat
.
$ npx hardhat
888 888 888 888 888
888 888 888 888 888
888 888 888 888 888
8888888888 8888b. 888d888 .d88888 88888b. 8888b. 888888
888 888 "88b 888P" d88" 888 888 "88b "88b 888
888 888 .d888888 888 888 888 888 888 .d888888 888
888 888 888 888 888 Y88b 888 888 888 888 888 Y88b.
888 888 "Y888888 888 "Y88888 888 888 "Y888888 "Y888
Welcome to Hardhat v2.0.0
? What do you want to do? …
❯ Create a sample project
Create an empty hardhat.config.js
Quit
Select Create a sample project
. This will create some files and install the @nomiclabs/hardhat-ethers
, @nomiclabs/hardhat-waffle
plugins, and other necessary packages.
TIP
Hardhat will let you know how, but in case you missed it you can install them with npm install --save-dev @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers
Look at the hardhat.config.js
file and you'll see that the Waffle plugin is enabled:
require("@nomiclabs/hardhat-waffle");
// This is a sample Hardhat task. To learn how to create your own go to
// https://hardhat.org/guides/create-task.html
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(account.address);
}
});
// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more
/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
solidity: "0.8.4",
};
TIP
There's no need for require("@nomiclabs/hardhat-ethers")
, as @nomiclabs/hardhat-waffle
already does it.
# Testing
Tests using Waffle are written with Mocha alongside Chai. If you haven't heard of them, they are super popular JavaScript testing utilities.
Inside the test
folder you'll find sample-test.js
. Let's take a look at it, and we'll explain it next:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Greeter", function () {
it("Should return the new greeting once it's changed", async function () {
const Greeter = await ethers.getContractFactory("Greeter");
const greeter = await Greeter.deploy("Hello, world!");
await greeter.deployed();
expect(await greeter.greet()).to.equal("Hello, world!");
const setGreetingTx = await greeter.setGreeting("Hola, mundo!");
// wait until the transaction is mined
await setGreetingTx.wait();
expect(await greeter.greet()).to.equal("Hola, mundo!");
});
});
In your terminal, run npx hardhat test
. You should see the following output:
$ npx hardhat test
Contract: Greeter
✓ Should return the new greeting once it's changed (762ms)
1 passing (762ms)
This means the test passed. Let's now explain each line:
const { expect } = require("chai");
We are requiring Chai
which is an assertions library. These asserting functions are called "matchers", and the ones we're using here actually come from Waffle.
This is why we're using the @nomiclabs/hardhat-waffle
plugin, which makes it easier to assert values from Ethereum. Check out this section in Waffle's documentation for the entire list of Ethereum-specific matchers.
WARNING
Some Waffle matchers return a Promise rather than executing immediately. If you're making a call or sending a transaction, make sure to check Waffle's documentation, and await
these Promises. Otherwise your tests may pass without waiting for all checks to complete.
describe("Greeter", function () {
it("Should return the new greeting once it's changed", async function () {
// ...
});
});
This wrapper just follows Mocha's proposed structure for tests, but you might have noticed the use of async
in it
's callback function. Interacting with the Ethereum network and smart contracts are asynchronous operations, hence most APIs and libraries use JavaScript's Promise
for returning values. This use of async
will allow us to await
the calls to our contract and the Hardhat Network node.
const Greeter = await ethers.getContractFactory("Greeter");
A ContractFactory
in ethers.js
is an abstraction used to deploy new smart contracts, so Greeter
here is a factory for instances of our greeter contract.
const greeter = await Greeter.deploy("Hello, world!");
Calling deploy()
on a ContractFactory
will start the deployment, and return a Promise
that resolves to a Contract
. This is the object that has a method for each of your smart contract functions. Here we're passing the string Hello, world!
to the contract's constructor.
Once the contract is deployed, we can call our contract methods on greeter
and use them to get the state of the contract.
expect(await greeter.greet()).to.equal("Hello, world!");
Here we're using our Contract
instance to call a smart contract function in our Solidity code. greet()
returns the greeter's greeting, and we're checking that it's equal to Hello, world!
, as it should be. To do this we're using the Chai matchers expect
, to
and equal
.
await greeter.setGreeting("Hola, mundo!");
expect(await greeter.greet()).to.equal("Hola, mundo!");
We can modify the state of a contract in the same way we read from it. Calling setGreeting
will set a new greeting message. After the Promise
is resolved, we perform another assertion to verify that the greeting change took effect.
#Testing from a different account
If you need to send a transaction from an account other than the default one, you can use the connect()
method provided by Ethers.js.
The first step to do so is to get the Signers
object from ethers
:
const [owner, addr1] = await ethers.getSigners();
A Signer
in Ethers.js is an object that represents an Ethereum account. It's used to send transactions to contracts and other accounts. Here we're getting a list of the accounts in the node we're connected to, which in this case is Hardhat Network, and only keeping the first and second ones.
TIP
To learn more about Signer
, you can look at the Signers documentation.
The ethers
variable is available in the global scope. If you like your code always being explicit, you can add this line at the top:
const { ethers } = require("hardhat");
Finally, to execute a contract's method from another account, all you need to do is connect
the Contract
with the method being executed:
await greeter.connect(addr1).setGreeting("Hallo, Erde!");
# Migrating an existing Waffle project
If you're starting a project from scratch and looking to use Waffle, you can skip this section. If you're setting up an existing Waffle project to use Hardhat you'll need to migrate the configuration options Waffle offers. The following table maps Waffle configurations to their Hardhat equivalents:
Waffle | Hardhat |
---|---|
sourcesPath | paths.sources |
targetPath | paths.artifacts |
solcVersion | solc.version (version number only) |
compilerOptions.evmVersion | solc.evmVersion |
compilerOptions.optimizer | solc.optimizer |
As an example, this Waffle configuration file:
{
"sourcesPath": "./some_custom/contracts_path",
"targetPath": "../some_custom/build",
"solcVersion": "v0.4.24+commit.e67f0147",
"compilerOptions": {
"evmVersion": "constantinople",
"optimizer": {
"enabled": true,
"runs": 200
}
}
}
Would translate into this Hardhat config:
module.exports = {
paths: {
sources: "./some_custom/contracts_path",
artifacts: "../some_custom/build",
},
solidity: {
version: "0.4.24", // Note that this only has the version number
settings: {
evmVersion: "constantinople",
optimizer: {
enabled: true,
runs: 200,
},
},
},
};
If you're migrating an existing Waffle project to Hardhat, then the minimum configuration you'll need is changing Hardhat's compilation output path, since Waffle uses a different one by default:
require("@nomiclabs/hardhat-waffle");
module.exports = {
paths: {
artifacts: "./build",
},
};
#Adapting the tests
Now, when testing using a standalone Waffle setup, you should use the different parts of Waffle from Hardhat.
For example, instead of doing:
const { deployContract } = require("ethereum-waffle");
You should do:
const { waffle } = require("hardhat");
const { deployContract } = waffle;
WARNING
Importing Waffle's functions from ethereum-waffle
, can lead to multiple problems.
For example, Waffle has a default gas limit of 4 million gas for contract deployment transactions, which is normally too low.
Please, make sure you import them from the waffle
field of the Hardhat Runtime Environment. It is a version of Waffle adapted to work well with Hardhat.
Also, you don't need to call chai.use
. This initialization is already handled by @nomiclabs/hardhat-waffle
. Just be sure to include require("@nomiclabs/hardhat-waffle");
in your Hardhat config.
Finally, instead of initializing a MockProvider
, just use the plugin's provider like this:
const { waffle } = require("hardhat");
const provider = waffle.provider;
Run your tests with npx hardhat test
and you should get stack traces when a transaction fails.