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 Crowdsale Contracts

Hello! Welcome to the next lesson in your journey to build a complete token application.

In our last lesson, we added a crucial security feature: a whitelist to control who can participate in the MyTokenSale. We now have a contract with multiple layers of logic: a token price, a funding cap, a specific sale window, and an access list. With this complexity, how can we be sure everything works together as expected? The answer is automated testing.

Today, your goal is to write tests to ensure the crowdsale contract handles funds and token distribution correctly. Given your background as a backend developer, you know that a robust test suite is non-negotiable for any production system. In the world of immutable smart contracts, where a single bug can lead to a permanent loss of funds, it's even more critical.

This lesson will guide you through building a comprehensive test suite for our MyTokenSale contract using the tools in our Hardhat environment.

1. The Anatomy of a Smart Contract Test

Before we dive into writing tests, let's quickly review the structure and tools we'll be using. Your Hardhat project already has a dedicated test directory. This is where all our test files will live.

Hardhat Project Folder Structure for Testing
A typical Hardhat project structure, with contracts, scripts, and a dedicated `test` folder for our test files.

Our testing stack consists of:

  • Mocha: A JavaScript test framework that provides the describe() and it() functions to structure our tests into logical suites and individual test cases.
  • Chai: An assertion library that gives us the expect() function to verify that our contract behaves as intended.
  • Ethers.js: A library that allows our test scripts to interact with the Ethereum blockchain (in this case, the local Hardhat Network), deploy contracts, and send transactions.
  • Hardhat Network Helpers: A powerful library integrated with Hardhat that lets us do things like manipulate time and take snapshots of the blockchain state.

A typical test file is structured like this:

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

describe("MyTokenSale", function () {
  // Setup code runs before tests

  it("Should do something correctly", async function () {
    // 1. Arrange: Set up the test conditions
    // 2. Act: Perform the action to be tested
    // 3. Assert: Check if the outcome is as expected
  });

  it("Should fail when something goes wrong", async function () {
    // Test for expected errors (reverts)
  });
});

2. The "Fixture": Efficient Test Setup

Our MyTokenSale contract has a complex constructor that requires a rate, wallet address, token address, cap, opening time, and closing time. Setting this up manually before every single test would be repetitive and inefficient.

To solve this, we use a pattern called a fixture. A fixture is a setup function that prepares the initial state for our tests (e.g., deploying contracts, creating accounts). We then use Hardhat's loadFixture helper, which runs this setup once, takes a snapshot of the blockchain state, and reverts to that clean state before each test. This ensures our tests are independent and run much faster.

The following video provides an excellent overview of the core Hardhat testing utilities, including the loadFixture pattern we will be using.

Hardhat Testing Tutorial | Solidity Smart Contract Testing Developer | Hardhat Testing Course

Watch this section from the "Hardhat Testing Tutorial" by Daulat Hussain to understand how to import testing utilities and structure a test file using describe and it. Pay close attention to the use of loadFixture to create a reusable setup function.

Watch from 00:25:27 to 00:35:33. Focus on how time, loadFixture, expect, and ethers are imported and how a setup function (runEveryTime in the video) is created and used with loadFixture to deploy a contract and return its instance for use in tests.

3. Writing the Test Suite for MyTokenSale

Now, let's apply these concepts to our project. Create a new file named MyTokenSale.test.js inside your test directory. We will build our test suite step-by-step.

3.1. The Complete Test File

Here is the complete test script. We will break it down section by section below.

const { expect } = require("chai");
const { ethers } = require("hardhat");
const { loadFixture, time } = require("@nomicfoundation/hardhat-network-helpers");

