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

Leveraging Test Fixtures for Efficient Testing

Hello! Welcome back to our course on building your own cryptocurrency.

In our last lesson, you mastered the crucial skill of testing for "unhappy paths" by asserting that your contract correctly reverts on invalid inputs. As you wrote more tests, you likely noticed a repeating pattern: nearly every test case starts with the same setup code to deploy the contract and get accounts.

As a developer, you know that repeating code is a signal that there's a better way. This adheres to the "Don't Repeat Yourself" (DRY) principle. Repetitive setup code not only clutters your tests but also significantly slows down your test suite, as deploying a contract is a transaction that takes time to execute.

Today, we'll solve this problem by implementing test fixtures. By the end of this lesson, you will be able to implement fixtures to reduce code duplication and improve the performance of your automated tests, making your development process more efficient and professional.


1. The Problem: Repetitive Test Setups

Let's look at the structure of our tests so far. Each it block probably looks something like this:

it("Should do something...", async function () {
  // --- Start of Repetitive Setup ---
  const [owner, addr1, addr2] = await ethers.getSigners();
  const MyToken = await ethers.getContractFactory("MyToken");
  const myToken = await MyToken.deploy();
  // --- End of Repetitive Setup ---

  // ... actual test logic ...
});

it("Should do something else...", async function () {
  // --- The same setup code is repeated ---
  const [owner, addr1, addr2] = await ethers.getSigners();
  const MyToken = await ethers.getContractFactory("MyToken");
  const myToken = await MyToken.deploy();
  // --- End of Repetitive Setup ---

  // ... another test's logic ...
});

This is inefficient. In traditional testing frameworks, you might solve this using a beforeEach hook, which runs a setup function before each test.

Testing Solidity Contracts: Prepare for Your Audit Like a Pro

To understand the traditional approach, watch this short clip from a video by Jesper Kristensen. It explains the purpose of the beforeEach hook in Mocha, the testing framework that Hardhat uses.

Watch from 01:41 to 04:14. Pay attention to how the beforeEach block is used to deploy a contract and get signers, making them available to all subsequent tests.

While beforeEach works, it has two main drawbacks in the context of blockchain testing:

  1. Performance: The beforeEach hook re-runs the setup—including the slow contract deployment transaction—before every single test.
  2. Code Style: It often requires you to declare variables in the outer describe scope and assign them within beforeEach, which can be less clean and potentially error-prone.

Hardhat provides a much better solution: fixtures.


2. The Solution: Hardhat Fixtures with loadFixture

A fixture is an asynchronous setup function that you write once. You then use Hardhat's loadFixture helper to run it in your tests.

Here's how it works:

  1. The first time loadFixture is called with your fixture function, it executes the setup (e.g., deploys your contract) and saves a "snapshot" of the blockchain's state.
  2. For all subsequent tests that call loadFixture with the same fixture, Hardhat doesn't re-run the setup. Instead, it instantly resets the blockchain to that saved snapshot.

This is incredibly fast. The concept is similar to reverting a virtual machine to a clean snapshot, a process enabled by the evm_revert RPC method used by the Hardhat Network.

EVM Revert State in Blockchain Testing
This diagram illustrates how a blockchain's state can be reverted to a previous snapshot. This is the core mechanism that makes Hardhat fixtures so efficient, avoiding the need to re-deploy contracts for every test.

Let's dive into the official Hardhat documentation to see the definition and benefits of fixtures.

Testing contracts

The Hardhat documentation explains why fixtures are the recommended approach for managing test setups.

Please read the section titled 'Reusing common test setups with fixtures'. It clearly explains what a fixture is and how loadFixture makes tests faster and cleaner.


3. Implementing and Using a Fixture

Now for the practical part. We will refactor our MyToken.test.ts to use a fixture. This involves three steps:

  1. Import loadFixture from @nomicfoundation/hardhat-network-helpers.
  2. Define an async function for our setup, which we'll call deployTokenFixture.
  3. Replace the repetitive setup code in each test with a call to await loadFixture(deployTokenFixture).

The video from our previous lesson actually used this pattern. We'll now formally analyze how it's constructed and why it's so effective.

Hardhat Testing Tutorial | Solidity Smart Contract Testing Developer | Hardhat Testing Course

