Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Testing ERC-20 Core Functions

Hello! Welcome back to our course on building a blockchain application.

In our last session, we took a crucial step towards professional smart contract development by setting up a complete automated testing environment using Hardhat, ethers.js, and Chai. We created our project, understood the roles of each tool, and prepared an empty test file, test/MyToken.test.ts, ready to be filled with logic.

Today, we'll bring that setup to life. We will write our first unit tests for the MyToken contract you built. Our goal is to programmatically verify that the core functions of our ERC-20 token—specifically balanceOf and transfer—work exactly as expected. This process is the bedrock of ensuring your contract is secure and reliable before it ever handles real value.

By the end of this lesson, you will be able to write and execute unit tests for the fundamental balanceOf and transfer functions of your ERC-20 token contract.


1. The Test Setup: beforeEach

In automated testing, it's essential that each test runs in a clean, isolated, and predictable environment. You wouldn't want the result of one test to affect the outcome of another. In your backend experience, this might involve resetting a database to a known state before each test case runs.

In Hardhat, we achieve this using the beforeEach hook provided by the Mocha testing framework. Any code inside a beforeEach block will be executed before every single it block within the same describe block. For our purposes, this is the perfect place to deploy a fresh instance of our MyToken contract.

Let's start by modifying the test/MyToken.test.ts file you created in the last lesson.

Deploying ERC20 Token With Hardhat: A Step-by-Step Guide

The following article, "Deploying ERC20 Token With Hardhat," provides a complete and well-structured example of a test file for an ERC-20 token. We will use its structure as our guide. Focus on how it sets up variables and the beforeEach block.

Please read the section 'Writing Unit Test for the ERC20 Token' and the subsection 'Global Variables and Setup' within 'The Test Code Structure'. Pay close attention to the beforeEach function. Notice how it gets the contract factory, gets the signers (accounts), and then deploys a new contract instance, storing these in variables declared outside the hook.

Based on that guide, let's update your test/MyToken.test.ts file. Replace the placeholder content with the following structure. Make sure you've copied your MyToken.sol into the contracts/ directory.

// test/MyToken.test.ts

import { expect } from "chai";
import { ethers } from "hardhat";
import { Signer } from "ethers";
import { MyToken } from "../typechain-types"; // This import is auto-generated by Hardhat

// Group tests with a top-level describe
describe("MyToken contract", function () {
  // Declare variables that we'll use in our tests
  let myToken: MyToken;
  let owner: Signer;
  let addr1: Signer;
  let addr2: Signer;
  
  // This runs before each test, deploying a new contract instance
  beforeEach(async function () {
    // Get the ContractFactory and Signers here.
    const tokenFactory = await ethers.getContractFactory("MyToken");
    [owner, addr1, addr2] = await ethers.getSigners();

    // Deploy a new contract before each test
    myToken = await tokenFactory.deploy();
    // Note: If your token's constructor takes arguments (e.g., initial supply), 
    // you would pass them to the deploy function like so:
    // myToken = await tokenFactory.deploy(initialSupply);
  });

  // Test cases will go here
});

Let's break this down:

  • We declare myToken, owner, addr1, and addr2 outside beforeEach so they are accessible within our it test blocks.
  • ethers.getContractFactory("MyToken") creates an object that knows how to deploy our MyToken contract.
  • ethers.getSigners() returns an array of test accounts provided by the Hardhat Network. The first one, by default, is the one that pays for the deployment, becoming the owner.
  • tokenFactory.deploy() sends the transaction to deploy the contract.
  • By the time any it block runs, we will have a freshly deployed myToken contract instance and three distinct accounts (owner, addr1, addr2) to interact with it.

2. Testing balanceOf and Initial State

Our first test will be a simple but vital one: does the account that deployed the contract receive the entire initial supply of tokens? This confirms that our constructor logic worked and that the balanceOf function is reporting the correct state.

We will add a new nested describe block for "Deployment" to keep our tests organized.

5. Testing contracts | Ethereum development environment ...

The official Hardhat documentation has a clear and concise example of this exact test. Let's review it.

Read the code block within the section titled 'Full coverage'. Find the it block with the description 'Should assign the total supply of tokens to the owner'. This is the pattern we will implement.

Now, add the following code inside your main describe("MyToken contract", ...) block, right after the beforeEach hook.

// test/MyToken.test.ts (inside the main describe block)

  describe("Deployment", function () {
    it("Should assign the total supply of tokens to the owner", async function () {
      // Get the balance of the owner's account
      const ownerBalance = await myToken.balanceOf(await owner.getAddress());
      
      // Expect the owner's balance to equal the total supply of the token
      expect(await myToken.totalSupply()).to.equal(ownerBalance);
    });
  });

Here's what this test does:

  1. It calls the balanceOf function from our deployed myToken contract, passing the owner's address.
  2. It then calls the totalSupply function.
  3. Finally, it uses Chai's expect function to assert that the value returned by totalSupply() is equal to the owner's balance. If they match, the test passes.

3. Testing the transfer Function

Now let's test a state-changing function: transfer. A successful transfer involves two parties: the sender's balance must decrease, and the receiver's balance must increase by the same amount. Our test must verify both outcomes.

