Skip to main content
Create your own

Testing Event Emissions

Hello again! In our last two lessons, you built a solid foundation in smart contract testing. You learned how to verify the "happy path" where everything works as expected, and you learned how to test the "unhappy path" by asserting that your contract correctly reverts invalid actions. We've covered inputs and outcomes, but there's a crucial piece of the puzzle missing: the observable side effects.

This lesson focuses on testing events, which are Solidity's mechanism for logging important contract activities onto the blockchain. For someone with your background in data warehousing and ETL, you can think of events as the immutable, structured log that a contract writes. Just as an ETL pipeline's success is often confirmed by log entries or audit tables, a smart contract's actions are broadcast and recorded through events. Verifying these events is essential for ensuring your contract communicates correctly with the outside world and provides a reliable audit trail.

By the end of this lesson, you will be able to write tests that confirm your contract emits the correct events and that the data within those events is accurate.

A successful test run in a Hardhat project. Note the checkmarks next to "emits Transfer event" and "emits Approval event," confirming that the tests for event emissions passed.

1. The Role of Events in Smart Contracts

Before we dive into testing, let's briefly revisit why events are so important. In Module 2, you learned how to define and emit them. Their primary purpose is to serve as a communication and logging layer for your smart contract, enabling a few key functionalities:

  • Off-Chain Notification: User interfaces (like MetaMask), decentralized applications (dApps), and backend services don't continuously query the blockchain for state changes. Instead, they subscribe to and listen for specific events. When a token is transferred, the Transfer event is emitted, and the user's wallet updates their balance automatically.
  • Data Indexing: Services like The Graph (which we'll discuss in a later module) use events to index historical blockchain data, making it efficiently queryable. This is how you can build analytics dashboards or historical views of contract activity without re-processing the entire chain.
  • Verifiable Audit Trail: Because events are stored in the transaction logs on the blockchain, they form a permanent and tamper-proof record of significant actions. This is invaluable for auditing, compliance, and debugging.

Given their importance, you must test that your contract emits the right events with the right data. A missing or incorrect event can break dApp functionality or create an unreliable audit log.

2. Testing Event Emission with .emit

The hardhat-ethers-chai-matchers library provides a simple and powerful matcher for this purpose: .emit. The basic syntax is:

await expect(transactionPromise).to.emit(contractObject, "EventName");

Let's break this down:

  • expect(transactionPromise): As with testing reverts, you wrap the function call that returns a transaction promise inside expect.
  • .to.emit(...): This is the assertion that an event was emitted.
  • contractObject: You must specify the contract instance that is expected to emit the event.
  • "EventName": The name of the event as a string, which must match the name defined in your Solidity contract.

Let's see this in a video walkthrough. The instructor first defines a contract with an event and then writes a test to verify its emission.

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

This video segment from the "Hardhat Testing Tutorial" by Daulat Hussain shows the process of defining an event and then testing it.

First, observe how an event is defined in the contract. Watch from the event definition, where the Withdraw event is created with an amount and a when parameter. Next, see how this event is tested. Watch from the event test. The key line is await expect(myTest.withdraw()).to.emit(myTest, "Withdraw"). This test asserts that calling the withdraw function results in the MyTest contract emitting the Withdraw event.

3. Validating Event Arguments with .withArgs

Confirming that an event was emitted is a good first step, but it's often not enough. You also need to ensure the data within the event is correct. Was the Transfer event for the right amount? Was the OwnershipTransferred event to the correct new owner?

To do this, you chain the .withArgs() matcher to your .emit() assertion:

await expect(tx).to.emit(contract, "EventName").withArgs(arg1, arg2, ...);

The arguments you provide to .withArgs() are checked in order against the arguments emitted by the event.

The official Hardhat documentation provides the clearest explanation of this functionality.

hardhat-ethers-chai-matchers

This section of the official Hardhat documentation covers testing events and their arguments.

First, read the section on the .emit matcher. It shows the basic syntax and how to chain .withArgs. Next, focus on the .withArgs matcher itself. Notice that the arguments you provide are compared against the event's arguments. Also, pay close attention to the anyValue predicate. This is a very useful tool when you want to check some arguments but not all. For example, you might want to verify the recipient of a transfer but not a timestamp that is difficult to predict.

The documentation also notes that you can create your own predicate functions for more complex validation, which is an advanced but powerful feature.

Another useful resource provides further examples and clarifies some common patterns.

2.1.0 • npm-nomicfoundation--hardhat-chai-matchers • tessl • Registry

This documentation from Tessl provides additional, practical examples for event testing.

Review the argument validation examples. These show exact matching, partial matching with anyValue, and using anyUint for validating any unsigned integer. Read the short section on Addressable objects. This highlights a key convenience: you can pass signer objects (like owner or recipient from ethers.getSigners()) directly into .withArgs(), and the matcher will correctly compare their addresses. You don't need to manually type .address everywhere.

4. Your Turn: Add and Test an Event

Let's put this into practice with our Box.sol contract.

  1. Modify Box.sol. First, add an event definition to your contract. We want to log who changed the value and what the new value is. Then, emit this event inside the store function.

    // contracts/Box.sol
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.24;
    
    contract Box {
        uint256 private value;
    
        // Add this event definition
        event ValueChanged(address indexed changer, uint256 newValue);
    
        function store(uint256 newValue) public {
            require(newValue != 0, "Value cannot be zero");
            value = newValue;
            // Add this line to emit the event
            emit ValueChanged(msg.sender, newValue);
        }
    
        function retrieve() public view returns (uint256) {
            return value;
        }
    }
    

    The indexed keyword helps make the changer address searchable in logs, a topic relevant for data indexing later. After saving, re-compile your contract with npx hardhat compile.

  2. Add a new test to Box.test.ts. Now, let's write a test that verifies this event is emitted with the correct arguments. Add the following it block inside your main describe block.

    // test/Box.test.ts
    
    it("Should emit a ValueChanged event on successful store", async function () {
      // Arrange: Load the contract and get the owner account
      const { box, owner } = await loadFixture(deployBoxFixture);
      const testValue = 123;
    
      // Act & Assert: Expect the call to store() to emit the event with correct arguments
      await expect(box.store(testValue))
        .to.emit(box, "ValueChanged")
        .withArgs(owner.address, testValue);
    });
    

    This test uses the owner account that loadFixture provides and checks that msg.sender in the contract corresponds to the owner.address in the test, and that the newValue is correctly logged.

  3. Run your tests. Execute npx hardhat test in your terminal. You should see all your tests pass, including this new one for event emission.

Conclusion

You have now mastered the three fundamental pillars of smart contract unit testing: asserting correct state changes, verifying reverts on invalid calls, and confirming event emissions. By testing events, you ensure your contract's audit trail is accurate and that it can be reliably integrated into a larger ecosystem of dApps and services.

Here are the key takeaways from this lesson:

  • Events are a contract's primary mechanism for off-chain communication and creating an on-chain audit log.
  • Use expect(tx).to.emit(contract, "EventName") to assert that a specific event is fired.
  • Chain .withArgs(arg1, ...) to validate that the event was emitted with the correct data payload.
  • The matcher library automatically resolves signer objects to their addresses, simplifying tests.
  • Use helpers like anyValue when you need to perform partial argument validation.

In our previous lessons, we've implicitly used different accounts to test things like ownership. In the next lesson, we will formalize this by diving deeper into how to simulate transactions from multiple different accounts to rigorously test multi-user interactions and access control logic.

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

Sign up