Smart Contracts Testing using Mocha.js and Chai.js

Introduction

solidity Smart Contracts Testing

Smart contracts execute on the blockchain and often manage valuable assets or critical logic. Thorough testing helps identify and prevent bugs, glitches, or unintended behaviors that could lead to incorrect results, data corruption, or financial losses. By validating the expected behavior of a smart contract, testing increases its reliability and ensures that it functions as intended.

Smart contracts are immutable and irreversible once deployed on the blockchain. This means any vulnerabilities or flaws present at deployment can have long-lasting consequences. Rigorous testing helps identify and address security vulnerabilities, such as loopholes, backdoors, or exploitable code. By simulating different scenarios and attacks, testing enhances the contract's resistance to potential breaches or hacks, safeguarding the assets and data it manages.

In other words, testing smart contracts mitigates risks, reduces errors, and enhances the overall quality of blockchain applications. It gives developers, auditors, and users confidence that the contracts will perform reliably and securely in real-world scenarios.

Mocha.js: The Flexible Testing Framework

Mocha.js is a robust and versatile JavaScript testing framework with an elegant structure for organizing and executing tests. Its flexibility allows you to seamlessly test various aspects of your code, making it an excellent fit for both frontend and backend development and smart contract testing. With Mocha.js, you can construct organized test suites, define test cases, and effortlessly integrate asynchronous operations.

Mocha.js offers a clear and intuitive syntax, empowering you to craft descriptive and readable test scripts. Its support for various assertion libraries and plugins further enriches your testing capabilities. This flexibility, combined with Mocha.js' widespread adoption, makes it an ideal choice for ensuring the reliability and functionality of your smart contracts. Complete documentation can be found here.

Chai.js: Expressive Assertions for Testing

Chai.js, a powerful assertion library, complements Mocha.js by enhancing the clarity and expressiveness of your test cases. With Chai.js, you can articulate your expectations about how your code should behave in a human-readable manner. Its versatile "expect", "should", and "assert" styles enable you to construct assertions that closely resemble natural language, making your tests more understandable and maintainable.

Chai.js seamlessly integrates with Mocha.js, offering a comprehensive suite of assertion methods to validate a wide range of conditions. Whether you're verifying contract state changes, event emissions, or complex business logic, Chai.js equips you with the tools to craft meaningful and precise assertions, ensuring that your smart contracts perform flawlessly. Complete documentation can be found here.

Token Transfer smart contract

In this, I will use a demo token smart contract, referenced from hardhat.org documentation, to show how we can write the test cases for a smart contract.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract Token {
    string public name = "C# Corner";
    string public symbol = "CSC";
    uint256 public totalSupply = 1000000;
    address public owner;
    mapping(address => uint256) balances;
    event Transfer(address indexed _from, address indexed _to, uint256 _value);

    constructor() {
        balances[msg.sender] = totalSupply;
        owner = msg.sender;
    }

    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount, "Not enough tokens");
        balances[msg.sender] -= amount;
        balances[to] += amount;
        emit Transfer(msg.sender, to, amount);
    }

    function balanceOf(address account) external view returns (uint256) {
        return balances[account];
    }
}

In the above smart contract

  • SPDX-License-Identifier is used to specify the license version of the smart contracts. I have not specified any license, hence it took the default value, i.e., UNCENSORED.
  • We are using a solidity compiler version above 0.8; if you want to use any other version, you can use the following syntax to specify
    pragma solidity >=0.4.9 <0.8.0;

    In the above, I am specifying to use any of the compiler versions between 0.4.9 and 0.8.0, excluding 0.8.0

  • Within our contract named "Token," we define essential string constants such as "name," "symbol," and "total supply" to characterize the token's identity and availability.
  • The "owner" variable securely stores the encrypted address of the contract initiator, serving as a reference point for contract control.
  • We employ a mapping variable called "balances" to meticulously record the token balances linked to each participating address throughout the contract's operation.
  • Through the "Transfer" event, we broadcast crucial transaction details, including the sender, recipient, and the precise amount being transferred, effectively documenting each token movement.
  • During contract instantiation, we initialize the owner's balance to mirror the predefined total supply, cementing the initial token distribution. Furthermore, we associate the contract initiator's address with the owner's role, establishing authoritative control.
  • The "transfer" function accepts parameters for the sender, receiver, and the intended amount to be transferred. It orchestrates the following actions:
    • Verifying whether the sender possesses sufficient tokens for the transfer.
    • Reducing the specified amount from the sender's token balance while simultaneously augmenting the recipient's balance.
    • Emitting a comprehensive event signal containing details of the sender, recipient, and the exact amount transmitted.
  • The "balanceOf" function, designed to enhance transparency and ease of use, welcomes an address as input and promptly delivers the corresponding token balance associated with that specific address.

