Skip to content

Unit Testing with Truffle

Now that you used the truffle console, its just a tiny step to understand how the unit testing framework works in truffle.

Testing consists basically of two different components:

  1. execute a transaction (function call, etc.)
  2. compare the state on the blockchain or the transaction result to an expected value

For example, if we mint an NFT for account#2 then we expect the ownerOf function to return account#2 for the NFT. It can be as simple as that, or much more complex, like executing several different transactions and then comparing the result to something.

First Unit Test

Truffle can do tests in JavaScript and Solidity. The problem with the Solidity tests with Truffle is, they can't really influence much of the transaction structure. You can't choose the account you're sending the TX from in Solidity. You can't modify anything on the chain. And you can't listen to events. You will see later, when we use Foundry, what I mean.

For this example, I'll stick to the JavaScript tests, as they are very similar to Hardhat.

Let's create a new file "Spacebear.test.js" in the test folder:

const Spacebear = artifacts.require("Spacebear");

contract("Spacebear", (accounts) => {
    it('should credit an NFT to a specific account', async () => {
        const spacebearInstance = await Spacebear.deployed();
        await spacebearInstance.safeMint(accounts[1],"spacebear_1.json");
        console.log(spacebearInstance.ownerOf(0));
    })
})

What do we have here:

  1. we import the Spacebear artifact.
  2. We start a test with the contract function. If you come from mocha for tests, you might be used to beforeAll and beforeEach functions. Internally, truffle uses Mocha, but, by using the contract function instead of describe, Truffle will automatically redeploy the contracts based on the migrations files to offer so called "clean room testing".
  3. The contract function gets all accounts, which are injected by Truffle by doing a web3.eth.getAccounts() before starting the test.
  4. Each test starts with the it(...) function, which expects a function as second parameter.

You have two choices now:

  1. Use the internal truffle chain to run the testing
  2. Use any external chain, like ganache, to run the tests

If you still have the truffle console open, then simply type in

test

Truffle Unit Testing
Truffle Unit Testing

You see the console.log output. And you see the test is passing. It's passing, because we did not compare anything that could make the test fail.

Let's update the test case with the truffle default assertions, and assert something that will make the test succeed:

const Spacebear = artifacts.require("Spacebear");

contract("Spacebear", (accounts) => {
    it('should credit an NFT to a specific account', async () => {
        const spacebearInstance = await Spacebear.deployed();
        await spacebearInstance.safeMint(accounts[1],"spacebear_1.json");
        assert.equal(await spacebearInstance.ownerOf(0), accounts[1], "Owner of Token is the wrong address");
    })
})

This will make the test pass.

and if we force the test to fail, it looks like this:

const Spacebear = artifacts.require("Spacebear");

contract("Spacebear", (accounts) => {
    it('should credit an NFT to a specific account', async () => {
        const spacebearInstance = await Spacebear.deployed();
        await spacebearInstance.safeMint(accounts[1],"spacebear_1.json");
        assert.equal(await spacebearInstance.ownerOf(0), accounts[0], "Owner of Token is the wrong address");
    })
})

Failing Truffle Unit test
Failing Truffle Unit test

Imagine you are working on a larger project. Having Unit-Tests is absolutely crucial! Smart Contracts can manage a lot of money, to have trust in your own code, it's hopefully not necessary to mention how important unit testing is.

Using Truffle Assertions

I personally found Chai assertions always a bit cumbersome. If you want to test that a specific event or event-chain has been emitted during a Transaction, you need to parse through the Transaction object:

const Spacebear = artifacts.require("Spacebear");

contract("Spacebear", (accounts) => {
    it('should credit an NFT to a specific account', async () => {
        const spacebearInstance = await Spacebear.deployed();
        let txResult = await spacebearInstance.safeMint(accounts[1],"spacebear_1.json");

        assert.equal(txResult.logs[0].event, "Transfer", "Transfer event was not emitted")
        assert.equal(txResult.logs[0].args.from, '0x0000000000000000000000000000000000000000', "Token was not transferred from the zero address");
        assert.equal(txResult.logs[0].args.to, accounts[1], "Receiver wrong address");
        assert.equal(txResult.logs[0].args.tokenId.toString(), "0", "Wrong Token ID minted");

        assert.equal(await spacebearInstance.ownerOf(0), accounts[1], "Owner of Token is the wrong address");
    })
})

That's why I actually recommend a neat library called "truffle-assertions".

Exit the truffle console and then simply install it by typing

npm install truffle-assertions

Then put it in action in our first unit test:

const Spacebear = artifacts.require("Spacebear");
const truffleAssert = require('truffle-assertions');


contract("Spacebear", (accounts) => {
    it('should credit an NFT to a specific account', async () => {
        const spacebearInstance = await Spacebear.deployed();
        let txResult = await spacebearInstance.safeMint(accounts[1],"spacebear_1.json");

        truffleAssert.eventEmitted(txResult, 'Transfer', {from: '0x0000000000000000000000000000000000000000', to: accounts[1], tokenId: web3.utils.toBN("0")});

        assert.equal(await spacebearInstance.ownerOf(0), accounts[1], "Owner of Token is the wrong address");
    })
})

Big Numbers

You are maybe wondering what's that web3.utils.toBN("0") doing here.

The problem is that JavaScript integer is using IEEE 754 for representing the numbers. The maximum number where all arithmetic operations work is 9,007,199,254,740,991 (2^53). But solidity uint256 ranges to 2^256, which is much larger. Without going to much in-depth, let me summarizes it by saying, after 9^53 it just doesn't work correctly anymore.

In order to work with such large numbers, they are automatically converted to be used with a library called Bignumber.js.

Bignumber.js allows for arbitrary large arithmetic operations. This why all (unsigned) integers are automatically converted and Bignumber is used everywhere.

Even more problematic, there are several different Bignumber libraries and not all versions of web3 convenience libraries (web3js, etherjs, etc) are using the same Bignumber objects. More on that later...

Now we have a unit test running, let's see how we can deploy the contract to one of the test networks.


Last update: July 27, 2023