Testing ERC-20 Tokens With Waffle

Intermediate

 

Testing ERC-20 Tokens With Waffle

In this tutorial you will learn how to:

  • Write tests for smart contracts with Waffle

  • Use some popular matchers to test smart contracts with Waffle

Assumptions:

  • you can get around in a terminal,

  • you can create a new JavaScript project,

  • you’ve written a few lines of Solidity code,

  • you’ve written a few tests in JavaScript,

  • you’ve used yarn or npm, JavaScripts’s package installer.

Again, if any of these are untrue, or you don’t plan to reproduce the code in this article, you can likely still follow along just fine.

A FEW WORDS ABOUT WAFFLE

Waffle is the most advanced library for writing and testing smart contracts.

Works with the JavaScript API ethers-js.

You can read more details in the Waffle documentation !

THE QUICK TUTORIAL

First things first, create new JavaScript or TypeScript project ( I’ll use TS, but if you use JS it’s not a problem ) :

Somewhat like this :

package.json

{
  "name": "tutorial",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "test": "export NODE_ENV=test && mocha",
    "lint": "eslint '{src,test}/**/*.ts'",
    "lint:fix": "eslint --fix '{src,test}/**/*.ts'",
    "build": "waffle"
  },
  "devDependencies": {
    "@types/mocha": "^5.2.7",
    "@typescript-eslint/eslint-plugin": "^2.30.0",
    "@typescript-eslint/parser": "^2.30.0",
    "eslint": "^6.8.0",
    "eslint-plugin-import": "^2.20.2",
    "ethers": "^5.0.17",
    "mocha": "^7.1.2",
    "ts-node": "^8.9.1",
    "typescript": "^3.8.3"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "declaration": true,
    "esModuleInterop": true,
    "lib": [
      "ES2018"
    ],
    "module": "CommonJS",
    "moduleResolution": "node",
    "outDir": "dist",
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "strict": true,
    "target": "ES2018"
  }
}

.gitignore

node_modules
build

.eslintrc.js

To get started, install ethereum-waffle. In this tutorial, I’ll use yarn, so to install ethereum-waffle run:

yarn add --dev ethereum-waffle

In this tutorial, I’ll use ERC20 token from OpenZeppelin.

So, add OpenZeppelin by installing it with yarn:

yarn add @openzeppelin/contracts -D

Then create BasicToken.sol contract in src directory:

pragma solidity ^0.6.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

// Example class - a mock class using delivering from ERC20
contract BasicToken is ERC20 {
    constructor(uint256 initialBalance) ERC20("Basic", "BSC") public {
        _mint(msg.sender, initialBalance);
    }
}

To compile your smart contract add the following entry in the package.json of your project :

{
  "scripts": {
    "build": "waffle"
  }
}

Also, add waffle.json file in the main directory of your project.

An example of waffle.json configuration:

{
  "compilerType": "solcjs",
  "compilerVersion": "0.6.2",
  "sourceDirectory": "./src",
  "outputDirectory": "./build"
}

You can read more about the Waffle configuration here.

Then just run yarn build to compile your smart contract.

You should see that Waffle compiled your contract and placed the resulting JSON output inside the build directory.

After we have successfully authored a Smart Contract we can test it. We will use Waffle to do it.

Tests in Waffle are written using Mocha alongside with Chai. We can use a different test environment, but Waffle matchers only work with Chai.

So, we need to add Chai to our dependencies :

yarn add --dev mocha chai

To write our test we need to create BasicToken.test.ts file in our test directory.

import { expect, use } from "chai"
import { Contract } from "ethers"
import { deployContract, MockProvider, solidity } from "ethereum-waffle"
import BasicToken from "../build/BasicToken.json"

use(solidity)

describe("BasicToken", () => {
  const [wallet, walletTo] = new MockProvider().getWallets()
  let token: Contract

  beforeEach(async () => {
    token = await deployContract(wallet, BasicToken, [1000])
  })
})

So, we use deployContract method from Waffle to deploy our token. As arguments, we should pass wallet, the compiled json file of our contract and default balance.

Waffle also allows us to create a wallet, which makes it very easy to deploy a contract.

You can read more about wallet here and you can read more about the deploying function here.

Let’s write a simple test to check balance of our wallet. Since we submitted the value 1000 during the deployment our contract, the balance of our wallet must be 1000 tokens, which we can check in the first test.

it("Assigns initial balance", async () => {
  expect(await token.balanceOf(wallet.address)).to.equal(1000)
})

To run the test use yarn test

In this tutorial, I want to show you the most useful matchers of Waffle, so let’s start with the first one.

Waffle allows us to test what events where emitted.

In this tutorial, I’ll test the transfer method of our contract.

In this test, I’ll make a transfer from one wallet to another and check whether the Transfer event was called.

it("Transfer emits event", async () => {
  await expect(token.transfer(walletTo.address, 7))
    .to.emit(token, "Transfer")
    .withArgs(wallet.address, walletTo.address, 7)
})

Also, a big advantage of this matcher is that we can check which arguments this event was called with by adding withArgs to our test.

This will allow us to be sure that our function is being called correctly!

Waffle allows us to test what message it was reverted with.

We will use revertedWith matcher in our test to check it.

We can write a test in which we will perform a transfer for an amount greater than we have on our wallet. And then we’ll check if the transaction reverted with the exact message!

it("Can not transfer above the amount", async () => {
  await expect(token.transfer(walletTo.address, 1007)).to.be.revertedWith(
    "VM Exception while processing transaction: revert ERC20: transfer amount exceeds balance"
  )
})

Waffle allows us to check for changes in the balances of the wallets!

We can use the changeTokenBalance matcher to check the balance change or the changeTokenBalances for a multiple account.

The matcher can accept numbers, strings and BigNumbers as a balance change, while the address should be specified as a wallet or a contract.

Let’s write the next test:

it("Send transaction changes receiver balance", async () => {
  await expect(() =>
    wallet.sendTransaction({ to: walletTo.address, gasPrice: 0, value: 200 })
  ).to.changeBalance(walletTo, 200)
})

The above is a test for a single wallet.

And the next one for multiple wallets:

it("Send transaction changes sender and receiver balances", async () => {
  await expect(() =>
    wallet.sendTransaction({ to: walletTo.address, gasPrice: 0, value: 200 })
  ).to.changeBalances([wallet, walletTo], [-200, 200])
})

The transaction is expected to be passed as a callback (we need to check the balance before the call) or as a transaction response.