Waffle: Dynamic mocking and testing contract calls
What is this tutorial about?
In this tutorial you will learn how to:
- use dynamic mocking
- test interactions between smart contracts
Assumptions:
- you already know how to write a simple smart contract in
Solidity
- you know your way around
JavaScript
andTypeScript
- you've done other
Waffle
tutorials or know a thing or two about it
Dynamic mocking
Why is dynamic mocking useful? Well, it allows us to write unit tests instead of integration tests. What does it mean? It means that we don't have to worry about smart contracts' dependencies, thus we can test all of them in complete isolation. Let me show you how exactly you can do it.
1. Project
Before we start we need to prepare a simple node.js project:
mkdir dynamic-mockingcd dynamic-mockingmkdir contracts srcyarn init# or if you're using npmnpm init
Let's start with adding typescript and test dependencies - mocha & chai:
yarn add --dev @types/chai @types/mocha chai mocha ts-node typescript# or if you're using npmnpm install @types/chai @types/mocha chai mocha ts-node typescript --save-dev
Now let's add Waffle
and ethers
:
yarn add --dev ethereum-waffle ethers# or if you're using npmnpm install ethereum-waffle ethers --save-dev
Your project structure should look like this now:
1.2├── contracts3├── package.json4└── test
2. Smart contract
To start dynamic mocking, we need a smart contract with dependencies. Don't worry, I've got you covered!
Here's a simple smart contract written in Solidity
whose sole purpose is to check if we're rich. It uses ERC20 token to check if we have enough tokens. Put it in ./contracts/AmIRichAlready.sol
.
1pragma solidity ^0.6.2;23interface IERC20 {4 function balanceOf(address account) external view returns (uint256);5}67contract AmIRichAlready {8 IERC20 private tokenContract;9 uint public richness = 1000000 * 10 ** 18;1011 constructor (IERC20 _tokenContract) public {12 tokenContract = _tokenContract;13 }1415 function check() public view returns (bool) {16 uint balance = tokenContract.balanceOf(msg.sender);17 return balance > richness;18 }19}Ipakita lahatKopyahin
As we want to use dynamic mocking we don't need the whole ERC20, that's why we're using the IERC20 interface with only one function.
It's time to build this contract! For that we will use Waffle
. First, we're going to create a simple waffle.json
config file which specifies compilation options.
1{2 "compilerType": "solcjs",3 "compilerVersion": "0.6.2",4 "sourceDirectory": "./contracts",5 "outputDirectory": "./build"6}Kopyahin
Now we're ready to build the contract with Waffle:
npx waffle
Easy, right? In build/
folder two files corresponding to the contract and the interface appeared. We will use them later for testing.
3. Testing
Let's create a file called AmIRichAlready.test.ts
for the actual testing. First of all, we have to handle the imports. We will need them for later:
1import { expect, use } from "chai"2import { Contract, utils, Wallet } from "ethers"3import {4 deployContract,5 deployMockContract,6 MockProvider,7 solidity,8} from "ethereum-waffle"
Except for JS dependencies, we need to import our built contract and interface:
1import IERC20 from "../build/IERC20.json"2import AmIRichAlready from "../build/AmIRichAlready.json"
Waffle uses chai
for testing. However, before we can use it, we have to inject Waffle's matchers into chai itself:
1use(solidity)
We need to implement beforeEach()
function that will reset the state of the contract before each test. Let's first think of what we need there. To deploy a contract we need two things: a wallet and a deployed ERC20 contract to pass it as an argument for the AmIRichAlready
contract.
Firstly we create a wallet:
1const [wallet] = new MockProvider().getWallets()
Then we need to deploy an ERC20 contract. Here's the tricky part - we have only an interface. This is the part where Waffle comes to save us. Waffle has a magical deployMockContract()
function that creates a contract using solely the abi of the interface:
1const mockERC20 = await deployMockContract(wallet, IERC20.abi)
Now with both the wallet and the deployed ERC20, we can go ahead and deploy the AmIRichAlready
contract:
1const contract = await deployContract(wallet, AmIRichAlready, [2 mockERC20.address,3])
With all of that, our beforeEach()
function is finished. So far your AmIRichAlready.test.ts
file should look like this:
1import { expect, use } from "chai"2import { Contract, utils, Wallet } from "ethers"3import {4 deployContract,5 deployMockContract,6 MockProvider,7 solidity,8} from "ethereum-waffle"910import IERC20 from "../build/IERC20.json"11import AmIRichAlready from "../build/AmIRichAlready.json"1213use(solidity)1415describe("Am I Rich Already", () => {16 let mockERC20: Contract17 let contract: Contract18 let wallet: Wallet1920 beforeEach(async () => {21 ;[wallet] = new MockProvider().getWallets()22 mockERC20 = await deployMockContract(wallet, IERC20.abi)23 contract = await deployContract(wallet, AmIRichAlready, [mockERC20.address])24 })25})Ipakita lahat
Let's write the first test for the AmIRichAlready
contract. What do you think our test should be about? Yeah, you're right! We should check if we are already rich :)
But wait a second. How will our mocked contract know what values to return? We haven't implemented any logic for the balanceOf()
function. Again, Waffle can help here. Our mocked contract has some new fancy stuff to it now:
1await mockERC20.mock.<nameOfMethod>.returns(<value>)2await mockERC20.mock.<nameOfMethod>.withArgs(<arguments>).returns(<value>)
With this knowledge we can finally write our first test:
1it("returns false if the wallet has less than 1000000 tokens", async () => {2 await mockERC20.mock.balanceOf.returns(utils.parseEther("999999"))3 expect(await contract.check()).to.be.equal(false)4})
Let's break down this test into parts:
- We set our mock ERC20 contract to always return balance of 999999 tokens.
- Check if the
contract.check()
method returnsfalse
.
We're ready to fire up the beast:
So the test works, but... there's still some room for improvement. The balanceOf()
function will always return 99999. We can improve it by specifying a wallet for which the function should return something - just like a real contract:
1it("returns false if the wallet has less than 1000001 tokens", async () => {2 await mockERC20.mock.balanceOf3 .withArgs(wallet.address)4 .returns(utils.parseEther("999999"))5 expect(await contract.check()).to.be.equal(false)6})
So far, we've tested only the case where we're not rich enough. Let's test the opposite instead:
1it("returns true if the wallet has at least 1000001 tokens", async () => {2 await mockERC20.mock.balanceOf3 .withArgs(wallet.address)4 .returns(utils.parseEther("1000001"))5 expect(await contract.check()).to.be.equal(true)6})
You run the tests...
...and here you are! Our contract seems to work as intended :)
Testing contract calls
Let's sum up what've done so far. We've tested the functionality of our AmIRichAlready
contract and it seems to be working properly. That means we're done, right? Not exactly! Waffle allows us to test our contract even further. But how exactly? Well, in Waffle's arsenal there's a calledOnContract()
and calledOnContractWith()
matchers. They will allow us to check if our contract called the ERC20 mock contract. Here's a basic test with one of these matchers:
1it("checks if contract called balanceOf on the ERC20 token", async () => {2 await mockERC20.mock.balanceOf.returns(utils.parseEther("999999"))3 await contract.check()4 expect("balanceOf").to.be.calledOnContract(mockERC20)5})
We can go even further and improve this test with the other matcher I told you about:
1it("checks if contract called balanceOf with certain wallet on the ERC20 token", async () => {2 await mockERC20.mock.balanceOf3 .withArgs(wallet.address)4 .returns(utils.parseEther("999999"))5 await contract.check()6 expect("balanceOf").to.be.calledOnContractWith(mockERC20, [wallet.address])7})
Let's check if the tests are correct:
Great, all tests are green.
Testing contract calls with Waffle is super easy. And here's the best part. These matchers work with both normal and mocked contracts! It is because Waffle records and filters EVM calls rather than inject code, like it is the case of popular testing libraries for other technologies.
The Finish Line
Congrats! Now you know how to use Waffle to test contract calls and mock contracts dynamically. There are far more interesting features to discover. I recommend diving into Waffle's documentation.
Waffle's documentation is available here(opens in a new tab).
Source code for this tutorial can be found here(opens in a new tab).
Tutorials you may also be interested in:
Huling pag-edit: @nhsz(opens in a new tab), Pebrero 27, 2024