describe("MyTokenSale", function () {
    // We define a fixture to reuse the same setup in every test.
    async function deployMyTokenSaleFixture() {
        // --- SETUP ---
        // Contracts are deployed using the first signer/account by default
        const [owner, investor1, investor2] = await ethers.getSigners();

        // Deploy the ERC-20 Token
        const MyToken = await ethers.getContractFactory("MyToken");
        const token = await MyToken.deploy();
        await token.deployed();

        // --- DEPLOY CROWDSALE ---
        const RATE = 1000; // 1 ETH = 1000 Tokens
        const CAP = ethers.utils.parseEther("100"); // 100 ETH cap
        const openingTime = (await time.latest()) + 60; // Opens in 1 minute
        const closingTime = openingTime + time.duration.days(7); // Open for 7 days

        const MyTokenSale = await ethers.getContractFactory("MyTokenSale");
        const sale = await MyTokenSale.deploy(
            RATE,
            owner.address, // Wallet to receive ETH
            token.address,
            CAP,
            openingTime,
            closingTime
        );
        await sale.deployed();

        // The crowdsale contract needs tokens to sell. Transfer them from the owner.
        const TOKEN_SALE_SUPPLY = ethers.utils.parseUnits("100000", 18); // 100,000 tokens
        await token.transfer(sale.address, TOKEN_SALE_SUPPLY);

        return { sale, token, owner, investor1, investor2, openingTime, closingTime, RATE, CAP };
    }

    // --- TEST SUITES ---

    describe("Deployment", function () {
        it("Should set the correct rate, wallet, and token", async function () {
            const { sale, owner, token, RATE } = await loadFixture(deployMyTokenSaleFixture);

            expect(await sale.rate()).to.equal(RATE);
            expect(await sale.wallet()).to.equal(owner.address);
            expect(await sale.token()).to.equal(token.address);
        });

        it("Should set the correct cap and timed sale parameters", async function () {
            const { sale, CAP, openingTime, closingTime } = await loadFixture(deployMyTokenSaleFixture);

            expect(await sale.cap()).to.equal(CAP);
            expect(await sale.openingTime()).to.equal(openingTime);
            expect(await sale.closingTime()).to.equal(closingTime);
        });
    });

    describe("Token Purchases", function () {
        it("Should allow a whitelisted user to buy tokens during the sale period", async function () {
            const { sale, token, investor1, openingTime, RATE } = await loadFixture(deployMyTokenSaleFixture);
            
            // Arrange
            await sale.add_to_whitelist([investor1.address]);
            await time.increaseTo(openingTime); // Fast-forward time to the sale opening
            const investmentAmount = ethers.utils.parseEther("1.0"); // Investor sends 1 ETH
            const expectedTokens = investmentAmount.mul(RATE);

            // Act & Assert
            await expect(sale.connect(investor1).buyTokens(investor1.address, { value: investmentAmount }))
                .to.emit(sale, "TokensPurchased")
                .withArgs(investor1.address, investor1.address, investmentAmount, expectedTokens);

            // Assert final balances
            expect(await token.balanceOf(investor1.address)).to.equal(expectedTokens);
        });

        it("Should REVERT if a non-whitelisted user tries to buy", async function () {
            const { sale, investor2, openingTime } = await loadFixture(deployMyTokenSaleFixture);
            
            // Arrange
            await time.increaseTo(openingTime);
            const investmentAmount = ethers.utils.parseEther("1.0");

            // Act & Assert
            await expect(sale.connect(investor2).buyTokens(investor2.address, { value: investmentAmount }))
                .to.be.revertedWith("MyTokenSale: address is not whitelisted");
        });

        it("Should REVERT if a purchase is attempted before the opening time", async function () {
            const { sale, investor1 } = await loadFixture(deployMyTokenSaleFixture);

            // Arrange
            await sale.add_to_whitelist([investor1.address]);
            const investmentAmount = ethers.utils.parseEther("1.0");

            // Act & Assert
            await expect(sale.connect(investor1).buyTokens(investor1.address, { value: investmentAmount }))
                .to.be.revertedWith("TimedCrowdsale: not open");
        });

        it("Should REVERT if a purchase would exceed the cap", async function () {
            const { sale, investor1, openingTime, CAP } = await loadFixture(deployMyTokenSaleFixture);
            
            // Arrange
            await sale.add_to_whitelist([investor1.address]);
            await time.increaseTo(openingTime);
            const investmentAmount = CAP.add(ethers.utils.parseEther("1.0")); // More than the cap

            // Act & Assert
            await expect(sale.connect(investor1).buyTokens(investor1.address, { value: investmentAmount }))
                .to.be.revertedWith("CappedCrowdsale: cap exceeded");
        });
    });

    describe("Whitelist Management", function () {
        it("Should allow the owner to add an address to the whitelist", async function () {
            const { sale, investor1 } = await loadFixture(deployMyTokenSaleFixture);

            await sale.add_to_whitelist([investor1.address]);
            expect(await sale.isWhitelisted(investor1.address)).to.be.true;
        });

        it("Should REVERT if a non-owner tries to add to the whitelist", async function () {
            const { sale, investor1, investor2 } = await loadFixture(deployMyTokenSaleFixture);
            
            await expect(sale.connect(investor1).add_to_whitelist([investor2.address]))
                .to.be.revertedWith("Ownable: caller is not the owner");
        });
    });
});

