For AI agents: the documentation index is at /llms.txt. Markdown versions of pages are available by appending .md to the URL.
Skip to main content

Testing

Introduction​

Envio ships with a built-in testing library that doubles as a development loop. createTestIndexer() runs your real handlers in-process, so you can iterate on logic and validate behavior without deploying anywhere. It's designed for:

  • TDD: Write a failing test, implement the handler, capture the snapshot, commit
  • Unit tests: Feed synthetic events directly into handlers to exercise edge cases in isolation
  • E2E tests against real blockchain data: Pin a block range or let the indexer auto-detect the first block with events, and run your full handler pipeline end-to-end
  • Regression-proof assertions: Inspect entities and per-block change sets, then lock in expected output with toMatchInlineSnapshot

The library integrates well with Vitest (recommended) and any other JavaScript-based testing framework.

Getting Started​

The simplest way to start is auto-exit mode - no block ranges, no mock events. The indexer automatically finds the first block with events and processes it.

import { describe, it } from "vitest";
import { createTestIndexer } from "envio";

describe("Indexer Testing", () => {
it("Should process first two blocks with events", async (t) => {
const indexer = createTestIndexer();

t.expect(
await indexer.process({ chains: { 1: {} } }),
"Should find the first block with an event on chain 1 and process it."
).toMatchInlineSnapshot();

t.expect(
await indexer.process({ chains: { 1: {} } }),
"Should find the second block with an event on chain 1 and process it."
).toMatchInlineSnapshot();
});
});

Run pnpm test - Vitest auto-fills the snapshots on first run. Review and commit them.

Auto-exit mode fetches real blocks from HyperSync, so a test can take longer than the template's default 20 second timeout. If it times out, raise the timeout for that test, for example it("...", { timeout: 120_000 }, async (t) => { ... }).


Process API​

indexer.process({ chains }) is the single entry point for driving the indexer. The shape of each chain entry determines the mode.

Processes the first block with matching events for each chain, then exits. Each subsequent call continues from where the previous one stopped.

const result = await indexer.process({
chains: {
1: {}, // auto-detect first block with events on chain 1
100: {}, // same for chain 100
},
});

Explicit block range​

Process a specific block range. Use when you need deterministic, pinned snapshots.

const result = await indexer.process({
chains: {
1: { startBlock: 10_861_674, endBlock: 10_861_766 },
},
});

A test indexer only moves forward. startBlock defaults to the chain's configured start block, or to one past the last block a previous call processed. It throws if startBlock is below the configured start block or not past the previous call's endBlock, and if endBlock is above the configured end_block. To go back, create a new indexer with createTestIndexer().

Simulate (mock events)​

Feed synthetic events without hitting the network. Best for unit-testing handler logic.

import { TestHelpers } from "envio";

const [addr1, addr2] = TestHelpers.Addresses.mockAddresses;

await indexer.process({
chains: {
1: {
simulate: [
{
contract: "ERC20",
event: "Transfer",
params: { from: addr1, to: addr2, value: 100n },
},
],
},
},
});

You can pass multiple events in a single simulate array - they will be processed in order, just like in production.

You can optionally specify detailed event metadata per simulated event using the same block / transaction / srcAddress / logIndex shape that real events expose. See field_selection for the full list of overridable fields. Without overrides, srcAddress is the contract's first configured address (the zero address for a wildcard or address-less contract), the block number is the previous item's, starting at the call's start block, and logIndex counts up from 0. Two items can't share both a block number and a logIndex.

result.changes​

result.changes is an array of per-block change objects. Each entry has block, chainId, eventsProcessed, plus entity names as keys with a sets array of created/updated entities and a deleted array of removed ids. Dynamic contract registrations appear under addresses.sets.

Rules​

  • Only one process() call can run at a time on an indexer. Await each one.
  • Chains in one call run one after another, in chain id order.
  • Each createTestIndexer() has its own entity store. Within one indexer, entities and block progress carry over from one process() call to the next.
  • Module-level state in your handler files, such as a top-level let, is shared by every test indexer in the file. Reset it yourself if a test depends on it.
  • Reorg rollbacks are off in the test indexer.

Entity State API​

Preset state before processing and read entities after.

// Preset state before processing
indexer.EntityName.set({ id: "...", field: value, chainId: 1 }); // chainId only for per-chain entities

// Read state after processing
await indexer.EntityName.get("id"); // returns entity | undefined
await indexer.EntityName.getOrThrow("id"); // throws if not found
await indexer.EntityName.getAll(); // returns all entities of this type
await indexer.EntityName.getWhere({ owner_id: { _eq: "0x..." } }); // same filters as in handlers

These calls throw while a process() call is running. Await process() first.

The test indexer also exposes indexer.chainIds and indexer.chains, with the same shape as on the real indexer.

Per-chain entities need a chainId

With disable_default_cross_chain: true there is no chain in context outside a handler, so these chain-agnostic operations take one:

indexer.Counter.set({ id: "1", count: 0n, chainId: 1 });

// Throws if the id exists on more than one chain
await indexer.Counter.get("1");

// Narrow it instead
await indexer.Counter.getWhere({ chainId: { _eq: 1 } });

Assertions​

The testing library works with any JavaScript assertion library. The examples below use t.expect, the expect from Vitest's test context.

// Snapshot (recommended, captures full output, auto-filled on first run)
t.expect(result.changes).toMatchInlineSnapshot();

// Entity assertions
const pool = await indexer.Pool.getOrThrow(poolId);
t.expect(pool).toEqual({ id: poolId, token0_id: "0xabc..." });

// Count
t.expect(result.changes[0]?.Pair?.sets).toHaveLength(1);

// Contract addresses (after dynamic registration)
t.expect(indexer.chains[1].MyContract.addresses).toContain("0x1234...");

TDD Workflow​

  1. Write a failing test with expected entity output
  2. Implement the handler until the test passes
  3. Capture the snapshot - run pnpm test to fill toMatchInlineSnapshot
  4. Review and commit the snapshot for regression testing
warning

Do not add tests which simply restate the implementation. These provide zero confidence.

Running Tests​

pnpm test              # Run all tests
pnpm test -u # Update snapshots