Learning How to Unit Test a Smart Contract with Hardhat

Hello. Welcome back. As you might know, I have been taking an Ethereum Developer Bootcamp created by Alchemy University. From the previous week's contents, I have learned elliptic curve cryptography, Merkle Trees, and Ethereum basics which you can find my blog posts about all of them on my page. This week I started learning smart contracts and Hardhat, and this week's assignment was to write testing scripts for these smart contracts.


Before starting to write the test scripts we have to create a Hardhat project, obviously :D We just need to follow these steps:

  1. npm init -y

  2. npm install --save-dev hardhat

  3. npx hardhat

  4. create a JavaScript project

  5. And say "yes" to all other stuff.

After that, we will see the "contracts", "scripts" and "test" folders. The Hardhat also creates example files in these folders which you can delete if you like or maybe read a few times. I can advise you to read them, just to understand what this example project is doing, and I assure you that only reading those files will also teach you a lot of things. I know this because I recently experienced it myself, I learned multiple stuff just by reading that example.

Okay, we created our Hardhat project. For this assignment we don't need to deploy our contract to the blockchain, we only need to test it locally. First of all, we need a contract and it is already given by Alchemy University, but you can use any contract. The important thing is that we need to learn how to test a contract no matter what it does. We should be able to write a test script that mimics the functions inside our contract. Alright, that is our simple contract down below and we will write some tests for this.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

contract Faucet {
  address payable public owner;

  constructor() payable {
    owner = payable(msg.sender);
  }

  function withdraw(uint _amount) payable public {
    // users can only withdraw .1 ETH at a time, feel free to change this!
    require(_amount <= 100000000000000000);
    (bool sent, ) = payable(msg.sender).call{value: _amount}("");
    require(sent, "Failed to send Ether");
  }

  function withdrawAll() onlyOwner public {
    (bool sent, ) = owner.call{value: address(this).balance}("");
    require(sent, "Failed to send Ether");
  }

  function destroyFaucet() onlyOwner public {
    selfdestruct(owner);
  }

  modifier onlyOwner() {
    require(msg.sender == owner);
    _;
  }
}

Above, we have a "faucet" contract that assigns the deploying address as the owner of the contract in the constructor(). Then we have a withdraw() function which can be called by anyone. We also have withdrawAll() and destroyFaucet() functions which have a modifier called onlyOwner(), and that modifier is described at the end. We, developers, need to test every function in this contract. Let's get to it.

Understanding Test Script

Before starting to write tests we have to clear a few things. In our test script, there will be new terms and new dependencies for us to learn or be familiar with. Some of these are: loadFixture, chai, mocha, describe, it etc. Yes, they are new, and they are highly different from what we did until this point.

In our test script, we will mimic our contract's behaviors. We will mimic the deployment and the functions too. To be able to manage that we will use something called Mocha which is a JS testing framework. The describe() and it() functions I mentioned before are coming from Mocha. Inside the describe() we are going to "describe" our contract (which is almost the same code as our deploy.js code because we mimic deployment too). Then inside the describe() we will also have multiple it() functions where we state what we want. Let me just paste the code here and then keep explaining.

describe('Faucet', function () {
  async function deployContractAndSetVariables() {
    const Faucet = await ethers.getContractFactory('Faucet');
    const faucet = await Faucet.deploy();

    const [owner, notOwner] = await ethers.getSigners();

    let moreAmount = ethers.utils.parseUnits('1', 'ether');
    let lessAmount = ethers.utils.parseUnits('0.05', 'ether');

    return { faucet, owner, notOwner, moreAmount, lessAmount };
  }

//this is the point I talk about loadFixture below.

  it('should deploy and set the owner correctly', async function () {
    const { faucet, owner } = await loadFixture(deployContractAndSetVariables);

    expect(await faucet.owner()).to.equal(owner.address);
  });

// it("should be something.....");
// it("should be something else...");
});

As you can see we have a describe() function in which we describe our contract, deploy it and return some values. After that, we also have the it() function which takes some of those values and uses them in expect().

Okay, now it's time to explain what is expect() and loadFixture(). Did you notice that "describe" is an umbrella function and under it, there is a function called deployContractAndSetVariables() and maybe a bunch of it() functions(some of them commented out above)? Let's just think that there are 10 different it() functions for our test. If we need to call deployContractAndSetVariables() to get some value in every it() function, our testing process will be slow and inefficient. For that issue, loadFixture comes to our help. When loadFixture is called for the first time with loadFixture(deployContractAndSetVariables); it will execute. But when it is called for the second time or third time it will not execute the whole deployContractAndSetVariables function again, it will just reset the state to the point right after the first execution (The point in the code above is this: "//this is the point I talk about loadFixture"). This way the function is not called and the contract is not deployed several times for every test we do, it is just called one time and we keep testing right after it for every test.

