Testing Solidity Events: Patterns for Complete Event Coverage
Events in Solidity are the communication layer between your smart contracts and the world outside the blockchain. Subgraphs index them. Front-ends listen for them. Off-chain services trigger workflows based on them. When an event is missing, emitted with wrong arguments, or emitted in the wrong order, the entire off-chain system breaks — silently, in ways that are incredibly hard to debug.
Most teams test events as an afterthought, if at all. "The function works, so the event probably fired correctly." This is wrong. Events have their own specification, and they need their own tests. This guide covers every pattern you need for complete event coverage.
Why Events Matter More Than You Think
Consider a typical DeFi interaction:
- User deposits to a yield vault
- Contract emits
Deposited(user, amount, shares) - The Graph indexes this event and updates the user's portfolio balance
- Front-end displays the updated balance
If the shares argument in step 2 is calculated incorrectly, the user sees a wrong portfolio balance even though their actual on-chain position is correct. The contract is "working" but the user experience is broken. The bug is in the event, not in the state change.
Events also serve as a permanent audit trail. Governance events, ownership transfers, and admin actions are often only tracked via events — not stored in contract state. An event that fires at the wrong time or with wrong arguments corrupts that audit trail permanently.
Event Anatomy: Indexed vs Non-Indexed Parameters
Understanding indexed vs non-indexed parameters is essential before writing tests:
// This event has 2 indexed topics and 1 non-indexed data field
event Transfer(
address indexed from, // topic[1] — filterable
address indexed to, // topic[2] — filterable
uint256 value // data — not filterable, but cheaper
);
// Up to 3 parameters can be indexed (plus topic[0] which is the event signature)
event ComplexEvent(
address indexed actor, // topic[1]
bytes32 indexed category, // topic[2]
uint256 indexed id, // topic[3]
uint256 amount, // data
string metadata // data
);Indexed parameters are stored as topics and can be filtered by nodes efficiently. Non-indexed parameters are ABI-encoded in the data field — cheaper to store but can't be filtered.
Testing implications: when you verify an event, you need to know which parameters are indexed because this affects how you assert them in both Hardhat and Foundry.
Testing Events in Hardhat
Basic Event Assertion
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Token Events", function () {
it("Should emit Transfer on successful transfer", async function () {
const [owner, addr1] = await ethers.getSigners();
const Token = await ethers.getContractFactory("MyToken");
const token = await Token.deploy("Test", "TST", ethers.parseEther("1000"), owner.address);
await expect(token.transfer(addr1.address, ethers.parseEther("100")))
.to.emit(token, "Transfer")
.withArgs(owner.address, addr1.address, ethers.parseEther("100"));
});
it("Should emit Transfer from zero address on mint", async function () {
const [owner, addr1] = await ethers.getSigners();
const Token = await ethers.getContractFactory("MyToken");
const token = await Token.deploy("Test", "TST", 0n, owner.address);
await expect(token.mint(addr1.address, ethers.parseEther("500")))
.to.emit(token, "Transfer")
.withArgs(ethers.ZeroAddress, addr1.address, ethers.parseEther("500"));
});
});Testing Events NOT Emitted
This is the most commonly skipped test. If your contract gates behavior on a condition, you need to verify that the event fires on the positive branch AND does not fire on the negative branch:
it("Should NOT emit LargeTransfer for small amounts", async function () {
await expect(token.transfer(addr1.address, ethers.parseEther("50")))
.to.not.emit(token, "LargeTransfer");
});
it("Should emit LargeTransfer for amounts above threshold", async function () {
const threshold = ethers.parseEther("10000");
await expect(token.transfer(addr1.address, threshold))
.to.emit(token, "LargeTransfer");
});
it("Should emit LargeTransfer at exactly the threshold", async function () {
const threshold = ethers.parseEther("10000");
await expect(token.transfer(addr1.address, threshold))
.to.emit(token, "LargeTransfer")
.withArgs(owner.address, addr1.address, threshold);
});Testing the boundary (exactly at threshold) is critical — off-by-one errors in >= vs > conditions are common.
Testing Event Arguments with Partial Matching
Sometimes you don't want to assert exact values for every argument. Hardhat's withArgs supports anyValue and custom matchers:
const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs");
it("Should emit event with correct sender regardless of amount", async function () {
await expect(token.transfer(addr1.address, ethers.parseEther("100")))
.to.emit(token, "Transfer")
.withArgs(owner.address, addr1.address, anyValue);
});
// Custom matcher for range checking
it("Should emit event with amount in valid range", async function () {
await expect(token.transfer(addr1.address, ethers.parseEther("100")))
.to.emit(token, "Transfer")
.withArgs(
owner.address,
addr1.address,
(amount) => amount > 0n && amount <= ethers.parseEther("1000000")
);
});Testing Event Ordering in Multi-Step Transactions
When a function triggers multiple events, their order matters:
it("Should emit events in correct order during batch transfer", async function () {
// Capture all events from a transaction
const tx = await token.batchTransfer(
[addr1.address, addr2.address],
[ethers.parseEther("100"), ethers.parseEther("200")]
);
const receipt = await tx.wait();
// Filter Transfer events
const transferEvents = receipt.logs
.filter(log => {
try {
return token.interface.parseLog(log).name === "Transfer";
} catch {
return false;
}
})
.map(log => token.interface.parseLog(log));
expect(transferEvents).to.have.length(2);
expect(transferEvents[0].args.to).to.equal(addr1.address);
expect(transferEvents[0].args.value).to.equal(ethers.parseEther("100"));
expect(transferEvents[1].args.to).to.equal(addr2.address);
expect(transferEvents[1].args.value).to.equal(ethers.parseEther("200"));
});Testing Multiple Events in One Transaction
A single function call can emit multiple different events:
it("Should emit both Approval and Transfer during transferFrom", async function () {
await token.approve(addr1.address, ethers.parseEther("200"));
// transferFrom should emit both Transfer and update Approval (in OpenZeppelin v5, Approval event on allowance decrease)
await expect(
token.connect(addr1).transferFrom(owner.address, addr2.address, ethers.parseEther("100"))
)
.to.emit(token, "Transfer")
.withArgs(owner.address, addr2.address, ethers.parseEther("100"));
});Testing Events in Foundry
Basic expectEmit
In Foundry, vm.expectEmit is placed before the emitting call. The four boolean parameters control which topics and data fields to check:
function test_EmitsTransferOnTransfer() public {
// Check all indexed topics AND the data field
vm.expectEmit(true, true, false, true);
emit Transfer(owner, user, 1000e18);
vm.prank(owner);
token.transfer(user, 1000e18);
}
function test_EmitsMintTransferFromZeroAddress() public {
vm.expectEmit(true, true, false, true);
emit Transfer(address(0), user, 500e18);
vm.prank(owner);
token.mint(user, 500e18);
}The expectEmit parameters are: (checkTopic1, checkTopic2, checkTopic3, checkData). If you pass (true, true, false, true), Foundry checks indexed arg1, indexed arg2, skips indexed arg3, and checks non-indexed data.
Testing Events NOT Emitted in Foundry
Foundry doesn't have a native .not.emit equivalent, but you can verify by inspecting the recorded logs:
function test_NoLargeTransferEventForSmallAmount() public {
vm.recordLogs();
vm.prank(owner);
token.transfer(user, 100e18); // Below threshold
Vm.Log[] memory logs = vm.getRecordedLogs();
// Check no LargeTransfer event in logs
bytes32 largeTransferSig = keccak256("LargeTransfer(address,address,uint256)");
for (uint i = 0; i < logs.length; i++) {
assertTrue(logs[i].topics[0] != largeTransferSig, "Unexpected LargeTransfer event");
}
}Testing Event Ordering in Foundry
function test_EventOrderInBatchOperation() public {
vm.recordLogs();
address[] memory recipients = new address[](2);
recipients[0] = user1;
recipients[1] = user2;
uint256[] memory amounts = new uint256[](2);
amounts[0] = 100e18;
amounts[1] = 200e18;
vm.prank(owner);
token.batchTransfer(recipients, amounts);
Vm.Log[] memory logs = vm.getRecordedLogs();
bytes32 transferSig = keccak256("Transfer(address,address,uint256)");
// Find Transfer events in order
uint256 transferCount = 0;
for (uint i = 0; i < logs.length; i++) {
if (logs[i].topics[0] == transferSig) {
if (transferCount == 0) {
// First transfer should be to user1
address to = address(uint160(uint256(logs[i].topics[2])));
assertEq(to, user1);
} else if (transferCount == 1) {
address to = address(uint160(uint256(logs[i].topics[2])));
assertEq(to, user2);
}
transferCount++;
}
}
assertEq(transferCount, 2, "Expected exactly 2 Transfer events");
}Testing Events from Internal Calls
If your contract calls another contract and that contract emits events, those events appear in the transaction receipt. Test them explicitly:
// Testing that a router's swap emits the underlying pool's Swap event
function test_RouterSwapEmitsPoolEvent() public {
vm.expectEmit(true, true, false, false, address(pool)); // note: last arg is emitter address
emit Swap(user, amountIn, 0, amountOut, 0);
vm.prank(user);
router.swapExactInput(tokenA, tokenB, amountIn, minAmountOut);
}In Foundry, you can pass the emitter contract address as the fifth argument to expectEmit to verify which contract emitted the event.
Event Testing for Subgraph Developers
If you're building a subgraph, your event tests directly map to your subgraph handler tests. For every event handler in your subgraph:
- Write a Solidity test that triggers the event
- Verify the event's arguments match exactly what your handler expects
- Test edge cases: zero amounts, same address to/from, maximum values
// This test documents the exact shape of the event your subgraph relies on
it("Should emit Deposited with correct share calculation", async function () {
const depositAmount = ethers.parseEther("1000");
const tx = await vault.deposit(depositAmount);
const receipt = await tx.wait();
const depositedEvent = receipt.logs
.map(log => {
try { return vault.interface.parseLog(log); } catch { return null; }
})
.find(e => e && e.name === "Deposited");
expect(depositedEvent).to.not.be.null;
expect(depositedEvent.args.user).to.equal(owner.address);
expect(depositedEvent.args.amount).to.equal(depositAmount);
expect(depositedEvent.args.shares).to.be.gt(0n);
// Document this: shares = amount when vault is empty (first deposit)
expect(depositedEvent.args.shares).to.equal(depositAmount);
});Common Event Testing Mistakes
Not testing at the boundary: If your event fires when amount >= threshold, test at threshold - 1, threshold, and threshold + 1.
Only testing the happy path: Test events in reverting transactions too — they should NOT emit events if the transaction reverts.
it("Should not emit events when transaction reverts", async function () {
// No approval — this will revert
await expect(
token.connect(addr1).transferFrom(owner.address, addr2.address, 100n)
).to.be.reverted;
// Verify no Transfer event was emitted
// (Hardhat automatically handles this — reverted txs don't emit events)
// But if you're checking side effects in a try/catch, verify explicitly
});Ignoring indexed vs non-indexed in assertions: In Foundry, a wrong boolean configuration in expectEmit makes tests pass when they shouldn't.
Forgetting to test contract-emitted events from external calls: If your function calls another contract, test that the external contract's events are also emitted.
Event coverage is a first-class concern in smart contract testing. Your on-chain state might be correct, but if your events are wrong, your subgraph is wrong, your front-end is wrong, and your users are confused. Test events with the same rigor you apply to state changes.