Recommended resources
The freely available resources this lesson will direct you to.
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.

Our testing stack consists of:
- Mocha: A JavaScript test framework that provides the
describe()andit()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.
- Arrange: We add our
investor1to the whitelist and use the Hardhattimehelper to fast-forward the blockchain's clock to theopeningTime. - Act: We simulate
investor1callingbuyTokensby using.connect(investor1)and sending 1 ETH as thevalue. - Assert: We use a powerful Chai matcher,
emit, to check that theTokensPurchasedevent was fired with the correct parameters. We also check the investor's final token balance to ensure the transfer was successful.
- Arrange: We add our
-
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 berevertedWithour specific error message from the previous lesson. - Not Open: We try to buy before the
openingTimeand expect the revert message from OpenZeppelin'sTimedCrowdsalecontract. - Cap Exceeded: We try to buy for an amount greater than the
CAPand expect the revert message fromCappedCrowdsale.
- Non-whitelisted: We try to buy with
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 ouronlyOwnermodifier 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 (likerevertedWithandemit) 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.