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
- Deposit and withdraw ERC20 tokens (USDT, SHIB, MATIC) from a Solidity contract (Hardhat, Ethers.js)www.youtube.com
- Testing Reverts and Custom Errors - Grasppaths.grasp.study
- Testing contracts | Ethereum development environment for ...hardhat.org
Hello! Welcome back to your course on building a cryptocurrency application.
In our last lesson, you successfully wrote tests for the approve and transferFrom functions, which are critical for your token's interoperability. We ended with a small challenge: you wrote a test that failed because your contract correctly rejected an attempt to transfer more tokens than allowed. This is a good thing! It means your contract's security rules are working.
Today, we will learn how to turn those expected failures into passing tests. This practice, often called "unhappy path" testing, is just as important as testing for correct functionality. For a backend developer, this is analogous to writing tests to ensure your API correctly returns a 403 Forbidden or 400 Bad Request status code when it should. A robust smart contract must be predictable not only in success but also in failure.
By the end of this lesson, you will be able to write tests that verify your contracts correctly revert on invalid inputs or when specific conditions are not met, ensuring your application is both functional and secure.
1. The Art of Failing Correctly: Testing Reverts
In Solidity, when a rule is broken, the transaction is "reverted." This stops execution, undoes all state changes made so far in the call, and returns the remaining gas to the caller. These rules are typically enforced using require() statements.
For example, the OpenZeppelin ERC20.sol contract has this line in its _spendAllowance function:require(currentAllowance >= amount, "ERC20: insufficient allowance");
If the condition isn't met, the transaction reverts and provides that helpful error message. Our job as testers is to create the invalid condition and then assert that the contract reverts with the exact expected message.
To do this, we'll use a special "matcher" function from the hardhat-chai-matchers library, which was installed with your Hardhat project.
2. The revertedWith Matcher
The key to testing reverts is the .to.be.revertedWith() matcher. Let's look at the documentation to understand the pattern.
Testing contracts | Hardhat development environment for ...
The official Hardhat documentation provides the clearest explanation of how to test for reverts. We will focus on the section about testing a function that reverts.
Please read the section titled 'Testing a function that reverts'. Pay close attention to the syntax await expect(contract.function()).to.be.revertedWith("Error message") and the explanation of why await is placed at the beginning.
As the documentation highlights, the syntax is:await expect(contractCall).to.be.revertedWith("Error message");
The most common mistake is misplacing await. You are not awaiting the result of contractCall (which would throw an error and fail the test). Instead, you are awaiting the resolution of the entire expect assertion.

