
Testing Solidity Event Emission with Indexed Parameters and Log Assertions
Why event testing matters
Event tests are useful whenever a contract is consumed by off-chain software. That includes:
- indexers such as The Graph
- backend services that react to contract activity
- frontend apps that display transaction history
- monitoring and alerting systems
- governance and compliance tooling
A contract can behave correctly on-chain and still be broken from an application perspective if it emits the wrong event, omits an event, or places the wrong data in indexed fields. Since indexed parameters are stored in topics rather than the data payload, they are especially important to test carefully.
What to verify
A good event test usually checks:
- the correct event was emitted
- the event was emitted exactly once, or the expected number of times
- each argument has the expected value
- indexed arguments are placed in the expected topic positions
- no unexpected event was emitted in a critical path
Solidity event structure and indexed parameters
Consider a simple token-like contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
event Deposited(address indexed account, uint256 amount, bytes32 reference);
event Withdrawn(address indexed account, uint256 amount);
mapping(address => uint256) public balances;
function deposit(bytes32 reference) external payable {
require(msg.value > 0, "no value");
balances[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value, reference);
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
emit Withdrawn(msg.sender, amount);
}
}In this example:
accountis indexed, so it becomes a topicamountandreferenceare non-indexed, so they are encoded in the event dataDepositedandWithdrawnare separate event signatures
When testing, you should not only assert that Deposited happened, but also that the indexed account is correct and the reference value is preserved.
Choosing the right assertion style
Different frameworks expose event assertions in different ways. The best choice depends on how much detail you need.
| Assertion style | Best for | Strengths | Limitations |
|---|---|---|---|
| High-level event matcher | Common unit tests | Readable, concise | May hide topic-level details |
| Low-level log inspection | Indexed parameters, multiple logs | Precise, framework-agnostic | More verbose |
| ABI decoding of logs | Complex events | Accurate field validation | Requires more setup |
For most contracts, start with a high-level matcher. Use low-level log inspection when you need to verify topic ordering, anonymous events, or multiple emissions in one transaction.
Testing events in Foundry
Foundry provides strong support for log assertions through cheatcodes such as expectEmit. This is especially useful when you want to verify both the event signature and the emitted values.
Example: asserting a deposit event
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
contract VaultTest is Test {
Vault vault;
function setUp() public {
vault = new Vault();
}
function testDepositEmitsEvent() public {
bytes32 reference = keccak256("invoice-001");
vm.expectEmit(true, false, false, true);
emit Vault.Deposited(address(this), 1 ether, reference);
vault.deposit{value: 1 ether}(reference);
}
}How expectEmit works
The four boolean flags correspond to whether Foundry should check:
topic1topic2topic3- event data
For Deposited(address indexed account, uint256 amount, bytes32 reference):
accountistopic1amountandreferenceare in the data payload, but onlyreferenceis abytes32value in the data- the event signature itself is always checked through the emitted event declaration
This pattern is powerful because it forces the test to mirror the exact event shape. If the contract emits the wrong account or changes the event payload, the test fails.
Testing multiple events in one transaction
If a function emits more than one event, assert each one in order:
function testWithdrawEmitsSingleEvent() public {
vm.deal(address(this), 2 ether);
vault.deposit{value: 2 ether}(bytes32("seed"));
vm.expectEmit(true, false, false, true);
emit Vault.Withdrawn(address(this), 1 ether);
vault.withdraw(1 ether);
}If the function emits a setup event and then a business event, place expectEmit immediately before the call that should trigger the target log. This keeps the test focused and avoids accidental matching on earlier logs.
Testing events in Hardhat
In JavaScript or TypeScript tests, event assertions are often expressed with to.emit and withArgs. This style is readable and works well for integration-style tests.
Example with ethers and chai
import { expect } from "chai";
describe("Vault", function () {
it("emits Deposited with the correct indexed account", async function () {
const [owner] = await ethers.getSigners();
const Vault = await ethers.getContractFactory("Vault");
const vault = await Vault.deploy();
const reference = ethers.keccak256(ethers.toUtf8Bytes("invoice-001"));
await expect(
vault.connect(owner).deposit(reference, { value: ethers.parseEther("1") })
)
.to.emit(vault, "Deposited")
.withArgs(owner.address, ethers.parseEther("1"), reference);
});
});Practical notes
withArgschecks the decoded event arguments, including indexed fields- if the event is emitted multiple times, the matcher may need more specific scoping
- if you are testing a transaction that emits several different events, assert the most important one explicitly rather than relying on implicit matching
For contracts with complex flows, it is often useful to inspect the transaction receipt and verify the exact event sequence.
Verifying topic-level details
Sometimes you need to test the raw log structure, not just decoded arguments. This is common when:
- an off-chain indexer depends on a specific indexed field
- you are testing anonymous events
- you want to confirm that a field is actually indexed
- you are debugging a mismatch between emitted logs and frontend filters
A log contains:
- the emitting contract address
topics[0]: the event signature hash, unless the event is anonymoustopics[1..]: indexed argumentsdata: non-indexed arguments
Why this matters
If you change an event from:
event Deposited(address indexed account, uint256 amount, bytes32 reference);to:
event Deposited(address account, uint256 amount, bytes32 reference);the event may still decode correctly in some tests, but off-chain consumers that filter by account topic will stop seeing it. Topic-level assertions catch that regression.
Example strategy
In Foundry, you can inspect logs from the receipt after the call and decode them with the ABI. In Hardhat, you can use the transaction receipt and examine receipt.logs directly. This is especially useful when you need to confirm exact topic positions or compare emitted logs across versions.
Testing negative cases
Event tests should not only confirm that the happy path emits logs. They should also confirm that invalid operations do not emit misleading events.
For example, in the Vault contract, a failed withdrawal should revert before emitting Withdrawn. A test should verify that no event is produced on failure.
Foundry example
function testWithdrawRevertsWithoutEvent() public {
vm.expectRevert(bytes("insufficient"));
vault.withdraw(1 ether);
}Because the transaction reverts, no event should survive in the receipt. This is important for systems that treat event emission as proof of state transition. If a revert occurs after an event is emitted, the log is discarded, but your test should still enforce the intended control flow.
Common pitfalls
1. Testing only the event name
A test that checks only Deposited without validating arguments can miss serious regressions. The event may be emitted with the wrong account or amount, which is just as harmful as not emitting it at all.
2. Ignoring indexed fields
Indexed parameters are often the fields that off-chain systems care about most. If you do not assert them, you may miss a broken filter path in your indexer or frontend.
3. Overfitting to log order
If a function emits multiple events, avoid brittle tests that depend on unrelated logs unless the order is part of the contract’s public behavior. Focus on the logs that matter.
4. Forgetting cross-contract emissions
If your contract calls another contract that emits events, your test may see logs from both. Make sure you are asserting the correct emitter address and not accidentally matching a downstream event.
A practical testing checklist
Use this checklist when adding event tests to a Solidity codebase:
- assert the event is emitted on the intended path
- verify all indexed arguments
- verify all non-indexed arguments
- confirm the event is not emitted on revert paths
- check the emitter address when multiple contracts are involved
- inspect raw logs when topic-level precision matters
- keep event names and argument order aligned with the ABI
When to prefer event tests over storage assertions
Event tests are not a replacement for state tests, but they are often the better choice when the contract’s public contract with the outside world is log-based.
Use event tests when:
- an off-chain service depends on the event
- the event is the canonical record of an action
- the contract is designed for indexing or analytics
- you need to validate a user-facing transaction history
Use storage assertions when:
- the state itself is the source of truth
- the event is only informational
- the contract’s behavior is not consumed off-chain
In practice, the strongest test suites combine both. State assertions verify correctness on-chain, while event assertions verify observability and integration behavior.
Conclusion
Testing event emission is a small investment that pays off quickly in production reliability. By checking indexed parameters, payload values, and log structure, you can catch regressions that would otherwise break indexers, dashboards, and transaction-driven workflows.
For most teams, the best approach is to use high-level event matchers for routine cases and raw log inspection for precision-sensitive paths. That balance keeps tests readable while still protecting the contract’s external interface.