I am not going into the deployment process and will directly start writing the test cases.

Key Concepts

  1. describe Function: The describe function is used to group related test cases together, creating a logical structure for your tests. It takes two arguments: a string description of the test suite and a callback function. The callback function contains the individual test cases (it blocks) that belong to this test suite.

    describe('Math Operations', () => { 
        // it blocks (test cases) will be defined here 
    });
  2. it Function: The it function defines an individual test case within a describe block. It also takes two arguments: a string description of the specific test case and a callback function that contains the test code and assertions. Each it block should represent a single behavior or scenario you want to test.

    describe('Math Operations', () => {
        it('should add two numbers correctly', () => {
            // Test code and assertions will be written here
        });
    
        it('should subtract two numbers correctly', () => {
            // Test code and assertions will be written here
        });
    });
  3. Expect: The expect function in Chai.js is a key component of the Chai assertion library, commonly used for writing expressive and readable tests in JavaScript. The expect function enables you to make assertions about values and test conditions within your code, making it easier to validate expected outcomes in your tests.

    const chai = require('chai');
    const expect = chai.expect;
    
    describe('Math Operations', () => {
        it('should add two numbers correctly', () => {
            const result = add(2, 3); // Assuming there's a function named 'add'
            expect(result).to.equal(5);
        });
    });

Test Cases for Token Transfer smart contract

Now as we have discussed all the necessary things, we will start writing some test cases.

Deployment Test Cases

describe("Deployment", function () {
    it("Should set the right owner", async function () {
        const [owner] = await ethers.getSigners();
        const demoToken = await ethers.deployContract("Token");
        expect(await demoToken.owner()).to.equal(owner.address);
    })
    it("Should assign the total supply of tokens to the owner", async function () {
        const [owner] = await ethers.getSigners();
        const demoToken = await ethers.deployContract("Token");
        const ownerBalance = await demoToken.balanceOf(owner.address);
        expect(await demoToken.totalSupply()).to.equal(ownerBalance);
    });
});
  • Verifying Contract Instantiation by Valid Owner: In this test, we ensure that the smart contract has been successfully instantiated with accurate ownership attribution. By validating the contract's owner, we confirm that the instantiation was executed by the intended and authorized entity.

  • Validating Total Supply Assignment to Owner: This test rigorously validates the integrity of the total supply configuration within the contract. By examining the contract owner's balance, we affirm that the total supply has been meticulously set and aligned with the owner's token holdings.

Let's understand the code written above.

  • The line

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

    efficiently retrieves the first address from a list of addresses provided by the Ethereum provider. This selected address is then designated as the authoritative owner of the smart contract, conferring control and administrative privileges.

  • By utilizing the line

    const demoToken = await ethers.deployContract("Token");

    the process of deploying the "Token" smart contract is executed. This action instantiates an instance of the contract on the Ethereum blockchain, enabling it to be interacted with by participants.

  • The assertion

    expect(await demoToken.owner()).to.equal(owner.address); 

    employs the Chai.js expect function to verify that the address responsible for instantiating the smart contract aligns precisely with the designated "owner" address. This ensures the contract's ownership integrity and safeguards its administration.

  • The line

     const ownerBalance = await demoToken.balanceOf(owner.address); 

    orchestrates the retrieval of the token balance associated with the designated contract "owner" address. This action leverages the "balanceOf" function embedded within the smart contract, facilitating an accurate assessment of the owner's token holdings.

  • The assertion

    expect(await demoToken.totalSupply()).to.equal(ownerBalance); 

    takes advantage of the Chai.js expect function to validate the unblemished coherence of the total supply. By confirming that the current account balance impeccably mirrors the original account balance established during contract instantiation, this verification fortifies the contract's foundational attributes.

Transaction Test Cases