3.2. Breakdown of the Test Suites

Let's analyze what each part of this file does.

describe("Deployment", ...)

This suite verifies that the contract constructor and initial setup worked correctly.

  • Test 1: Checks that the core crowdsale parameters (rate, wallet, token) were stored correctly.
  • Test 2: Confirms that the parameters for our inherited contracts (CappedCrowdsale, TimedCrowdsale) were also set up properly.

describe("Token Purchases", ...)

This is the most important suite, testing the main buyTokens functionality.

  • Happy Path Test: This is the ideal scenario. We test that a user who should be able to buy tokens can.

    1. Arrange: We add our investor1 to the whitelist and use the Hardhat time helper to fast-forward the blockchain's clock to the openingTime.
    2. Act: We simulate investor1 calling buyTokens by using .connect(investor1) and sending 1 ETH as the value.
    3. Assert: We use a powerful Chai matcher, emit, to check that the TokensPurchased event was fired with the correct parameters. We also check the investor's final token balance to ensure the transfer was successful.
  • Revert Condition Tests: These tests ensure our security checks and business rules are enforced.

    • Non-whitelisted: We try to buy with investor2 (who is not on the whitelist) and expect the transaction to be revertedWith our specific error message from the previous lesson.
    • Not Open: We try to buy before the openingTime and expect the revert message from OpenZeppelin's TimedCrowdsale contract.
    • Cap Exceeded: We try to buy for an amount greater than the CAP and expect the revert message from CappedCrowdsale.
Test your understanding!

In the "Happy Path Test", we verify that the TokensPurchased event is emitted. Why is this a better test than just checking the final token balance of the investor?

Show answer

While checking the final balance is important, testing for the event emission verifies more of the contract's behavior. It confirms not only that tokens were transferred but also that the contract correctly logged the key details of the transaction: who the purchaser was, who the beneficiary was, the exact weiAmount spent, and the tokenAmount received. This is crucial for off-chain applications that listen for these events to update a user interface or database.

describe("Whitelist Management", ...)

This suite tests the Ownable access control on our add_to_whitelist function.

  • Test 1: Confirms the owner can successfully add an address.
  • Test 2: Confirms that a transaction from any other account (investor1) trying to call this function is reverted. This proves our onlyOwner modifier is working.

For a deeper dive into testing revert conditions, time-based logic, and access control, the following resource is very helpful. It demonstrates these patterns clearly.

Mastering Smart Contract Testing: Full Test Suite for ERC-20 Token Sale

This article from Medium, although using a different framework (Truffle), demonstrates the essential logic for testing a token sale. It shows how to test for initialization, successful purchases, and failures like underpayment or overselling.

Read 'Part 2: Testing the Token Sale'. Notice the logical structure of the tests: initialization, buying tokens (happy path), and preventing invalid actions (underpayment, overselling, unauthorized sale ending). You can see how the concepts map directly to our Hardhat test suites.

Conclusion

Excellent work! You've just built a professional-grade test suite for a complex smart contract. This is a critical skill for any serious blockchain developer. By testing the happy path, edge cases, and security rules, you can deploy your contracts with a much higher degree of confidence.

Key Takeaways:

  • Testing is non-negotiable for smart contracts due to their immutability.
  • Fixtures (loadFixture) are essential for writing clean, fast, and independent tests by creating a reusable initial state.
  • A good test suite covers deployment verification, happy paths, and sad paths (reverts).
  • Hardhat's network helpers (like time) and Chai matchers (like revertedWith and emit) are powerful tools for simulating real-world conditions and asserting outcomes.
  • Testing access control ensures that administrative functions can only be called by authorized accounts (e.g., the owner).

Preview of the Next Lesson:
Our token and crowdsale contracts are now complete and well-tested. But to truly grow the value of a token, you need to create utility for it. One of the most popular mechanisms in DeFi is staking. In our next module, we will begin a new, exciting phase: designing the smart contract architecture for a token staking mechanism that rewards users for locking up their tokens.

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

Sign up