Hello again! In our last lesson, you built a solid foundation by writing tests to verify the initial state of your MyToken contract. We confirmed that upon deployment, its name, symbol, total supply, and the deployer's balance are all set correctly. This ensures the contract starts in a known, valid state.
Today, we move from verifying the static setup to testing the contract's dynamic behavior. This lesson is all about the transfer function—the very heart of an ERC-20 token. Your goal is to write tests that confirm tokens can be transferred successfully between accounts and, just as importantly, that transfers fail when they should. This is akin to validating the core transaction logic in a financial system; every possible outcome must be tested to ensure the integrity of the ledger.
We will cover three critical aspects of testing the transfer function:
- Verifying that sender and receiver balances are updated correctly after a successful transfer.
- Confirming that a
Transferevent is emitted with the correct details. - Ensuring that the transaction reverts with a specific error message if a user tries to send more tokens than they own.
1. The Mechanics of a Token Transfer
Before we write tests, let's look at what the transfer function actually does under the hood. When you use OpenZeppelin's ERC20 contract, the transfer function ultimately calls an internal function named _transfer.

As you can see in the _transfer function in the image, a standard transfer involves three key steps:
- Check and decrease the sender's balance: The line
_balances[sender] = _balances[sender].sub(amount, ...)subtracts the transfer amount from the sender's balance. Since Solidity v0.8, this subtraction will automatically revert if the sender's balance is less than theamount(preventing an underflow), effectively serving as our balance check. - Increase the recipient's balance:
_balances[recipient] = _balances[recipient].add(amount)adds the same amount to the recipient's balance. - Emit an event:
emit Transfer(sender, recipient, amount)logs the transaction details on the blockchain. This is crucial for off-chain applications, like block explorers or the data warehousing tools you're familiar with, to track token movements without having to query contract state directly.
Our tests must verify that each of these steps executes correctly.
2. Testing a Successful Transfer
To test a transfer, we need more than one account. Hardhat makes this easy. The ethers.getSigners() function gives us an array of pre-funded test accounts we can use to simulate interactions.
We'll set up our test file to have access to the owner (who deployed the contract and holds all the tokens initially) and at least one other account, which we'll call addr1.
// At the top of your describe block
let myToken, owner, addr1, addr2;
// Inside beforeEach
[owner, addr1, addr2] = await ethers.getSigners();
//... contract deployment
The primary test case is to see what happens when the owner transfers some tokens to addr1. We need to verify that the owner's balance decreases and addr1's balance increases by the correct amount.
While you could check each balance individually before and after the transfer, the hardhat-chai-matchers plugin gives us a much more elegant and powerful tool: changeTokenBalances. This matcher checks the balance changes for multiple accounts in a single, atomic assertion.
The official Hardhat documentation provides the perfect guide for this.
5. Testing contracts | Ethereum development environment ...
This part of the Hardhat tutorial explains how to test transactions between different accounts. It introduces the connect() method and the changeTokenBalances matcher, which are fundamental tools for our current goal.
First, quickly review the section "Using a different account". This explains the connect() method, which is essential for making a contract call from an account other than the default deployer. Next, scroll down to the "Full coverage" example code within the describe("Transactions", ...) block. Focus on the test case named "Should transfer tokens between accounts". Pay close attention to this line: await expect(hardhatToken.transfer(addr1.address, 50)).to.changeTokenBalances(hardhatToken, [owner, addr1], [-50, 50]); This single line does the following: Executes hardhatToken.transfer(addr1.address, 50). Asserts that on the hardhatToken contract... ...the balances for the accounts [owner, addr1]... ...change by [-50, 50] respectively.
This is a clean and expressive way to test the core financial logic of the transfer.
3. Testing Event Emissions
A successful transfer must emit a Transfer event. This is our on-chain audit trail. Testing for this event and its data is just as important as testing the balance change.
The hardhat-chai-matchers library provides another matcher for this: .to.emit(), which can be chained with .withArgs() to check the event's parameters.
The Hardhat documentation you just read also covers this.
5. Testing contracts | Ethereum development environment ...
This resource is the same as before, but now we're focusing on testing events.
In the "Full coverage" section, find the test case right below the one we just looked at, titled "Should emit Transfer events". The key line is: await expect(hardhatToken.transfer(addr1.address, 50)).to.emit(hardhatToken, "Transfer").withArgs(owner.address, addr1.address, 50); This asserts that the transfer call causes the hardhatToken contract to emit a Transfer event, and that the arguments of that event are exactly the sender's address, the recipient's address, and the amount. For a more dynamic explanation, the following video demonstrates testing for both reverts and events. Watch this segment to see how an event test is constructed.
For a more dynamic explanation, the following video demonstrates testing for both reverts and events. Watch this segment to see how an event test is constructed.
This video from a Chainlink bootcamp provides a clear, practical walkthrough of writing unit tests for events and storage updates in a Hardhat environment.
Watch the segment from 28:15 to 30:50. The presenter writes a test to emit a Deposit event and then adds the .withArgs() check to validate its parameters. The principle is identical to testing our Transfer event.
4. Testing Failed Transfers
Robust systems are defined not just by how they handle success, but also by how they handle failure. We must test that our contract correctly prevents invalid actions. The most common failure case for a transfer is an insufficient balance.
When the require statement in the OpenZeppelin contract's _transfer function (or the built-in underflow check) fails, the transaction reverts. Our test should confirm that this revert happens and that it happens for the right reason.
The matcher for this is .to.be.revertedWith(). You must provide the exact error string that the contract is expected to return. For OpenZeppelin's ERC20, this is ERC20: transfer amount exceeds balance.
Let's return to the Hardhat documentation one last time.
5. Testing contracts | Ethereum development environment ...
We continue with the same Hardhat tutorial page.
Now, examine the final test case in the "Transactions" block, titled "Should fail if sender doesn't have enough tokens". It simulates a transfer from addr1, who has a balance of 0, and expects the transaction to be reverted with a specific error message. The key line is: await expect(hardhatToken.connect(addr1).transfer(owner.address, 1)).to.be.revertedWith("Not enough tokens"); Note that the example uses a custom error message, "Not enough tokens". When using the OpenZeppelin contract, you should test for the standard error message ERC20: transfer amount exceeds balance.
5. Putting It All Together
Now, let's integrate these new tests into your MyToken.test.js file. We'll add a new describe block for "Transactions" to keep our tests organized.
Here is the complete test file, including the deployment tests from the previous lesson and the new transfer tests.
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("MyToken", function () {
let MyToken;
let myToken;
let owner;
let addr1;
let addr2;
const initialSupply = ethers.parseEther("1000000"); // 1 million tokens
// Deploy a fresh contract before each test
beforeEach(async function () {
[owner, addr1, addr2] = await ethers.getSigners();
MyToken = await ethers.getContractFactory("MyToken");
myToken = await MyToken.deploy(initialSupply);
});
// Test suite for deployment
describe("Deployment", function () {
it("Should have the correct name", async function () {
expect(await myToken.name()).to.equal("MyToken");
});
it("Should have the correct symbol", async function () {
expect(await myToken.symbol()).to.equal("MTK");
});
it("Should have the correct total supply", async function () {
expect(await myToken.totalSupply()).to.equal(initialSupply);
});
it("Should assign the total supply to the deployer", async function () {
expect(await myToken.balanceOf(owner.address)).to.equal(initialSupply);
});
});
// Test suite for transactions
describe("Transactions", function () {
it("Should transfer tokens between accounts", async function () {
const transferAmount = ethers.parseEther("50");
// Transfer 50 tokens from owner to addr1
await expect(myToken.transfer(addr1.address, transferAmount))
.to.changeTokenBalances(myToken, [owner, addr1], [-transferAmount, transferAmount]);
});
it("Should emit Transfer events", async function () {
const transferAmount = ethers.parseEther("50");
// Transfer 50 tokens from owner to addr1
await expect(myToken.transfer(addr1.address, transferAmount))
.to.emit(myToken, "Transfer")
.withArgs(owner.address, addr1.address, transferAmount);
});
it("Should fail if sender doesn’t have enough tokens", async function () {
const initialOwnerBalance = await myToken.balanceOf(owner.address);
const transferAmount = ethers.parseEther("1");
// Try to send 1 token from addr1 (0 tokens) to owner.
await expect(myToken.connect(addr1).transfer(owner.address, transferAmount))
.to.be.revertedWith("ERC20: transfer amount exceeds balance");
// Owner's balance should not have changed.
expect(await myToken.balanceOf(owner.address)).to.equal(initialOwnerBalance);
});
});
});
Replace the content of your test/MyToken.test.js file with the code above and run npx hardhat test in your terminal. You should see all tests pass, confirming your token's transfer functionality is working exactly as expected.
Conclusion
You have now successfully written a comprehensive test suite for the most fundamental feature of your ERC-20 token: the transfer function. You've verified the "happy path" (a successful transfer) and the "unhappy path" (a failed transfer), ensuring your contract is both functional and secure against basic invalid operations.
Key Takeaways:
- Simulating Users: Use
ethers.getSigners()to get multiple accounts andconnect()to execute transactions from different perspectives. - Testing Balance Changes: The
changeTokenBalancesmatcher provides a clean, atomic way to verify that token amounts have moved correctly between accounts. - Testing Events: The
to.emit(...).withArgs(...)matcher is crucial for confirming that your contract communicates its actions correctly to the outside world. This is a key component for off-chain indexing and analytics. - Testing Reverts: Use
to.be.revertedWith("Error message")to ensure your contract properly rejects invalid transactions with the expected error.
In the next lesson, we will explore the other core mechanism of the ERC-20 standard: the allowance system, using the approve and transferFrom functions. This will enable you to write tests for scenarios where one account (or another smart contract) is given permission to spend tokens on behalf of a user.