describe("Transaction", function () {
    it("Should transfer tokens between accounts", async function () {
        const [addr1, addr2] = await ethers.getSigners();
        const demoToken = await ethers.deployContract("Token");
        await demoToken.transfer(addr1.address, 50);
        expect(await demoToken.balanceOf(addr1.address)).to.equal(50);
        await demoToken.connect(addr1).transfer(addr2.address, 50);
        expect(await demoToken.balanceOf(addr2.address)).to.equal(50);
    });

    it("Should emit transfer events", async function () {
        const [owner, addr1, addr2] = await ethers.getSigners();
        const demoToken = await ethers.deployContract("Token");
        await expect(demoToken.transfer(addr1.address, 50)).to.emit(demoToken, "Transfer").withArgs(owner.address, addr1.address, 50);
        await expect(demoToken.connect(addr1).transfer(addr2.address, 50)).to.emit(demoToken, "Transfer").withArgs(addr1.address, addr2.address, 50);
    });

    it("Should fail if sender doesnt have enough tokens", async function () {
        const [owner, addr1] = await ethers.getSigners();
        const demoToken = await ethers.deployContract("Token");
        const initialOwnerBalance = await demoToken.balanceOf(owner.address);
        await expect(demoToken.connect(addr1).transfer(owner.address, 1)).to.be.revertedWith("Not enough tokens");
        expect(await demoToken.balanceOf(owner.address)).to.equal(initialOwnerBalance);
    });
});

Here above, we write 3 test cases:

  • Validating Token Transfer Capability: Through meticulous assessment, we verify the seamless functionality of the contract's token transfer mechanism. This evaluation encompasses a successful transfer of tokens from one address to another, gauging the contract's adeptness at facilitating secure and accurate value exchange.

  • Ensuring Successful Event Emission: Our evaluation extends to validating the robustness of event emission subsequent to a transaction. We meticulously scrutinize and confirm that the essential event is emitted without impediment, showcasing the contract's adeptness at broadcasting pivotal transactional details to external observers.

  • Safeguarding Against Insufficient Balance: In our scrutiny, we enact a deliberate transaction with a sender possessing an inadequate token balance. Through this controlled scenario, we meticulously examine and ensure that the transaction unequivocally fails, asserting the contract's robustness in enforcing balance-related constraints and preserving the integrity of token operations.

Let's understand the code

  • The line

    await demoToken.transfer(addr1.address, 50); 

    triggers the initiation of a token transfer transaction within the smart contract. The transfer function is called with addr1.address as the recipient and 50 as the token amount to be transferred.

  • Subsequently, the line

    expect(await demoToken.balanceOf(addr1.address)).to.equal(50); 

    conducts an assertion to ensure the accuracy of the token balance associated with the recipient's address addr1.address. This verification confirms that the balance has indeed been adjusted to 50 after the previous token transfer.

  • Continuing the line

    await demoToken.connect(addr1).transfer(addr2.address, 50); 

    employs the connect method to facilitate a chained token transfer from addr1 to addr2, transferring 50 tokens between these two addresses.

  • The subsequent lines feature a sequence of await expect statements, starting with

    await expect(demoToken.transfer(addr1.address, 50)).to.emit(demoToken, "Transfer").withArgs(owner.address, addr1.address, 50);.

    Here, an assertion is made using Chai's expect syntax, confirming that a specific event, namely "Transfer," is emitted by the demoToken contract during the token transfer. The assertion further specifies the event arguments, asserting that the event should involve the owner.address as the sender, addr1.address as the recipient, and 50 as the transferred amount.

  • Subsequently, a similar expect assertion,

    await expect(demoToken.connect(addr1).transfer(addr2.address, 50)).to.emit(demoToken, "Transfer").withArgs(addr1.address, addr2.address, 50);, 

    validates the occurrence of an emitted "Transfer" event for a token transfer initiated by addr1 to addr2.

  • Lastly, the line

    await expect(demoToken.connect(addr1).transfer(owner.address, 1)).to.be.revertedWith("Not enough tokens"); 

    enforces an assertion to confirm that a token transfer from addr1 to the owner.address with an insufficient balance of 1 token should result in the transaction being reverted with an error message indicating "Not enough tokens."

Conclusion

We are done creating a token transfer smart contract and have written 5 test cases. I hope you learned something new. For all your questions, please feel free to post in the comment section.

FAQs

Q. What is smart contract testing with Mocha.js and Chai.js?

Answer: Smart contract testing with Mocha.js and Chai.js involves using these JavaScript testing frameworks to ensure the accuracy and security of Ethereum smart contracts.

Q. Why is testing my smart contracts important?

Answer: Testing smart contracts is crucial to detect and prevent issues that could lead to financial losses or security vulnerabilities.

Q. How do Mocha.js and Chai.js contribute to smart contract testing?

Answer: Mocha.js provides a structured testing environment, while Chai.js offers assertion methods to verify the expected outcomes of smart contract functions.

Q. What's the difference between unit testing and integration testing in smart contracts?

Answer: Unit testing focuses on testing individual functions, while integration testing checks how multiple contracts interact.

Q. Can Mocha.js and Chai.js be used with other testing tools?

Answer: Yes, Mocha.js and Chai.js can be integrated with other tools like Truffle or Hardhat to enhance your smart contract testing capabilities.


Similar Articles