This video by Daulat Hussain demonstrates exactly how to create and use a fixture. We'll focus on the part where the setup function is defined and then called by loadFixture.

Watch from 29:13 to 37:25. Observe how the instructor consolidates all the setup logic (getting accounts, deploying the contract) into a single async function named runEveryTime. Then, see how the it block uses loadFixture(runEveryTime) to get the contract instance and other data needed for the test. This runEveryTime function is our fixture.

Let's apply this to our MyToken.test.ts file.

Step 1: Define the Fixture Function

At the top of your describe("MyToken", ...) block, define your fixture. This function will contain all the setup logic and return an object with the contract instance and accounts.

// test/MyToken.test.ts

// ... other imports ...
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";

describe("MyToken", function () {
  // We define a fixture to reuse the same setup in every test.
  async function deployTokenFixture() {
    // Get signers
    const [owner, addr1, addr2] = await ethers.getSigners();

    // Deploy the contract
    const myToken = await ethers.deployContract("MyToken");
    await myToken.waitForDeployment();

    // Fixtures can return anything you consider useful for your tests
    return { myToken, owner, addr1, addr2 };
  }

  // ... your tests will go here ...
});

Step 2: Use the Fixture in Your Tests

Now, you can refactor your tests to use this fixture. Instead of several lines of setup, you'll have just one.

Before (Old way):

it("Should assign the total supply of tokens to the owner", async function () {
  const [owner] = await ethers.getSigners();
  const myToken = await ethers.deployContract("MyToken");

  const ownerBalance = await myToken.balanceOf(owner.address);
  expect(await myToken.totalSupply()).to.equal(ownerBalance);
});

After (With Fixture):

it("Should assign the total supply of tokens to the owner", async function () {
  // Load the fixture
  const { myToken, owner } = await loadFixture(deployTokenFixture);

  const ownerBalance = await myToken.balanceOf(owner.address);
  expect(await myToken.totalSupply()).to.equal(ownerBalance);
});

Notice how much cleaner the test becomes. All the setup complexity is abstracted away into the deployTokenFixture function.


Test your understanding!

Now it's your turn. Open your test/MyToken.test.ts file and refactor it completely to use the deployTokenFixture.

  1. Add the import for loadFixture.
  2. Define the deployTokenFixture function as shown above.
  3. Go through each it block and replace the manual deployment and getSigners calls with a single await loadFixture(deployTokenFixture) call.

Refactor the test where you check if a token transfer fails when the sender has an insufficient balance. How would it look with a fixture?

Show answer

Your refactored test should look similar to this. The setup is now a single, clean line.

// test/MyToken.test.ts

// ... inside the 'Transactions' describe block ...

it("Should fail if sender doesn't have enough tokens", async function () {
  // Load the fixture to get a fresh contract state and accounts
  const { myToken, owner, addr1 } = await loadFixture(deployTokenFixture);
  
  const initialOwnerBalance = await myToken.balanceOf(owner.address);

  // Try to send 1 token from addr1 (which has 0 tokens) to owner.
  // We use .connect(addr1) to send the transaction from that account.
  await expect(
    myToken.connect(addr1).transfer(owner.address, 1)
  ).to.be.revertedWith("ERC20: transfer amount exceeds balance");

  // Owner's balance should not have changed.
  expect(await myToken.balanceOf(owner.address)).to.equal(
    initialOwnerBalance
  );
});

When you run npx hardhat test, all your tests should still pass, but your code will be much more maintainable and your test suite will run faster.


Conclusion

Congratulations! You've taken a significant step toward writing professional-grade smart contract tests. By using fixtures, you are now able to write tests that are not only correct but also clean, efficient, and scalable.

Key Takeaways:

  • Fixtures are setup functions used to create a known state for your tests (e.g., deploying contracts).
  • The loadFixture helper from Hardhat is the recommended way to use fixtures.
  • loadFixture uses blockchain snapshotting, making it much faster than re-running setup logic with beforeEach for every test.
  • Using fixtures follows the DRY principle, reducing code duplication and making your tests easier to read and maintain.

Preview of the Next Lesson:
Our tests are now well-structured and efficient. In the next module, we'll shift our focus to security and access control. We'll start by exploring how to prevent common vulnerabilities like reentrancy attacks and integer overflows, which are critical for building a secure token that investors can trust.

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

Sign up