Smart Contract Testing with Waffle 3
What are the features of Waffle and how to use them.
1. Requirements: Install the tools
First let's create a new project and initialize it with Buidler.
$ npm init
$ npm install --save-dev @nomiclabs/buidler
Now create a Buidler project by running npx buidler
and choose the example project. Afterwards, you can install the required dependencies.
$ npm install --save-dev @nomiclabs/buidler-waffle ethereum-waffle chai @nomiclabs/buidler-ethers ethers
We are almost finished with the setup. Let's change the package.json scripts. Add the npx buidler test
command to your package.json scripts and see if everything is working by running npm test
.
2. Waffle testing
The default contract created by Buidler is a simple Greeter contract. On the right is the extended version of it:
- You can set and read a
greeting
variable. - You see a way to utilize the Buidler
console.log
. - Added a
number
variable and a respective setter.
This will be our example contract for the testing. The basic example test is given below under test/sample-test.js
:
const { expect } = require("chai");
describe("Greeter", () => {
it("Should return the new greeting", async () => {
const Greeter = await ethers.getContractFactory("Greeter");
const greeter = await Greeter.deploy("Hello world!");
expect(await greeter.greet()).to.equal("Hello world!");
await greeter.setGreeting("Hola, mundo!");
expect(await greeter.greet()).to.equal("Hola, mundo!");
});
});
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.8;
import "@nomiclabs/buidler/console.sol";
contract Greeter {
string greeting;
uint256 public number;
constructor(string memory _greeting) public {
console.log("Deploying a Greeter with greeting:", _greeting);
greeting = _greeting;
}
function greet() public view returns (string memory) {
return greeting;
}
function setGreeting(string memory _greeting) public {
console.log("Changing greeting from '%s' to '%s'", greeting, _greeting);
greeting = _greeting;
}
function setNumber(uint256 _number) public {
number = _number;
}
}
You can either use the Buidler functionality of getContractFactory
. This is an injected function by Buidler to help you. Or you could use the native Waffle deployContract
which is available automatically as waffle.deployContract in Buidler. If you wanted to go down that path, make sure to the correct path to your ABI (./artifacts
in Buidler), see here for an example.
2.1 Waffle matching helpers
The test above shows you the basic setup, but doesn't really use a lot of Waffle features yet. One very useful feature present in Waffle is the automatic matching. They are similar what the @openzeppelin/test-helpers are for Truffle.
Available matchers exist for
- BigNumbers
- Events
- Contract calls (doesn't exist for Truffle)
- Reverts
- Balance changes
Now let's use the BigNumbers matcher as example. You don't have to do anything, you can just the .to.equal
and it will work for BigNumbers as well.
Let's also move the deployment into a before hook. This way our contract is only deployed once.
const { expect } = require("chai");
describe("Greeter", () => {
let greeter;
before(async () => {
const Greeter = await ethers.getContractFactory("Greeter");
greeter = await Greeter.deploy("Hello world!");
await greeter.deployed();
});
it("Should return the new greeting", async () => {
expect(await greeter.greet()).to.equal("Hello world!");
await greeter.setGreeting("Hola, mundo!");
expect(await greeter.greet()).to.equal("Hola, mundo!");
});
it("Should set the number", async () => {
expect(await greeter.number()).to.equal("0");
await greeter.setNumber(123);
expect(await greeter.number()).to.equal("123");
});
});
2.2 Waffle fixtures to speed up running time
Now you may have noticed that there's one problem with using the before hook as done above. What do you think would happen if we added the following line to the second test (Should set the number)?
expect(await greeter.greet()).to.equal("Hello world!");
That's right, it would fail. Why?
Because the before
hook is run only once, so in our first test with setGreeting("Hola, mundo!")
we actually change the state also for the second test. The easiest solution?
Just use beforeEach
instead of before
. This works as it deploys a new contract before each test. The drawback?
Performance!
This is where Waffle comes in. We can define fixtures that create certain scenarios. For example our fixture:
async function fixture([wallet]) {
const greeter = await deployContract(
wallet, Greeter, ["Hello world!"]
);
return { greeter, wallet };
}
This fixture just deploys our Greeter contract, but with a catch. A snapshot of our blockchain is taken after running this fixture. So afterwards with loadFixture
we don't actually run time-intensive redeploments.
Rather we simply load the previously stored snapshot of the blockchain. This makes our runtimes significantly faster than using beforeEach
.
Please note: Unfortuntately currently the buidler-integrated loadFixture
is broken as discovered by PaulRBerg. The solution is to use createFixtureLoader
as shown on the right.
const { expect } = require("chai");
const { deployContract, MockProvider } = require("ethereum-waffle");
const { waffle } = require("@nomiclabs/buidler");
const Greeter = require("../artifacts/Greeter.json");
const myProvider = new MockProvider();
const loadFixture = waffle.createFixtureLoader(
myProvider.getWallets(),
myProvider
);
describe("Greeter", () => {
async function fixture([wallet]) {
const greeter = await deployContract(wallet, Greeter, ["Hello world!"]);
return { greeter, wallet };
}
it("Should return the new greeting once it's changed", async () => {
const { greeter } = await loadFixture(fixture);
expect(await greeter.greet()).to.equal("Hello world!");
await greeter.setGreeting("Hola, mundo!");
expect(await greeter.greet()).to.equal("Hola, mundo!");
});
});
2.3 Waffle mocking
On top of mocking as with Truffle contracts, in Waffle you can mock contract calls automatically. It's like an integrated gnosis/mock-contract. Instead of deployContract
you just use deployMockContract
when deploying in your tests. Then you have the ability to change the return values of contract calls.
One drawback is that this works only when calling a contract within a contract. So given our Greeter example, to use the feature we need to add a GreetManager contract. Let's also add a string parameter to the greet
function. As you can see, we can
- mock every call using
mockGreeter.mock.greet.returns("mockedReturn 1")
- or mock depending on the passed arguments using
mockGreeter.mock.greet .withArgs("argument") .returns("mockedReturn 2")
//SPDX-License-Identifier: MIT
pragma solidity ^0.6.8;
import "./Greeter.sol";
contract GreetManager {
Greeter greeter;
constructor(Greeter greeter_) public {
greeter = greeter_;
}
function useGreeter(string memory custom)
public
view
returns (string memory)
{
return greeter.greet(custom);
}
}
const { expect } = require("chai");
const {
deployContract,
deployMockContract,
MockProvider,
} = require("ethereum-waffle");
const Greeter = require("../artifacts/Greeter.json");
const GreetManager = require("../artifacts/GreetManager.json");
const wallet = new MockProvider().getWallets()[0];
describe("Greeter", () => {
it("Should return what is set by mock", async () => {
const mockGreeter = await deployMockContract(wallet, Greeter.abi, [
"Hello world!",
]);
const greetManager = await deployContract(wallet, GreetManager, [
mockGreeter.address,
]);
await mockGreeter.mock.greet.returns("Hello new world!");
await mockGreeter.mock.greet
.withArgs("Hello old world!")
.returns("Hello new (not old) world!");
expect(await greetManager.useGreeter("custom")).to.equal(
"Hello new world!"
);
expect(await greetManager.useGreeter("Hello old world!")).to.equal(
"Hello new (not old) world!"
);
});
});
2.3 ENS (Ethereum Name Service)
ENS is nowadays quite often supported. You can register domains and subdomains in it. Many projects like MetaMask support this, so you can use simple names instead of addresses. If you are using it in your contracts, you can easily deploy the complete ENS smart contracts system with Waffle.
const provider = new MockProvider();
await provider.setupENS();
This is all you need for the setup. Now you can create domains and subdomains:
await ens.createTopLevelDomain('com');
await ens.createSubDomain('soliditydeveloper.com');
await ens.createSubDomain('vitalik.buterin.eth', {recursive: true}); // all in one
And you can set addresses via:
// name to address
await ens.setAddress('soliditydeveloper.com', '0x001...03');
// address to name
await ens.setAddressWithReverse('soliditydeveloper.com', '0x001...03');
Waffle or Truffle?
I think both are viable options these days. Both have unique features. In particular I like the Waffle fixtures as they can speed up test runs significantly. If you feel like that's something you don't really need, Truffle might be the better option just due to more active developments, more tooling and support.
You should also consider the size of the project and how long it will be maintained in the future. It's certainly an option to use only Waffle. For example Uniswap v2 core being a smaller project exists purely with Waffle: https://github.com/Uniswap/uniswap-v2-core.
Or once again, why choose? Just use Waffle and Buidler for testing and Truffle for deployments / tool support.
Truffle Waffles: Yum!
Solidity Developer