3. Practical Examples: Testing a Time-Lock Contract
To see this in action, we'll step away from our MyToken contract for a moment and analyze a simple Lock contract. This contract will lock some Ether until a specific time has passed and only allow the owner to withdraw it.
A great way to see this broken down is through a guided video tutorial. We'll watch a few key segments that demonstrate testing for different kinds of reverts.
Hardhat Testing Tutorial | Solidity Smart Contract Testing Developer | Hardhat Testing Course
The video 'Hardhat Testing Tutorial' by Daulat Hussain provides excellent, clear examples of testing various revert conditions. We will focus on the parts where the instructor tests invalid constructor arguments and access control.
First, watch the section where the instructor writes the 'MyTest' smart contract. This will give you the context for the tests we are about to analyze. Don't worry about coding along just yet; just focus on understanding the contract's logic, especially the require statements in the constructor and the withdraw function.
The contract has three main rules we can test:
- Constructor: The
unlockTimemust be in the future. - Withdrawal Time: The
withdrawfunction can only be called afterunlockTime. - Withdrawal Access: Only the
ownercan call thewithdrawfunction.
Let's see how to test these "unhappy paths."
3.1. Testing an Invalid Constructor Argument
The first test will ensure the contract deployment itself fails if we provide an invalid unlockTime (i.e., a time in the past).
Hardhat Testing Tutorial | Solidity Smart Contract Testing Developer | Hardhat Testing Course
Now, let's watch how to test the constructor's require statement. This demonstrates how to check for a revert during contract deployment.
Watch this segment carefully. Notice how the instructor gets the latest block timestamp and then tries to deploy the contract using that time, which is not in the future. The key part is the await expect(...deploy...).to.be.revertedWith(...) line.
The core of the test looks like this:
// Get the latest block time
const latestTime = await time.latest();
// Deploying the contract with a time that is NOT in the future should fail
await expect(
MyTest.deploy(latestTime, { value: 1 })
).to.be.revertedWith("Your unlock time should be in future");
Here, we simulate providing a bad input to the constructor and assert that the deployment reverts with the precise error message from the contract's require statement.
3.2. Testing Time-Based and Access Control Rules
Next, let's test the withdraw function's rules. We need to check two failure conditions:
- What happens if someone tries to withdraw too early?
- What happens if someone other than the owner tries to withdraw?
Hardhat Testing Tutorial | Solidity Smart Contract Testing Developer | Hardhat Testing Course
This final video segment covers tests for both of the withdraw function's rules. It's a perfect demonstration of testing access control and other business logic conditions.
Please watch this section. The first test checks for the 'called too soon' error. The second, more complex test uses contract.connect(otherAccount)—which you've used before—to simulate a call from a non-owner and assert that it reverts.
The tests for these conditions are:
Test 1: Withdrawing too soon.
it("Should revert if called too soon", async function () {
const { myTest } = await loadFixture(runEveryTime);
// Calling withdraw() immediately after deployment should fail
await expect(myTest.withdraw()).to.be.revertedWith(
"Wait till the time period is completed"
);
});
This is straightforward. We deploy the contract and immediately try to withdraw, expecting it to fail because the unlockTime has not been reached.
Test 2: Withdrawing from another account.
it("Should revert if called from another account", async function () {
const { myTest, otherAccount } = await loadFixture(runEveryTime);
// We use .connect() to send the transaction from another account
await expect(
myTest.connect(otherAccount).withdraw()
).to.be.revertedWith("You are not an owner");
});
This pattern should be familiar. We use .connect() to have otherAccount call the withdraw function, and we assert that the transaction reverts with the ownership error message.
Test your understanding!
Now it's your turn to apply what you've learned. In the last lesson, you wrote a test for a spender who tries to transferFrom an amount greater than their allowance. The test failed with an ERC20: insufficient allowance error.
Go back to your test/MyToken.test.ts file and modify that test. Use the await expect(...).to.be.revertedWith(...) pattern to make it a passing test that correctly asserts the expected failure.
Show answer
Your test should now look something like this. Notice how we wrap the transferFrom call inside await expect() and check for the specific error string from the OpenZeppelin contract.
// test/MyToken.test.ts
describe("Allowances and Delegated Transfers", function () {
// ... your other tests for approve and transferFrom ...
it("Should fail if spender tries to transfer more than their allowance", async function () {
const { myToken, owner, addr1, addr2 } = await loadFixture(deployTokenFixture);
const amountToApprove = 100;
// Owner approves addr1 for 100 tokens
await myToken.approve(await addr1.getAddress(), amountToApprove);
// addr1 tries to transfer 101 tokens from owner to addr2
// We expect this transaction to revert with a specific message
await expect(
myToken.connect(addr1).transferFrom(
await owner.getAddress(),
await addr2.getAddress(),
amountToApprove + 1
)
).to.be.revertedWith("ERC20: insufficient allowance");
});
});
When you run npx hardhat test now, this test should pass, proving that your contract is secure against this invalid action and your test suite correctly verifies it.
Conclusion
Excellent work! You have now mastered one of the most critical skills in smart contract testing: verifying failure conditions. By writing tests that check for reverts, you ensure your contract is robust, secure, and predictable, preventing invalid state transitions and protecting user funds.
Key Takeaways:
- "Unhappy path" testing is crucial for building secure smart contracts. It verifies that your contract fails correctly when rules are broken.
- The primary tool for this in Hardhat is the
await expect(...).to.be.revertedWith("Error message")pattern. - It's essential to place
awaitbeforeexpectto await the resolution of the assertion, not the contract call itself. - You can test for reverts caused by invalid constructor arguments, access control violations (e.g., non-owners), and unmet business logic conditions (e.g., time-locks).
Preview of the Next Lesson:
As your test files grow, you may notice you're writing the same setup code (like deploying your contract) in every it block. This is repetitive and inefficient. In our next lesson, we will learn how to use test fixtures to refactor this setup code, making your tests cleaner, faster, and easier to maintain.