One of the most important factors to consider when writing software is testing. The ability to write good automated tests will save you time as a developer that will be spent while trying to manually test your software as a user would do.
Bugs, misbehaviors and flaws in your code will be shown to you more clearly when you write good tests for your code. Ensuring trust in your code is very important when building, and writing good tests is the best way to do it.
Solidity has it owns testing functionality baked into it, and you can totally write your tests 100% in solidity. But personally, I like using chai, which am used to working with on a daily and it makes me feel much closer to the user because it comes after I write the deploy script.
Starting the project
initialize a new hardhat project, using
this link
Writing the smart contract
Create a new folder and name it Contracts
Import solidity and create a contract object called Forum
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
contract Forum {
}
First of all, lets first understand what we are going to build,
We need to create a contract that lets users pay some eth to join and start blogging on the contract
- We need to set a joining fee amount
- We need to keep track of the number of users/bloggers in the contract
- We need to save the blogger's addresses in the contract
Using the set variables write;
uint256 private immutable i_joinFee;
uint256 private s_count;
address[] private s_bloggers;
Notice how we have a specific naming convention for our variables. We don't have to absolutely follow this style of naming, but it helps to give our codebase a clear definition to someone that's reading it.
The variables that are prefixed with i_
at the beginning mean that they are **immutable **, hence they won't change that much. And the variables that are prefixed with s_
at the beginning mean that they are storage
variables, hence can be subjected to change at a time.
Following such a naming convention makes the smart contract very gas efficient because we are explicitly letting the compiler know which functions are immutable and which aren't.
Read more on more gas optimization conventions to follow when writing your smart contracts here
Notice how we used the private
key in the contract variables. This makes the variables private to the contract and cannot be accessed from outside the contract itself.
At times we may want to read the variables from outside the contract, so what do we do?
In-order to do this, we write the variables in pure functions, and then make the functions public, so that when we can call the function outside the smart contract, it'll return the specific variable that we are looking for.
Pure / View functions
Let create some new pure functions;
// View functions
function getJoinFee() public view returns (uint256) {
return i_joinFee;
}
function getBlogger(uint256 _index) public view returns (address) {
return s_bloggers[_index];
}
function getBloggersCount() public view returns (uint256) {
return s_count;
}
Constructor
We need to set a joining fee and number of players in the contract. Lets do this in the constructor
constructor(uint256 joinFee) {
i_joinFee = joinFee;
s_count = 0;
}
Defining them in the constructor means they'll be set at once initial runtime only.
Note: The constructor is the best place to set your immutable variables that you want to be defined once at deployment of the smart contract
Functions
To keep it very simple and minimalistic, we'll only have one function and it will be the join function that will allow the members to join the smart contract.
function join() public payable {
// Make sure that they are paying the required join fee amount
if (msg.value < i_joinFee) {
revert Forum__NotEnoughEthEntered();
}
s_bloggers.push(msg.sender);
s_count++;
}
The function verifies to let a user join the forum while paying a required joinning fee. We do a check to verify that the memeber is paying with the right amount and if so, we push them to the bloggers
list and increase the count.
The revert Forum__NotEnoughEthEntered();
is a custom error message that is raised when a user attempts to join with an insufficient amount. We use the solidity custom error
function to define these errors and its a convention to declare them at the top of the contract.
Scroll to the top of the contract and add the code below,
error Forum__NotEnoughEthEntered();
Events
We may need to trigger some events when a user joins the contract, and inorder to do this, lets define an event to emit when a user is joins and call it in the join
function
// Events
event JoinForum(address blogger);
function join() public payable {
// Make sure that they are paying the required join fee amount
if (msg.value < i_joinFee) {
revert Forum__NotEnoughEthEntered();
}
s_bloggers.push(msg.sender);
s_count++;
emit JoinForum(msg.sender);
}
Cheers!
Our contract is ready lets review the code, run and compile it..
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
error Forum__NotEnoughEthEntered();
contract Forum {
uint256 private immutable i_joinFee;
uint256 private s_count;
address[] private s_bloggers;
constructor(uint256 joinFee) {
i_joinFee = joinFee;
s_count = 0;
}
// Events
event JoinForum(address blogger);
function join() public payable {
// Make sure that they are paying the required join fee amount
if (msg.value < i_joinFee) {
revert Forum__NotEnoughEthEntered();
}
s_bloggers.push(msg.sender);
s_count++;
emit JoinForum(msg.sender);
}
// View functions
function getJoinFee() public view returns (uint256) {
return i_joinFee;
}
function getBlogger(uint256 _index) public view returns (address) {
return s_bloggers[_index];
}
function getBloggersCount() public view returns (uint256) {
return s_count;
}
}
Try npx hardhat compile
in the console
Deploying the contract
Head over to the hardhat.config.js
file and update the code as follows;
require("@nomicfoundation/hardhat-toolbox");
require("hardhat-deploy");
require("dotenv").config();
const { GOERLI_URL, PRIVATE_KEY } = process.env;
module.exports = {
defaultNetwork: "hardhat",
rinkeby: {
url: GOERLI_URL,
accounts: [PRIVATE_KEY],
chainId: 4,
},
solidity: "0.8.9",
namedAccounts: {
deployer: {
default: 0,
},
},
};
Create a .env
file and add the corresponding enviroment variables.
The deploy script
Create a folder called deploy
and in it create a file and name it 01-Forum-deploy.js
The 01-
prefixed at the beginning will help hardhat deploy know which deploy script to execute first by following its order of the number.
Import ethers and from hardhat
const { ethers, network } = require("hardhat");
Create a function and export it with the module
module.exports = async ({ getNamedAccounts, deployments }) => {
};
module.exports.tags = ['all', 'deploy']
The getNamedAccounts
and deployments
functions are passed in automatically when we run hardhat deploy. So here we are destructuring them.
In our function;
module.exports = async ({ getNamedAccounts, deployments }) => {
// Get the deployer
const { deployer } = await getNamedAccounts();
// get the deploy and run functions
const { deploy, log } = deployments;
// Lets specify the args that are to be passed into the smart contract's constructor
// in this case is JOIN_FEE
const args = [JOIN_FEE];
// deploy the contract
console.log("Deploing contract!");
const forum = await deploy("Forum", {
from: deployer,
args: args,
log: true,
// waitConfirmations: 3,
});
log("Contract deployed");
log("------------------------------------------------");
};
We extract the deployer from getNamedAccounts
and the deploy, log functions from the deployments from ethers.
We called the .deploy()
and passin the smart contract's name Forum
, specify the deployer. The args will accept the runtime variables that have been specified in the smart contract's constructor, in this case the JOIN_FEE
.
Our deploy script is done;
const { ethers, network } = require("hardhat");
const JOIN_FEE = ethers.utils.parseEther("0.05");
module.exports = async ({ getNamedAccounts, deployments }) => {
// Get the deployer
const { deployer } = await getNamedAccounts();
// get the deploy and run functions
const { deploy, log } = deployments;
// Lets specify the args that are to be passed into the smart contract's constructor
// in this case is JOIN_FEE
const args = [JOIN_FEE];
// deploy the contract
console.log("Deploing contract!");
const forum = await deploy("Forum", {
from: deployer,
args: args,
log: true,
// waitConfirmations: 3,
});
log("Contract deployed");
log("------------------------------------------------");
};
module.exports.tags = ["all", "deploy"];
In the console, type npx hardhat deploy
Output:
Nothing to compile
Deploing contract!
deploying "Forum" (tx: 0x773ed4837f8f2b63d070534d248eddc693fbdab15424966962f041cbe1e03444)...: deployed at 0x5FbDB2315678afecb367f032d93F642f64180aa3 with 288594 gas
Contract deployed
Now that our contract was successfully deployed to goerli testnet, we can write some unit test for the contract.
Unit Testing
Create a folder and name it test
and under it create another folder called unit
.
In the unit folder create a file and name it Forum.test.js
Import the following functions
const { getNamedAccounts, deployments, ethers } = require('hardhat')
const { assert, expect } = require('chai')
We'll use assert
and expect
from chai to make our tests
Test are written in describe
functions in them are declared beforeEach()
functions that act as the initial declaration functions for the test.
Inside beforeEach()
we have the it()
that can be run using their specific description name in them. In the it()
we have the ability to run the functions individually at any specific time as you'll see.
Create a describe function and name it Forum
describe('Forum', function () {
}
Declare global variables at the top of the function.
We do this because we may want to reference them inside our functions
let forumContract, forumAddress, deployer
In the beforeEach()
function we'll deploy the contracts and scope the address
beforeEach(async function () {
// Here we define our deployemnts and constants
deployer = await getNamedAccounts().deployer
const deployAll = await deployments.fixture(['all'])
forumContract = await ethers.getContract('Forum')
forumAddress = forumContract.address
})
Here's our code so far
const { getNamedAccounts, deployments, ethers } = require('hardhat')
const { assert, expect } = require('chai')
describe('Forum', function () {
let forumContract, forumAddress, deployer
beforeEach(async function () {
// Here we define our deployemnts and constants
deployer = await getNamedAccounts().deployer
const deployAll = await deployments.fixture(['all'])
forumContract = await ethers.getContract('Forum')
forumAddress = forumContract.address
})
})
Note that we use the deployments.fixture(['all'])
to target the tag with 'all'
that we declared in the deploy script.
Now lets first write a test for the constructor.
In the contract we set an initial bloggersCount to 0
when the contract to deployed.
Let'sy verify that it does so in the test below
describe('constructor', function () {
it('Verifies the blogger number count to be 0', async function () {
const bloggerCount = await forumContract.getBloggersCount()
const expectedNumber = 0
assert.equal(bloggerCount.toString(), expectedNumber)
})
})
We call the .getBloggersCount()
from the contract and whatever is returned, we assert it equal to 0
To run our test, type npx hardhat test
Output:
Forum
constructor
Deploing contract!
✔ Verifies the blogger number count to be 0 (98ms)
1 passing (6s)
We see that our tests is passing.
Notice how our test script is running from top to bottom while redeploying the contract everytime.
This is not necessary because we already deployed it already. We only want to run the constructor test while ignoring the redeploy function.
In order to achieve this, we use
npx hardhat test --grep "Verifies the blogger number count to be 0"
This will ignore the rest of the code and only run the specified test with the description "Verifies the blogger number count to be 0"
Let's write another test.
Lets verify that the join fee set is 0.05
ether.
In top function, lets, declare a global JOIN_FEE
variable for the function.
let JOIN_FEE = ethers.utils.parseEther('0.05')
Create an it()
like below
it('Verifes the set join fee', async function () {
const contractJoinFee = await forumContract.getJoinFee()
assert.equal(contractJoinFee.toString(), JOIN_FEE)
})
Run the test using npx hardhart test --grep "Verifes the set join fee"
Output:
Forum
constructor
Deploing contract!
✔ Verifes the set join fee (55ms)
1 passing (2s)
Learn more about testing smart contracts from the solidity documentation here