Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Unit Testing: Happy Path, State Changes, and Return Values

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).

  1. 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.
  2. Act: Execute the specific function you want to test. This is the core action of your test case.
  3. 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:

  1. Deploys a Counter contract (Arrange).
  2. Calls the inc() function (Act).
  3. Checks that count is now 1 (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 be public or external and their names must be prefixed with test.
  • 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 like assertEqual(actual, expected). Here, we read the public count variable and assert that its value is 1, 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 setUp function in the parent contracts creates users (alice, bob) and the WhenAliceHasSufficientFunds contract mints tokens to alice.
  • Act: The line bool success = transferToken(from, to, amount); calls the transfer function.
  • Assert:
    • Return Value: assertTrue(success); checks that the function returned true, as expected for a successful transfer.
    • State Change: The two assertEqDecimal calls verify that the balances of from and to have been updated correctly.

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 transfer function to send 50 tokens from the owner to addr1.

  • Assert:

    expect(await hardhatToken.balanceOf(addr1.address)).to.equal(50);
    

    We use Chai's expect syntax to assert that the balance of addr1 is now 50. This is the Hardhat/Chai equivalent of Foundry's assertEqual and 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 setUp function for arrangement and assertion functions like assertEqual and assertTrue.
  • In Hardhat, you write tests in JavaScript/TypeScript, using ethers.js to 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.

Can't find a good explanation? Sign up and we'll make it for you

Sign up