Create your own
Lesson illustration

Testing Event Emissions

Hello! Welcome to your seventh lesson in the "Modern Development & Testing Toolchain" module.

Introduction

In our last two lessons, we built a solid foundation in contract testing. We started with the "happy path," ensuring functions produce the correct state changes and return values. Then, we moved to the "unhappy path," verifying that our contracts fail securely and predictably using reverts and custom errors.

So far, we've tested the internal consequences of function calls. But smart contracts don't exist in a vacuum; they need to communicate with the outside world. This is where events come in. They are the primary mechanism for a contract to signal that something has happened to off-chain applications, such as front-ends, indexers, or notification services.

Today, we'll complete our tour of fundamental testing types by focusing on this crucial communication layer.

Learning Outcome: By the end of this lesson, you will be able to write tests to verify correct event emission and parameter values.

We will cover:

  • The importance of events for off-chain applications.
  • How to test event emissions and their arguments in Foundry using vm.expectEmit.
  • How to test events in Hardhat using the emit and withArgs Chai matchers.

1. Why Test Events?

Events are the blockchain's equivalent of logging. They are a specialized data structure stored in a transaction's receipt that is much cheaper than contract storage. Your experience as a front-end developer gives you a direct appreciation for their importance:

  • Informing UIs: Instead of constantly re-fetching contract state, a front-end can subscribe to events to know when data has changed. For example, a UI for a decentralized exchange would listen for a Swap event to update the price chart and user balances in real-time.
  • Data Indexing: Services like The Graph listen for events to build and maintain indexed, queryable datasets (subgraphs) from blockchain data. This is how most dApps efficiently display historical information like a user's transaction history.
  • Off-chain Logic: Events can trigger off-chain processes, like sending an email notification, updating a database, or executing a subsequent transaction via a bot.

If your application's front-end or back-end services rely on an event, your test suite must guarantee that the event is emitted correctly, with the right parameters, every single time. Failing to do so can lead to silent bugs where the contract works but the user-facing application does not.

Let's modify our Counter contract to include an event.

// src/Counter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Counter {
    uint256 public count;

    // Event definition
    event NumberChanged(address indexed user, uint256 newCount);

    function inc() public {
        count += 1;
        emit NumberChanged(msg.sender, count);
    }

    function dec() public {
        count -= 1;
        emit NumberChanged(msg.sender, count);
    }
}

2. Testing Events in Foundry

Foundry uses the vm.expectEmit cheat code to test events. The syntax is a bit more involved than what we've seen before but is very powerful. It requires a four-step process:

  1. Declare your expectation: Call vm.expectEmit(...) with boolean flags indicating which parts of the event you want to check.
  2. Emit the expected event: In your test, emit the event with the exact parameters you expect the contract to produce.
  3. Call the function: Execute the contract function that is supposed to emit the event.
  4. Foundry compares: Foundry automatically compares the event emitted by the contract call against the one you emitted in the test.

The video below explains this process clearly.

Solidity Development with Foundry: Cast, Anvil, Chisel, and Forge

This segment from the Ethereum Engineering Group's video on Foundry provides a detailed walkthrough of using expectEmit.

Please watch from 00:29:03 to 00:32:09. Focus on: The multi-step process for setting up an event test. The meaning of the boolean flags in expectEmit, which correspond to the event's topics (indexed parameters) and data (non-indexed parameters). The syntax of emitting the expected event within the test itself before calling the target function.

As the video explains, the expectEmit cheat code takes four boolean arguments: vm.expectEmit(checkTopic1, checkTopic2, checkTopic3, checkData).

  • checkTopic1, checkTopic2, checkTopic3 correspond to the first three indexed parameters of an event.
  • checkData corresponds to all non-indexed parameters.

In our NumberChanged event, user is the first (and only) indexed parameter, and newCount is the non-indexed data. So, to check both, we would use vm.expectEmit(true, false, false, true).

Here is a complete test for our Counter contract's inc function:

// test/Counter.t.sol
import "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";

contract CounterTest is Test {
    Counter public counter;
    address user = makeAddr("user");

    function setUp() public {
        counter = new Counter();
    }

    function test_IncrementEmitsEvent() public {
        // Arrange
        vm.prank(user); // Set the caller for the next transaction

        // 1. Declare expectation: check the indexed 'user' and the 'newCount' data.
        vm.expectEmit(true, false, false, true);

        // 2. Emit the event we expect to see.
        emit Counter.NumberChanged(user, 1);

        // Act: 3. Call the function.
        counter.inc();

        // Assert: 4. Foundry implicitly handles the assertion.
    }
}

This test ensures that calling inc() not only changes the state but also correctly reports this change to the outside world.

