Unit Testing your Smart Contracts

ยท

4 min read

Writing tests for your smart contracts are just as important as running audits on them too. There are a few reasons why you might want to write full coverage tests for your smart contracts, one of which would be to save you some bucks. How? Deploying to a public testnet cost no dime ( love don't cost a dime ), unlike moving your contracts to the mainnet.

Imagine deploying multiple times to the mainnet because of minor issues like forgetting to use the correct name for your token or even using the wrong decimals ( ERC-20 tokens), these little things happen. One way to save some cash and maybe send them to me instead is to develop a culture of writing tests for your smart contract, they really are not smart enough ( trust no man ).

For me, I prefer using hardhat to develop, test and deploy my contracts for so many reasons, this might also work for truffle. To get started with writing tests, there are a few libraries I use for this purpose which include:

  • Chai
  • Chai-as-promised
  • ethers

You are probably wondering, ethers?? yes!! ethers.js .. I mean, who is gonna help us communicate with the blockchain? You?? (Lol). So basically, we need ethers to make those function calls and state variable retrievals. Talk is cheap, let me show you the way already. Below is the contract we would be running our test against.

Things to do

  • Create a file with the name Instagram.sol for your contracts.
  • Create a test file under the test directory named instagram.test.js
  • Compile your contract
  • Ensure your hardhat is configured to compile contracts using version 0.8.4.

pragma solidity ^0.8.4;

contract Instagram {
  string public name = "Instagram";

  // Store Posts
  uint public imageCount = 0;
  mapping(uint => Image) public images;

  struct Image {
  uint id;
  string url;
  string description;
  uint tipAmount;
  address payable author;
} 

event ImageCreated(
  uint id,
  string url,
  string description,
  uint tipAmount,
  address payable author
);

event ImageTipped(
  uint id,
  string url,
  string description,
  uint tipAmount,
  address payable author
);


function uploadImage(string memory imageUrl, string memory _description) public {

  require(bytes(imageUrl).length > 0, 'Image imgHash is required');
  require(bytes(_description).length > 0, 'Image description is required');
  require(msg.sender != address(0x0),'Fake ass address');
  imageCount++;
  // Add Image to contract
  images[imageCount] = Image(imageCount, imageUrl,  _description, 0, payable(msg.sender));
  // Trigger an event
  emit ImageCreated(imageCount, imageUrl, _description, 0, payable(msg.sender));
}
  // Create Images

  function tipImageOwner(uint _id) public payable {
    // Make sure that the id is valid
    require(_id > 0 && _id <= imageCount);
    // Fetch the image
    Image memory _image = images[_id];

    address payable _author = payable(_image.author);

    _author.transfer(msg.value);

    _image.tipAmount = _image.tipAmount + msg.value;

    images[_id] = _image;

    emit ImageTipped(_id, _image.url, _image.description, _image.tipAmount, _author);

  }


}

Step 1 : Import dependencies

const { expect, use } = require("chai");
const { ethers } = require("hardhat");

use(require("chai-as-promised")).should();

Step 2: Deploy Contract

// below the previous code add this line of code
describe("Deploy Instagram", async () => {
 let instagram;

  before(async () => {
    const Instagram = await ethers.getContractFactory("Instagram");
    instagram= await Instagram .deploy();
    await instagram.deployed();
  })

  // other code below here

})

All we are doing here is to deploy our contract first in order to interact with its function in future test cases. You are typically saying, before anything, please deploy my contract and finally store the instance of that contract in a global variable Instagram

Test Case 1 : Ensure deployment was successful and has the correct name

 // add below previous code

  it('deploys successfully', async () => {
      const address = await instagram.address
      expect(address).to.not.equal(0x0);
      expect(address).to.not.be.equal(null);
    })

    it('has a name', async () => {
      const name = await instagram.name()
      expect(name).to.be.equal('Instagram')
    })

We have two test cases here, first ensures that we have an address for the deployed contract. The second case tries to assert that the name remained the same. You might be wondering why we are calling name as a function. That's typical of ethers, to retrieve state variables you need to call them like it's a function.

Go ahead and run

npx hardhat test test/instagram.test.js

Test Case 2: Ensure we can upload an image

it("creates image", async () => {
      const { address: deployer } = await ethers.getSigner()
      await instagram.uploadImage('HTTP', 'Image description')

      const imageCount = await instagram.imageCount();
      const lastUploadedImage = await instagram.images(1);

      expect(imageCount).to.equal(1)
      expect(lastUploadedImage.author).to.equal(deployer)
      expect(lastUploadedImage.url).to.equal('HTTP')
      expect(lastUploadedImage.description).to.equal('Image description')
    })

ethers.getSigner simply retrieves the account or wallet used to deploy the contracts, this represents msg.sender . So, we call theuploadImage function passing parameters as well. After this, we can test that there was an increment in imageCount, the author matches the deployers address and URL and description are both correct.

Test case 3: Test for failure

 it("Should fail to upload image", async () => {
       await instagram.uploadImage('', 'Image Description').should.be.rejected;
    await instagram.uploadImage('https://image.com', '').should.be.rejected;
    })

This case happens to be the reason for writing, to test for failure. Currently, in our contract, we have guards that ensure both image URL and description are not empty.

require(bytes(imageUrl).length > 0, 'Image imgHash is required');
  require(bytes(_description).length > 0, 'Image description is required');

It's not typical to test for failures, but I recommend this for full coverage tests on your contracts.

The End

Happy Hacking