To send a transaction from an account other than the default owner, we need to use the .connect() method. myToken.connect(addr1) returns a new contract instance that is "connected" to addr1, meaning any transaction sent from it will originate from addr1.

Let's add another describe block for "Transactions".

// test/MyToken.test.ts (inside the main describe block)

  describe("Transactions", function () {
    it("Should transfer tokens between accounts", async function () {
      // 1. Transfer 50 tokens from owner to addr1
      await myToken.transfer(await addr1.getAddress(), 50);
      const addr1Balance = await myToken.balanceOf(await addr1.getAddress());
      expect(addr1Balance).to.equal(50);

      // 2. Transfer 50 tokens from addr1 to addr2
      // We use .connect(signer) to send a transaction from another account
      await myToken.connect(addr1).transfer(await addr2.getAddress(), 50);
      const addr2Balance = await myToken.balanceOf(await addr2.getAddress());
      expect(addr2Balance).to.equal(50);
    });
  });

This test case verifies two transfers in sequence:

  1. It calls transfer from the owner's account (the default) to send 50 tokens to addr1. It then asserts that addr1's balance is now 50.
  2. It uses .connect(addr1) to initiate a transfer from addr1 to addr2. It then asserts that addr2's balance is now 50.

4. Running Your Tests

Your complete test/MyToken.test.ts file should now look like this:

import { expect } from "chai";
import { ethers } from "hardhat";
import { Signer } from "ethers";
import { MyToken } from "../typechain-types";

describe("MyToken contract", function () {
  let myToken: MyToken;
  let owner: Signer;
  let addr1: Signer;
  let addr2: Signer;

  beforeEach(async function () {
    const tokenFactory = await ethers.getContractFactory("MyToken");
    [owner, addr1, addr2] = await ethers.getSigners();
    myToken = await tokenFactory.deploy();
  });

  describe("Deployment", function () {
    it("Should assign the total supply of tokens to the owner", async function () {
      const ownerBalance = await myToken.balanceOf(await owner.getAddress());
      expect(await myToken.totalSupply()).to.equal(ownerBalance);
    });
  });

  describe("Transactions", function () {
    it("Should transfer tokens between accounts", async function () {
      await myToken.transfer(await addr1.getAddress(), 50);
      const addr1Balance = await myToken.balanceOf(await addr1.getAddress());
      expect(addr1Balance).to.equal(50);

      await myToken.connect(addr1).transfer(await addr2.getAddress(), 50);
      const addr2Balance = await myToken.balanceOf(await addr2.getAddress());
      expect(addr2Balance).to.equal(50);
    });
  });
});

Now, open your terminal in the project's root directory and run the test command:

npx hardhat test

You should see an output indicating that your tests are passing. Congratulations! You've just written and executed your first automated unit tests for a smart contract.

Test your understanding!

Now it's your turn to write a test. Add a new test case inside the describe("Transactions", ...) block with the description "Should update balances after transfers". This test should do the following:

  1. Get the owner's initial balance.
  2. Transfer 100 tokens from owner to addr1.
  3. Transfer 50 tokens from owner to addr2.
  4. Check the final balances of all three accounts to ensure they are correct. The owner's balance should have decreased by 150.
Show answer

Here's how you could write that test. Note that BigNumber (the type returned by contract calls) has its own methods like .sub() for arithmetic.

it("Should update balances after transfers", async function () {
  const initialOwnerBalance = await myToken.balanceOf(await owner.getAddress());

  // Transfer 100 tokens from owner to addr1.
  await myToken.transfer(await addr1.getAddress(), 100);

  // Transfer another 50 tokens from owner to addr2.
  await myToken.transfer(await addr2.getAddress(), 50);

  // Check final balances.
  const finalOwnerBalance = await myToken.balanceOf(await owner.getAddress());
  expect(finalOwnerBalance).to.equal(initialOwnerBalance.sub(150));

  const addr1Balance = await myToken.balanceOf(await addr1.getAddress());
  expect(addr1Balance).to.equal(100);

  const addr2Balance = await myToken.balanceOf(await addr2.getAddress());
  expect(addr2Balance).to.equal(50);
});

Conclusion

In this lesson, you moved from setting up a test environment to actively using it to validate your smart contract's logic. This is a massive leap forward in building robust and secure dApps. By writing automated tests, you create a safety net that catches regressions and verifies functionality every time you make a change.

Key Takeaways:

  • The beforeEach hook is essential for creating a clean, isolated state for each test by deploying a fresh contract instance.
  • You can test read-only functions like balanceOf to check the state of the contract.
  • You can test state-changing functions like transfer by performing an action and then asserting that the new state is correct.
  • The contract.connect(signer) method is used to send transactions from different accounts.
  • Chai's expect provides a readable syntax for making assertions about your contract's behavior.

Preview of the Next Lesson:
We've tested direct transfers, but a huge part of the ERC-20 standard is about delegated transfers. In the next lesson, we will dive into testing the approve and transferFrom functions. This will allow us to verify scenarios where one user (or another smart contract) is given permission to spend tokens on behalf of another user.

Can't find a good explanation? Sign up and we'll make it for you

Sign up