Alright, we understand the loadFixture, what about expect()? Where did it come from? It comes from something called Chai. I don't know what is the deal with these programmers with food&drink but there are tons of them (waffle, brownie, truffle, chai, mocha, etc). Chai is a BDD/TDD assertion library for developers to test their products. With expect(), we can write language-like test statements -> expect(something).to.be.a(something).

Writing tests for our functions

I think we covered most of the important parts of creating a test script. We described our contract and created one it() function to check if the constructor set the owner correctly. So, what do we need to do? We have to write more it() functions to check other stuff. We, as developers, have to think about how our contract should behave and check if it is acting as we hope.

  • We should check if someone can withdraw more than 0.1 ether

  • We should check if someone other than the owner can withdraw all of the funds

  • We might want to check if self-destruct works properly etc.

Let's dive in:

 it('should revert if the user want to withdraw more then 0.1 eth', async function () {
    const { faucet, moreAmount } = await loadFixture(deployContractAndSetVariables);

    await expect(faucet.withdraw(moreAmount)).to.be.reverted;
  });

As you might remember, in the previous part of this code, we already returned the values we need with this: return { faucet, owner, notOwner, moreAmount, lessAmount }; Remember, we have to return those values when describing, then we will get those values inside the it() function. The "moreAmount" above is 1 ether and our contract should revert the withdraw function. This it() function in our test script checks that.

Let's check something else. For example, if any other user than the owner can call withdrawAll function. It's down below:

it('should revert if someone other than owner tries to withdraw all', async function () {
    const { faucet, notOwner } = await loadFixture(deployContractAndSetVariables);

    await expect(faucet.connect(notOwner).withdrawAll()).to.be.reverted;
  });

We are getting the "faucet" and "notOwner" values with loadFixture and then we use the expect function. I want to draw your attention to the faucet.connect(notOwner)... part of the code. With this, we can connect some other address to our contract and act like someone else while testing. What is this code doing? It's basically saying that "If someone other than the owner tries to call withdrawAll function in our contract, expect it to be reverted".

Great! What else should we check? Maybe we can check when the owner wants to withdraw all of the funds are the funds actually getting transferred? What do we need to know to check that? First, we need to know the balance of the contract address and we also need to use changeEtherBalance. If you read the documentation changeEtherBalance takes to arguments: address and the amount. Let's see:

it('should transfer the funds to the owner', async function () {
    const { faucet, owner } = await loadFixture(deployContractAndSetVariables);
    const balance = await ethers.provider.getBalance(faucet.address)

    await expect(await faucet.withdrawAll()).to.changeEtherBalance(owner, balance);
  });

As withdrawAll(), destroyFaucet() function should also only be called by the owner. We need to check that too. We also need to check if the destroyFaucet() function actually destroys the contract. The first one is the same as above, let's try to check the other one.

What happens when selfdestruct() is called? The funds are transferred and the contract address is gone. We might check that with ethers.provider.getCode. It takes a contract address as an argument and returns the code of the contract. If there isn't a contract on that address it returns "0x". Check the code below:

it('should actually selfdestruct', async function () {
    const { faucet } = await loadFixture(deployContractAndSetVariables);

    await faucet.destroyFaucet();
    const destroyedCode = await ethers.provider.getCode(faucet.address)

    await expect(destroyedCode).to.equal("0x");
  });

First, we are calling faucet.destroyFaucet(); If it actually destroys the faucet, the code of the faucet address should be "0x" and we are checking that.


This was my learning process of how to write testing scripts to test smart contracts with Hardhat and I hope I explained it in a simple way with a basic contract. Thank you for reading and I would appreciate any comments/advice. See you in next week's project.

Take Home Messages (for myself)

  1. Writing tests for smart contracts are fun. You can make a career out of it, who knows?

  2. Trying to test every possible option might help you to understand vulnerabilities and might force you to think like a bad actor. This helps to create better and safer contracts.

  3. Don't forget to return all the values you need in the it() functions.

  4. When using expect() from Chai, be careful with await. If it is not used properly, it might cause some trouble.