Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome to your fifth lesson in Module 2: "Modern Development & Testing Toolchain."
Introduction
In our last lesson, we configured our local development environment, setting up both Hardhat Network and Anvil, and explored the powerful technique of mainnet forking. With a robust testing environment now at our fingertips, we can shift our focus to writing the tests themselves.
Today, we'll start with the foundation of any good test suite: "happy path" testing. This involves writing unit tests that verify your contract's functions behave exactly as intended under normal, expected conditions. We will focus on the two primary outcomes of any function call: changes to the contract's state and the values it returns.
Learning Outcome: By the end of this lesson, you will be able to write unit tests for a contract's happy path, covering state changes and return values.
We will explore how to achieve this in both Foundry (writing tests in Solidity) and Hardhat (writing tests in TypeScript/JavaScript), allowing you to see the same core principles applied in different contexts.
1. The Anatomy of a Unit Test: Arrange, Act, Assert
Before we dive into code, it's helpful to understand a simple, universal pattern for structuring tests: Arrange, Act, Assert (AAA).
- Arrange: Set up the initial conditions for the test. This might involve deploying your contract, creating test accounts, or minting tokens to an address. The goal is to bring the world to the specific state required for the function you're about to test.
- Act: Execute the specific function you want to test. This is the core action of your test case.
- Assert: Verify that the outcome of the action is what you expected. This involves checking for correct state changes (e.g., did a balance update correctly?) and/or correct return values.
Keeping this AAA pattern in mind will help you write clear, logical, and easy-to-understand tests, regardless of the framework you use.
2. Happy Path Testing with Foundry
Foundry allows you to write your tests directly in Solidity, which can feel very intuitive as you are using the same language and mental model as your contract code.
Let's start with a very simple Counter contract and see how to test its increment function.
// src/Counter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Counter {
uint256 public count;
function inc() public {
count += 1;
}
function dec() public {
count -= 1;
}
}
Our goal is to write a test that:
- Deploys a
Countercontract (Arrange). - Calls the
inc()function (Act). - Checks that
countis now1(Assert).
The following video provides a concise walkthrough of this exact process.
How to Write Basic Tests | Testing with Foundry
This video from Smart Contract Programmer provides a clear, step-by-step guide to writing a basic test for our simple Counter contract.
Please watch from 00:31 to 03:00. This section covers: Test Setup (00:31 - 01:51): How to use the setUp() function to deploy a fresh contract instance before each test. Writing the Test (01:51 - 03:00): How to write a test function (prefixed with test) that calls the inc() function and then uses assertEqual to verify that the count state variable has been updated correctly.
Let's break down the key concepts from the video:
setUp()function: This special function is run before each test function. By deploying our contract here (counter = new Counter();), we ensure every test starts with a fresh, clean contract instance. This is the Arrange step.testInc()function: Test functions in Foundry must bepublicorexternaland their names must be prefixed withtest.counter.inc();: This is the Act step, where we call the function we are testing.assertEqual(counter.count(), 1);: This is the Assert step. Foundry's built-in test library provides assertion functions likeassertEqual(actual, expected). Here, we read the publiccountvariable and assert that its value is1, verifying the state change.
3. Testing Both State Changes and Return Values
Many functions don't just change state; they also return a value. A standard ERC-20 transfer function is a perfect example: it changes the token balances of two accounts (state change) and returns a bool indicating success (return value).
Let's look at how to test both of these outcomes in Foundry.
Writing ERC-20 Tests in Solidity with Foundry
Now let's look at a more practical example: testing an ERC-20 token transfer. This will allow us to test both a state change (the token balances) and a return value (the boolean success flag). This article from soliditydeveloper.com provides an excellent walkthrough.
Please read section '6. Token Transfer Tests', focusing on the code block for the WhenAliceHasSufficientFunds contract. Pay close attention to the itTransfersAmountCorrectly helper function. Notice how it uses: assertTrue(success) to check the return value of the transfer. assertEqDecimal(...) to verify the final balances of the sender and receiver.
In the itTransfersAmountCorrectly function from the article, we see a complete test of the happy path:
- Arrange: The
setUpfunction in the parent contracts creates users (alice,bob) and theWhenAliceHasSufficientFundscontract mints tokens toalice. - Act: The line
bool success = transferToken(from, to, amount);calls the transfer function. - Assert:
- Return Value:
assertTrue(success);checks that the function returnedtrue, as expected for a successful transfer. - State Change: The two
assertEqDecimalcalls verify that the balances offromandtohave been updated correctly.
- Return Value:
This example clearly demonstrates how to verify both primary outcomes of a function call within a single test.
4. Happy Path Testing with Hardhat
Now, let's see how the same principles apply in a Hardhat environment. Given your extensive background in front-end development, this JavaScript/TypeScript-based approach using ethers.js and the Chai assertion library will likely feel familiar.
We'll use the official Hardhat tutorial, which tests a simple ERC-20 token contract.
5. Testing contracts | Ethereum development environment ...
The concepts of Arrange, Act, and Assert are identical in Hardhat, but the implementation uses JavaScript/TypeScript.
Please read the sections 'Writing tests' and 'Using a different account'. The first section shows how to test the initial state after deployment, verifying the owner's balance. This is a classic 'happy path' setup test. The second section demonstrates a token transfer test. Focus on how it calls hardhatToken.transfer(...) and then uses expect(...).to.equal(...) to verify the recipient's new balance.
Let's map the token transfer test from the Hardhat tutorial to our AAA pattern:
-
Arrange:
const [owner, addr1, addr2] = await ethers.getSigners(); const hardhatToken = await ethers.deployContract("Token");Here, we get our test accounts and deploy the contract. The deployment itself assigns the total supply to the
owner, setting up our initial state. -
Act:
await hardhatToken.transfer(addr1.address, 50);We call the
transferfunction to send 50 tokens from theownertoaddr1. -
Assert:
expect(await hardhatToken.balanceOf(addr1.address)).to.equal(50);We use Chai's
expectsyntax to assert that the balance ofaddr1is now 50. This is the Hardhat/Chai equivalent of Foundry'sassertEqualand effectively verifies the state change.
The Hardhat documentation also shows a more advanced and expressive way to assert token balance changes, which you may find useful:
await expect(
hardhatToken.transfer(addr1.address, 50)
).to.changeTokenBalances(hardhatToken, [owner, addr1], [-50, 50]);
This single line both performs the action and asserts the state change for multiple accounts simultaneously.
Conclusion
Today, you've taken the first and most important step in testing smart contracts: verifying the "happy path." You've learned how to write tests that confirm your functions work as expected under normal conditions, which is the bedrock of a secure and reliable contract.
Key Takeaways:
- The Arrange-Act-Assert pattern provides a clear structure for writing any unit test.
- Happy path tests are essential for verifying the core logic of your contract.
- In Foundry, you write tests in Solidity, using a
setUpfunction for arrangement and assertion functions likeassertEqualandassertTrue. - In Hardhat, you write tests in JavaScript/TypeScript, using
ethers.jsto interact with contracts and an assertion library like Chai (expect) to verify outcomes. - A complete happy path test should cover both state changes (e.g., updated balances, counters) and return values.
Next Steps:
While ensuring your contract works when things go right is crucial, it's equally important to ensure it behaves correctly when things go wrong. In our next lesson, we will explore the "unhappy path" by learning how to write tests for failure conditions, including expected reverts and custom errors.