Recommended resources
The freely available resources this lesson will direct you to.
- Hardhat Testing Tutorial | Solidity Smart Contract Testing Developer | Hardhat Testing Coursewww.youtube.com
- Testing Solidity Contracts: Prepare for Your Audit Like a Prowww.youtube.com
- Solidity Tutorial: Creating an ERC20 Staking Contractmedium.com
- Building a Web3 Staking Platform: A Comprehensive Guide -metaschool.so
Hello! Welcome to the next lesson in your journey to building a complete decentralized application.
In the previous lessons, you've done the heavy lifting of building the core logic for your staking contract. You designed the reward calculation engine and implemented the stake, withdraw, and getReward functions. The contract should work, but in the world of blockchain, "should" is not good enough. We need proof.
Today's lesson is dedicated to building that proof. Our goal is to write comprehensive tests for the staking, unstaking, and reward calculation logic. Just as in your backend development experience, testing is a crucial phase of the development lifecycle. However, for immutable smart contracts where bugs can lead to irreversible financial loss, it's an absolutely non-negotiable step. We will use Hardhat's powerful testing environment to simulate user interactions and verify that our contract behaves exactly as expected under all conditions.
We will cover:
- Setting up a structured test file using Mocha and Chai.
- Testing the "happy path" for each function: does it work correctly when used as intended?
- Testing the "unhappy paths": does it properly reject invalid actions?
- Verifying state changes, event emissions, and token transfers.
- Introducing the concept of "time travel" to test our time-dependent reward logic.
1. The Anatomy of a Hardhat Test Suite
Before we write our first test, let's understand the structure. We'll be working inside a new file, test/StakingRewards.test.js. Hardhat uses the testing framework Mocha, which provides functions like describe() and it(), and the assertion library Chai, which gives us expect().
describe(name, function): A way to group related tests. You can nestdescribeblocks.it(description, function): An individual test case. The description should explain what the test is verifying, e.g., "it should allow a user to stake tokens".beforeEach(function): A block of code that runs before eachitblock in the samedescribescope. This is perfect for setting up a clean, predictable state for every test, like deploying contracts and creating test accounts.
The following video provides an excellent overview of this structure and the general workflow of testing a Solidity contract.
Testing Solidity Contracts: Prepare for Your Audit Like a Pro
Watch this segment from 'Testing Solidity Contracts: Prepare for Your Audit Like a Pro' to get a feel for the basic structure of a test file, the roles of describe and beforeEach, and how to deploy contracts within your test setup.
Watch from 00:49 to 04:14. Focus on how the describe block organizes the entire test suite and how the beforeEach block is used to deploy a fresh instance of the contract before each test runs. This ensures your tests are independent of each other.
2. Setting Up Our Staking Test Environment
For our staking contract, the beforeEach setup is particularly important. For every single test, we need to:
- Deploy our ERC-20 token contract (let's call it
StakingToken). - Deploy our
StakingRewardscontract, giving it the address of theStakingToken. - Get access to multiple simulated user accounts (signers) to test interactions.
- Distribute some
StakingTokento our test users so they have something to stake. - Fund the
StakingRewardscontract with reward tokens.
Let's look at a concrete example of this setup.
Building a Web3 Staking Platform: A Comprehensive Guide
The article 'Building a Web3 Staking Platform' provides a clear, practical example of a beforeEach block tailored specifically for a staking system. This will serve as a great template for our own test file.
Read the first code block in the 'Testing our smart contract' section. Pay close attention to the beforeEach function. Notice how it deploys both a StakingToken and a StakingPlatform (our StakingRewards), gets signers (owner, addr1, addr2), and then transfers tokens to addr1 and addr2 to prepare them for staking.
Here is what our initial test/StakingRewards.test.js file will look like, based on these principles.
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("StakingRewards", function () {
let StakingToken, stakingToken;
let RewardsToken, rewardsToken;
let StakingRewards, stakingRewards;
let owner, addr1, addr2;
beforeEach(async function () {
[owner, addr1, addr2] = await ethers.getSigners();
// Deploy Staking Token (the one users will stake)
StakingToken = await ethers.getContractFactory("MyERC20Token"); // Use your token name
stakingToken = await StakingToken.deploy();
// Deploy Rewards Token (the one users will earn)
RewardsToken = await ethers.getContractFactory("MyERC20Token"); // Can be the same or different
rewardsToken = await RewardsToken.deploy();
// Deploy StakingRewards Contract
StakingRewards = await ethers.getContractFactory("StakingRewards");
stakingRewards = await StakingRewards.deploy(stakingToken.address, rewardsToken.address);
// Mint some tokens for our test users
await stakingToken.mint(addr1.address, ethers.utils.parseUnits("1000", 18));
await stakingToken.mint(addr2.address, ethers.utils.parseUnits("1000", 18));
// Mint reward tokens and fund the StakingRewards contract
const rewardsAmount = ethers.utils.parseUnits("100000", 18);
await rewardsToken.mint(stakingRewards.address, rewardsAmount);
// Notify the staking contract about the new rewards
await stakingRewards.notifyRewardAmount(rewardsAmount);
// Users must approve the staking contract to spend their tokens
await stakingToken.connect(addr1).approve(stakingRewards.address, ethers.utils.parseUnits("100", 18));
await stakingToken.connect(addr2).approve(stakingRewards.address, ethers.utils.parseUnits("100", 18));
});
// ... our tests will go here ...
});
3. Testing the stake() Function
Let's start by testing the stake() function. We need to verify the "happy path" (it works as expected) and the "unhappy paths" (it fails when it should).
A good set of tests for stake() would be:
- Happy Path: A user stakes a valid amount. We expect their staked balance and the contract's total supply to increase, and their token balance to decrease.
- Unhappy Path: A user tries to stake 0 tokens. We expect the transaction to be reverted with our custom error message.
This article provides excellent examples of how to test these exact scenarios.
Solidity Tutorial: Creating an ERC20 Staking Contract
The 'Solidity Tutorial: Creating an ERC20 Staking Contract' article uses a test-driven approach that's perfect for our needs. We'll look at its 'deposit' tests to build our own.
Focus on the sections 'deposit / should transfer amount' and 'deposit / should increment the total staked by the amount'. Notice the use of changeTokenBalances to assert token movements and expect(await staking.totalStaked()).to.eq(...) to check state variables.
Here's how we'd write those tests in our file:
// Inside the main describe("StakingRewards", ...) block
describe("Staking", function () {
it("Should allow a user to stake tokens and update balances", async function () {
const stakeAmount = ethers.utils.parseUnits("100", 18);
// Perform the stake
await stakingRewards.connect(addr1).stake(stakeAmount);
// Check balances
expect(await stakingRewards.balanceOf(addr1.address)).to.equal(stakeAmount);
expect(await stakingRewards.totalSupply()).to.equal(stakeAmount);
});
it("Should emit a Staked event", async function () {
const stakeAmount = ethers.utils.parseUnits("100", 18);
// Expect the stake transaction to emit a "Staked" event with the correct arguments
await expect(stakingRewards.connect(addr1).stake(stakeAmount))
.to.emit(stakingRewards, "Staked")
.withArgs(addr1.address, stakeAmount);
});
it("Should revert if staking 0 tokens", async function () {
// Expect the transaction to be reverted with a specific error message
await expect(stakingRewards.connect(addr1).stake(0))
.to.be.revertedWith("Cannot stake 0");
});
});
4. Testing Reward Logic with "Time Travel"
Testing reward accrual presents a unique challenge: we can't wait hours or days for rewards to accumulate. This is where Hardhat's "time travel" ability becomes essential. We can instruct the local blockchain network to instantly fast-forward time.
The most common way is to use the evm_increaseTime method. This is a fundamental technique for testing any time-dependent logic in DeFi.
Testing Solidity Contracts: Prepare for Your Audit Like a Pro
This clip from 'Testing Solidity Contracts' will introduce you to the powerful concept of manipulating blockchain time for testing purposes. This is the key to verifying our reward calculations.
Watch from 18:48 to 19:40. The video explains how you can use the provider to send commands like evm_increaseTime to advance the blockchain's clock, which is exactly what we need to test reward accrual.
Let's write a test that uses this. The flow will be:
- A user stakes some tokens.
- We increase the time by one week.
- We check their
earned()amount. It should be non-zero. - The user calls
getReward(). - We verify they received the tokens and their pending rewards are reset.
// Inside the main describe("StakingRewards", ...) block
describe("Rewards", function () {
it("Should accrue rewards over time and allow claiming", async function () {
const stakeAmount = ethers.utils.parseUnits("100", 18);
await stakingRewards.connect(addr1).stake(stakeAmount);
// Increase time by 1 week (in seconds)
const oneWeekInSeconds = 7 * 24 * 60 * 60;
await ethers.provider.send("evm_increaseTime", [oneWeekInSeconds]);
await ethers.provider.send("evm_mine"); // Mine a new block to apply the time change
// Check that rewards have accrued
const earnedRewards = await stakingRewards.earned(addr1.address);
expect(earnedRewards).to.be.gt(0); // Greater than 0
// Get user's balance before claiming
const initialRewardBalance = await rewardsToken.balanceOf(addr1.address);
// Claim rewards
await stakingRewards.connect(addr1).getReward();
// Check balances after claiming
const finalRewardBalance = await rewardsToken.balanceOf(addr1.address);
expect(finalRewardBalance).to.be.gt(initialRewardBalance);
// Check that pending rewards are reset
expect(await stakingRewards.rewards(addr1.address)).to.equal(0);
// earned() should now be close to 0 (might be a tiny amount from the 1 block it took to claim)
expect(await stakingRewards.earned(addr1.address)).to.be.lt(ethers.utils.parseUnits("0.001", 18));
});
});
Test your understanding!
In the test above, why do we need to call ethers.provider.send("evm_mine") after evm_increaseTime? What would happen if we left that line out?
Show answer
The evm_increaseTime command only adjusts the timestamp for the next block. It doesn't automatically create that block. The call to evm_mine forces a new block to be mined, which "seals in" the new, advanced timestamp. Without it, the time change wouldn't be reflected in the contract's state, and functions like block.timestamp would still return the old value. Our earned() calculation would return 0, and the test would fail.
5. Testing the withdraw() Function
Finally, let's test the withdraw() function. We need to verify that it correctly reduces a user's stake and that it fails if they try to withdraw more than they have.
A key integration point to test here is the updateReward modifier on our withdraw function. When a user withdraws, their pending rewards should be calculated and stored before their stake is reduced.
// Inside the main describe("StakingRewards", ...) block
describe("Withdrawing", function () {
it("Should allow a user to withdraw and update balances", async function () {
const stakeAmount = ethers.utils.parseUnits("100", 18);
await stakingRewards.connect(addr1).stake(stakeAmount);
// Let some time pass
const oneDayInSeconds = 24 * 60 * 60;
await ethers.provider.send("evm_increaseTime", [oneDayInSeconds]);
await ethers.provider.send("evm_mine");
// Withdraw half the staked amount
const withdrawAmount = ethers.utils.parseUnits("50", 18);
await stakingRewards.connect(addr1).withdraw(withdrawAmount);
// Check balances
const remainingStake = stakeAmount.sub(withdrawAmount);
expect(await stakingRewards.balanceOf(addr1.address)).to.equal(remainingStake);
expect(await stakingRewards.totalSupply()).to.equal(remainingStake);
// Check that rewards were updated (but not paid out)
expect(await stakingRewards.rewards(addr1.address)).to.be.gt(0);
});
it("Should revert if withdrawing more than staked", async function () {
const stakeAmount = ethers.utils.parseUnits("100", 18);
await stakingRewards.connect(addr1).stake(stakeAmount);
const overWithdrawAmount = ethers.utils.parseUnits("101", 18);
await expect(stakingRewards.connect(addr1).withdraw(overWithdrawAmount))
.to.be.revertedWith("Cannot withdraw 0 or more than staked");
});
});
Conclusion
Congratulations! You've now built a solid, comprehensive test suite for your staking contract. By verifying the correct behavior of staking, withdrawing, and reward claiming, you've significantly increased the security and reliability of your code.
Key Takeaways:
- Tests are organized into
describeanditblocks for clarity. - The
beforeEachblock is essential for creating a clean, repeatable test environment. - We must test for happy paths (correct execution), state changes (
balanceOf,totalSupply), event emissions, and unhappy paths (reverts). - Time manipulation (
evm_increaseTime) is a critical tool for testing any time-dependent logic in smart contracts, especially for reward accrual.
Preview of the Next Lesson:
We've just scratched the surface of time manipulation. In the next lesson, we will dive deeper into Hardhat's advanced time manipulation features. You'll learn how to set the clock to a specific timestamp, explore helpers from @nomicfoundation/hardhat-network-helpers, and write even more precise tests for complex, time-dependent scenarios in your staking contract.