Welcome back. In our previous lesson, you implemented a complete, secure dividend distribution mechanism for our SecurityToken. By using the pull-over-push pattern and OpenZeppelin's ReentrancyGuard, you've built a function that is both efficient and robust against common attack vectors.
With the full implementation of our SecurityToken now complete, our focus shifts from building to verifying. Smart contracts are immutable and often manage significant value, making rigorous testing not just a best practice, but an absolute necessity. This lesson begins that process by focusing on the contract's administrative backbone. You will learn to write tests that validate the token issuance (minting) logic and the role-based access control that secures it, ensuring only authorized parties can create new tokens and manage contract permissions.
1. The Anatomy of a Hardhat Test Suite
Before writing our first test, let's establish the structure. In a Hardhat environment, tests are typically written in JavaScript or TypeScript using the Mocha testing framework and the Chai assertion library. This combination provides a descriptive and readable way to define and validate test cases.
A typical test file is organized with three main functions:
describe(): This function groups related tests together. You can nestdescribeblocks to create a clear hierarchy. For example, we might have a top-leveldescribe("SecurityToken", ...)block with nested blocks for "Deployment", "Issuance", and "Transfers".it(): This is the individual test case. It describes a specific behavior that should be verified, for instance,it("should allow an admin to mint tokens").beforeEach(): This is a "hook" that runs before eachit()block within the samedescribeblock. It's perfect for setting up a clean state for every test, such as deploying a fresh instance of your contract. This ensures that tests are independent and don't influence each other.
The article below provides an excellent overview of this structure. As you read, focus on how describe, beforeEach, and it work together to create an organized test suite.
How to test Smart Contracts. 7th Episode — Utilizing Mocha and Chai
This article from David Cisar breaks down the foundational structure of a test file using Mocha and Chai.
In the section "Testing in Hardhat," pay close attention to the code examples. Notice how the author: Declares variables for the contract instance and accounts at the top of the describe block. Uses the beforeEach hook to deploy a new contract instance and assign accounts before each test. Defines an individual test case using the it() function, which contains the core test logic.
2. Setting Up the Test Environment
For our SecurityToken, the setup is more involved than in a simple contract. We need to manage multiple roles and even a separate contract for dividends. Our beforeEach hook will be responsible for creating this entire testing environment from scratch for every test.
Here’s what our setup will look like in our test file, which you can create at test/SecurityToken.test.js:
- Get Accounts: We'll use
ethers.getSigners()to get a list of test accounts provided by Hardhat. We can then assign them to descriptive names likeowner,minter,complianceAdmin, and a genericuserto represent an unauthorized party. - Deploy Dependencies: Our
SecurityTokenconstructor requires the address of the dividend token. We'll deploy a mock ERC-20 contract for this purpose. - Deploy the Main Contract: We'll deploy the
SecurityTokencontract itself. - Assign Roles: Using the
owneraccount, we will grant theMINTER_ROLE,COMPLIANCE_ADMIN_ROLE, andDIVIDEND_ADMIN_ROLEto the respective accounts we defined in step 1.
This process, neatly contained within beforeEach, ensures that every it block starts with a fully configured, predictable state.
The following video demonstrates a similar setup process. The instructor creates a helper function that acts just like a beforeEach hook to prepare the environment for testing.
Hardhat Testing Tutorial | Solidity Smart Contract Testing Developer | Hardhat Testing Course
This video by Daulat Hussain provides a practical walkthrough of setting up a test environment and writing tests for deployment state.
Watch the section from creating a setup function. Observe how variables for time, amount, and accounts are defined, and how the contract is deployed. This is analogous to what we will do in our beforeEach hook. Next, see how the tests verify the initial state. The instructor writes tests to check the correct owner and balance after deployment. This is exactly what we'll do to confirm our token's name, symbol, and admin roles are set correctly.
3. Testing Issuance and Role-Based Access Control
With our setup in place, we can now write tests for the core administrative functions. We'll focus on two main aspects: verifying the initial state and roles, and then testing the mint function's logic and access control.
Test 1: Verifying Deployment State
First, we should write a test to confirm the contract is deployed with the correct initial parameters and roles.
it("should set the correct initial state and grant admin role", async function () {
// Check name and symbol
expect(await securityToken.name()).to.equal("My Security Token");
expect(await securityToken.symbol()).to.equal("MST");
// Check that total supply is initially zero
expect(await securityToken.totalSupply()).to.equal(0);
// Check that the deployer (`owner`) has the DEFAULT_ADMIN_ROLE
const DEFAULT_ADMIN_ROLE = await securityToken.DEFAULT_ADMIN_ROLE();
expect(await securityToken.hasRole(DEFAULT_ADMIN_ROLE, owner.address)).to.be.true;
});
Test 2: Testing the mint Function
Next, we'll test the mint function. This requires two distinct test cases: the "happy path" where an authorized user succeeds, and the "sad path" where an unauthorized user fails. This is where we test our role-based access control.
To simulate calls from different users, we use the .connect() method on the contract instance. For example, securityToken.connect(minter).mint(...) will execute the mint function as if it were called by the minter account.
Here is how you would test the happy path:
it("should allow an account with MINTER_ROLE to mint tokens", async function () {
const mintAmount = ethers.utils.parseUnits("1000", 18);
// The 'minter' account (given the role in beforeEach) calls the mint function
await securityToken.connect(minter).mint(investor1.address, mintAmount);
// Assert that the investor's balance and total supply have updated
expect(await securityToken.balanceOf(investor1.address)).to.equal(mintAmount);
expect(await securityToken.totalSupply()).to.equal(mintAmount);
});
To test the sad path, we need to check that the function call fails with the correct error. The Chai assertion library, when extended with Hardhat's matchers, provides revertedWith for this exact purpose.
it("should prevent an account without MINTER_ROLE from minting", async function () {
const mintAmount = ethers.utils.parseUnits("1000", 18);
// The `user` account (with no roles) attempts to call mint.
// We expect this transaction to be reverted.
await expect(
securityToken.connect(user).mint(investor1.address, mintAmount)
).to.be.revertedWith(
`AccessControl: account ${user.address.toLowerCase()} is missing role ${MINTER_ROLE}`
);
});
This test confirms that our onlyRole(MINTER_ROLE) modifier on the mint function is working correctly.
The following resources provide excellent examples of testing access control and reverts, which you can use as a reference.
How to test Smart Contracts. 7th Episode — Utilizing Mocha and Chai
This article again provides clear, relevant examples.
Review the section "Using different ethereum accounts" to see the .connect() pattern in action. Examine the code under "Unit testing the KYC Contract". The tests for setAuthorizedOperator are very similar to our mint test, checking both a successful call by the owner and a reverted call by an unauthorized account.
Hardhat Testing Tutorial | Solidity Smart Contract Testing Developer | Hardhat Testing Course
This part of the video tutorial explicitly covers how to test for failed conditions.
Watch the section from testing for reverts. The instructor demonstrates how to use revertedWith to ensure that a contract deployment fails if a timestamp is not in the future. The principle is identical to how we test for access control failures.
Conclusion
In this lesson, you've taken the first and most critical step in validating our SecurityToken: testing its core administrative and issuance logic. You now understand how to structure a test suite with Mocha and Chai, create a clean testing environment using the beforeEach hook, and write targeted tests for both successful and failed outcomes.
Key Takeaways:
- Test files are structured using
describefor grouping,itfor individual test cases, andbeforeEachfor setup. - Simulating different user roles is done by getting multiple signers with
ethers.getSigners()and using.connect()to make calls from specific accounts. - "Happy path" tests verify expected state changes (e.g., balances, total supply) and event emissions.
- "Sad path" tests use
expect(...).to.be.revertedWith(...)to ensure that unauthorized actions fail with the correct error message, confirming your security model is effective.
You've now built a strong foundation for your test suite. In the next lesson, we will expand upon it by writing tests for the token's transfer logic, specifically verifying that our on-chain compliance rules—the allowlist—are correctly enforced.