3. Testing Events in Hardhat

Testing events in Hardhat will feel very familiar, as it uses a fluent expect syntax provided by the hardhat-chai-matchers library.

The core pattern is expect(transaction).to.emit(contract, "EventName").withArgs(...).

  1. Call the contract function and store the returned transaction promise.
  2. Wrap the transaction promise in expect().
  3. Chain .to.emit(contract, "EventName") to assert that the event was fired.
  4. Chain .withArgs(arg1, arg2, ...) to assert that the event was fired with specific parameter values.

The following guide provides a clear, practical example of this pattern.

The Complete Hardhat Testing Guide for Secure Smart ...

This section from 'The Complete Hardhat Testing Guide' demonstrates the standard way to test for event emissions and their arguments.

Please read section 'IV. The event AuctionCreated is fired'. Notice how the test first triggers the transaction and then uses expect(tx).to.emit(...).withArgs(...) to verify both the event name and all of its parameters.

Applying this to our Counter contract, the test looks like this:

// test/Counter.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
import { Counter } from "../typechain-types"; // Assuming TypeChain is set up

describe("Counter", function () {
  async function deployCounterFixture() {
    const [owner, otherAccount] = await ethers.getSigners();
    const CounterFactory = await ethers.getContractFactory("Counter");
    const counter = await CounterFactory.deploy();
    return { counter, owner, otherAccount };
  }

  // ... other tests ...

  describe("Events", function () {
    it("Should emit a NumberChanged event on inc()", async function () {
      const { counter, owner } = await loadFixture(deployCounterFixture);

      await expect(counter.inc())
        .to.emit(counter, "NumberChanged")
        .withArgs(owner.address, 1);
    });

    it("Should emit a NumberChanged event on dec()", async function () {
      const { counter, owner } = await loadFixture(deployCounterFixture);
      await counter.inc(); // Set count to 1 first

      await expect(counter.dec())
        .to.emit(counter, "NumberChanged")
        .withArgs(owner.address, 0);
    });
  });
});

3.1. Advanced: Matching Partial Arguments

Sometimes you don't want to or can't check every argument. A common example is a timestamp, which can be tricky to predict exactly. The hardhat-chai-matchers library and ethers provide several ways to handle this.

This Stack Exchange thread discusses a few modern solutions to this exact problem.

Testing arguments of contract events with hardhat + chai

This Stack Exchange thread offers excellent solutions for when you only want to test specific arguments of an event.

Please read the top three answers (sorted by votes). They cover: anyValue (Answer 3): Using a special matcher to ignore a specific argument. withNamedArgs (Answer 4): A very readable way to check arguments by their name in the event definition. Manual Filtering (Answer 5): A more manual but powerful method of getting the event from the transaction receipt and asserting on it directly.

Let's summarize the best approaches:

  • Using anyValue: If you want to ignore an argument, you can import anyValue from @nomicfoundation/hardhat-chai-matchers/withArgs.

    import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs";
    
    // Example: We only care about the new count, not the user.
    await expect(counter.inc())
      .to.emit(counter, "NumberChanged")
      .withArgs(anyValue, 1);
    
  • Using withNamedArgs: This is often the most readable approach, as you specify the arguments to check by their Solidity name.

    // Example: We only care about the user.
    await expect(counter.inc())
      .to.emit(counter, "NumberChanged")
      .withNamedArgs({ user: owner.address });
    

These techniques make your tests more flexible and resilient to changes in parts of the event you aren't focused on.

Conclusion

You have now learned how to test the third and final pillar of a contract's public interface: events. By verifying event emissions, you ensure that your smart contracts can communicate reliably with the off-chain world, a requirement for any user-facing decentralized application.

Key Takeaways:

  • Events are the logging and communication layer of the EVM, critical for front-ends and off-chain services.
  • Testing events ensures the integrity of the entire application stack, not just the on-chain logic.
  • In Foundry, you use the vm.expectEmit cheat code in a four-step process: expectEmit, emit, call, and implicit assertion.
  • In Hardhat, you use the fluent expect(tx).to.emit(contract, "EventName").withArgs(...) chain from hardhat-chai-matchers.
  • For complex cases in Hardhat, you can use anyValue or withNamedArgs to test a subset of an event's parameters.

Next Steps:

We have now covered the fundamental types of unit tests: checking state, reverts, and events. While comprehensive, writing these tests manually for every possible scenario can be tedious. What if we could automate the discovery of weird edge cases? In our next lesson, we will explore fuzz testing with Foundry, a powerful technique that automatically bombards your functions with random inputs to uncover hidden bugs.

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

Sign up