Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our course on building your own cryptocurrency.
In our last lesson, you streamlined your test suite by implementing fixtures with loadFixture. This powerful technique allows you to write cleaner, faster, and more maintainable tests by avoiding repetitive setup code. Now that our testing foundation is solid, we can focus on verifying one of the most critical aspects of any smart contract: security and access control.
Many smart contract functions should only be callable by specific accounts. For example, only the contract owner should be able to mint new tokens or pause the contract in an emergency. Today's lesson is dedicated to verifying these restrictions. By the end of this session, you will be able to test functions that are restricted by access control modifiers, ensuring that only authorized users can perform sensitive actions.
1. Why Test Access Control?
Imagine if the mint function of your ERC-20 token could be called by anyone. A malicious actor could create an infinite number of tokens for themselves, instantly making your token worthless and destroying the trust of your investors.
Access control modifiers, like the common onlyOwner pattern, are designed to prevent this. They act as a gatekeeper, checking that the msg.sender (the caller of the function) is authorized. As a developer, your job isn't just to implement these checks, but to prove they work with automated tests.
A typical access-controlled function in an OpenZeppelin-based contract looks like this:
// This is a function that only the designated owner can call.
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
Our goal is to write a test that confirms an unauthorized user—a "non-owner"—is blocked from calling this function.
2. The Core Technique: Simulating Calls with .connect()
So, how do we "log in" as a different user in our tests? By default, all function calls you make on a contract instance in Hardhat are sent from the first signer returned by ethers.getSigners(), which we typically call owner.
To simulate a transaction from a different account, you use the .connect() method. This method, available on any ethers.js contract object, returns a new instance of the contract connected to a different signer. When you call a function on this new instance, the msg.sender will be the address of that new signer.
Let's see how the official Hardhat documentation explains this.
The Hardhat documentation provides the definitive guide on how to test functions from the perspective of different users. This section is key to understanding today's lesson.
Please read the section titled 'Using a different address'. Pay close attention to how ethers.getSigners() is used to get multiple accounts and how lock.connect(otherAccount) is used to simulate a call from a non-owner.
As you just read, the pattern is straightforward:
- Get multiple accounts:
const [owner, otherAccount] = await ethers.getSigners(); - Connect the contract to the other account:
myToken.connect(otherAccount) - Call the restricted function:
myToken.connect(otherAccount).mint(...) - Assert that the call reverts:
await expect(...).to.be.revertedWith(...)
3. Writing the Access Control Test
Let's put this into practice. We will write a test for the mint function of our MyToken contract, which we assume is protected by an onlyOwner modifier from OpenZeppelin.
First, let's reinforce the pattern by looking at another high-quality resource.
Hardhat Guide: Automated Smart Contract Testing
This article from Krayon Digital provides a concise example of testing access control, reinforcing the pattern we just learned from the Hardhat docs.
Read section '4.2. Testing different accounts and permissions' and the first example in section '6.2. Testing function modifiers and access control'. Both show the exact expect(contract.connect(otherAccount).function()).to.be.revertedWith(...) pattern.
Now, let's add the test to our test/MyToken.test.ts file. We'll use the deployTokenFixture you created in the last lesson.
// test/MyToken.test.ts
// ... inside your main describe("MyToken", function () { ... }) block
describe("Access Control", function () {
it("Should prevent non-owners from minting tokens", async function () {
// 1. Setup: Load the fixture to get the contract and accounts
const { myToken, addr1 } = await loadFixture(deployTokenFixture);
// 2. Action & Assertion: Try to mint from addr1's account
// We expect this transaction to be reverted with a specific error message.
await expect(
myToken.connect(addr1).mint(addr1.address, 100)
).to.be.revertedWith("Ownable: caller is not the owner");
});
it("Should allow the owner to mint tokens", async function () {
// It's also good practice to test the "happy path"
const { myToken, owner, addr1 } = await loadFixture(deployTokenFixture);
// The owner CAN mint tokens. We don't need .connect() because
// 'myToken' is already connected to the owner by default.
await myToken.mint(addr1.address, 100);
// Verify the result
const addr1Balance = await myToken.balanceOf(addr1.address);
expect(addr1Balance).to.equal(100);
});
});
The error message "Ownable: caller is not the owner" is the default reason string provided by OpenZeppelin's Ownable contract. By testing for this specific message, you are confirming that the modifier is not only present but is also the reason the transaction is failing.
4. Live Coding Example
Reading about it is one thing, but seeing it in action solidifies the concept. The following video demonstrates testing access control for a withdraw function in a time-locked wallet contract. The principle is exactly the same.
Hardhat Testing Tutorial | Solidity Smart Contract Testing Developer | Hardhat Testing Course
This video by Daulat Hussain shows a practical example of testing access control. Watch how he uses .connect() to simulate a call from a non-owner and asserts that it reverts.
Watch from 51:32 to 54:38. Notice the key lines where he gets the otherAccount, connects it to the contract instance, calls the withdraw function, and asserts that it is revertedWith the correct error message. This is a perfect demonstration of the pattern we've been discussing.
Test your understanding!
Assume your MyToken contract also has a burn function that allows the owner to destroy their own tokens. To prevent users from accidentally burning tokens they don't own, the standard ERC-20 burn function can only be called by the token holder themselves.
Let's say you have a custom, admin-only burn function:
function burnFrom(address account, uint256 amount) public onlyOwner {
_burn(account, amount);
}
This function allows the contract owner to burn tokens from any account.
Write a Hardhat test to ensure that only the contract owner can call burnFrom. Specifically, test that a call from addr1 trying to burn tokens from owner's account is correctly reverted.
Show answer
Your test should look something like this, placed within your Access Control describe block:
it("Should prevent non-owners from calling burnFrom", async function () {
// Setup: Load fixture and get accounts
const { myToken, owner, addr1 } = await loadFixture(deployTokenFixture);
// Pre-mint some tokens to the owner to have something to burn
await myToken.mint(owner.address, 1000);
// Action & Assertion: Try to call burnFrom from addr1's account.
// We expect this to fail because addr1 is not the owner.
await expect(
myToken.connect(addr1).burnFrom(owner.address, 100)
).to.be.revertedWith("Ownable: caller is not the owner");
});
This test effectively proves that your onlyOwner modifier on the burnFrom function is working as intended.
Conclusion
Excellent work! You have now learned how to write automated tests that verify the access control mechanisms in your smart contracts. This is a fundamental skill for building secure, reliable, and trustworthy dApps.
Key Takeaways:
- Access control is critical for protecting sensitive functions like
mint,pause, or administrative actions. - Use the
.connect(signer)method on anethers.jscontract instance to simulate transactions from different accounts. - Combine
.connect()withawait expect(...).to.be.revertedWith("Error message")to create powerful and precise tests for your access control modifiers. - Always test both the "unhappy path" (a non-owner is blocked) and the "happy path" (the owner can successfully call the function).
Preview of the Next Lesson:
With functional and security testing under our belts, we'll turn our attention to another important metric: efficiency. In the next lesson, you will learn how to measure and analyze the gas consumption of your smart contract functions, an essential step in optimizing your contract for cost-effectiveness on the mainnet.