Unit testing
A trigger handler is a near-pure function of an event payload, a snapshot of conditions, and a set of action handlers. That shape is the whole reason Triggery exists — and it’s also the reason trigger tests are short, fast and framework-agnostic. You don’t render anything. You don’t await an effect commit. You don’t fake a <Provider>. You build a tiny runtime, mock the ports, fire the event, assert what was called.
This page covers the unit-testing surface in @triggery/testing: createTestRuntime, mockCondition, mockAction, fireSync versus fire + flushMicrotasks, and the inspector as an assertion target.
Install
Section titled “Install”The package has no runtime dependencies beyond @triggery/core (which you already have). It works the same in Vitest, Jest with ESM, node:test and bun:test — no test-runner globals are touched.
The shape of a trigger test
Section titled “The shape of a trigger test”Four lines of setup, one line of action, two lines of assertion. That’s the whole shape. Everything below is a variation.
createTestRuntime — an isolated runtime per test
Section titled “createTestRuntime — an isolated runtime per test”createTestRuntime(options?) wraps createRuntime from @triggery/core and adds three test-only methods: mockCondition, mockAction, flushMicrotasks. Everything else from the public Runtime API is available — fire, fireSync, subscribe, getInspectorBuffer, graph, dispose.
The crucial bit is isolation. Two test runtimes share nothing — no triggers, no event index, no inspector buffer. Define a trigger against rtA, fire on rtB, and the trigger doesn’t run:
This is why you should always pass the runtime as the second argument to createTrigger in tests. Calling createTrigger(config) without one falls back to getDefaultRuntime(), which is a global singleton — and leaks across tests.
Options
Section titled “Options”createTestRuntime accepts the same RuntimeOptions as createRuntime:
For most tests the defaults are fine. Bump inspectorBufferSize if you assert on long sequences. Force inspector: true if your test runner sets NODE_ENV=production for some reason — without it, getInspectorBuffer() would return an empty array.
mockCondition — supply a snapshot
Section titled “mockCondition — supply a snapshot”Use it where a useCondition would normally run. Two forms:
The wrapping rule: if you pass a function with arity === 0, it’s treated as the getter directly. If you pass anything else — including a function with parameters — it’s treated as the value and wrapped in () => valueOrGetter. The escape hatch for “my condition value is itself a zero-arg function” is to pass an explicit getter: () => myFn.
mockCondition returns a RegistrationToken with an unregister() method, exactly like registerCondition. Use it to test mid-run replacement:
mockAction — register a side-effect handler
Section titled “mockAction — register a side-effect handler”handler is typically vi.fn() (or jest.fn() / a bare closure with side effects you can assert later). The trigger’s actions.name?.(payload) calls go straight to your handler.
Like mockCondition, this returns a token. The runtime keeps a stack of registrations per name and the most recent one wins — so registering a second mock for the same action transparently replaces the first (and unregistering pops back to the previous one).
Firing events: fireSync versus fire
Section titled “Firing events: fireSync versus fire”The runtime has two firing modes and your test picks the one that matches how the trigger will run in production.
fireSync — bypass the scheduler
Section titled “fireSync — bypass the scheduler”fireSync dispatches handlers in the same call frame, before fireSync returns. No microtask, no await. Use it for:
- Synchronous handlers (the default).
- Hot-path tests where you want call-stack determinism.
- Reproducing a “fire from inside an action” cascade in a single tick.
fire + flushMicrotasks — through the scheduler
Section titled “fire + flushMicrotasks — through the scheduler”fire queues dispatch on the microtask scheduler — the same path your production code takes. Two fire calls in the same tick batch into one microtask drain. Use it to test:
- Microtask coalescing (two rapid
fires should both run before the next macrotask). - Anything that genuinely depends on the scheduler — order, batching, the
'sync'schedule option.
flushMicrotasks is await Promise.resolve() twice; it lets queued handlers run, including any follow-up microtasks they schedule. After it resolves, assertions are safe.
Async assertions
Section titled “Async assertions”For an async handler — one that returns a Promise — you must let it settle. The same flushMicrotasks pattern works, with extra rounds if the handler does its own awaiting:
For deterministic timing, mock fetch (MSW, vi.fn(), a closure) so the resolution happens on a known microtask. Then await rt.flushMicrotasks() is usually enough.
Asserting skip reasons via the inspector
Section titled “Asserting skip reasons via the inspector”When a trigger doesn’t run, the inspector records why. That’s the cleanest assertion target for “did it skip because the required condition was missing” — much better than asserting the action was not called, which proves nothing about the path taken.
Other skip reasons you can match on:
| Reason fragment | Meaning |
|---|---|
'missing-required: <name>' | A required condition wasn’t registered. |
'disabled' | The trigger was trigger.disable()-d. |
'cancelled-by-middleware: <name>' | A middleware’s onFire returned { cancel: true }. |
The inspector also records executedActions: readonly string[] per run — handy for asserting which actions actually fired (think debounce/throttle):
See Inspector for the full snapshot shape.
Snapshot inspection patterns
Section titled “Snapshot inspection patterns”Three useful patterns when you’re debugging a flaky trigger or building a contract test.
Last run
Section titled “Last run”trigger.inspect() returns the latest snapshot for that trigger, or undefined if it hasn’t run. Equivalent to runtime.getInspectorBuffer().find(s => s.triggerId === trigger.id) but O(1).
Full sequence (newest first)
Section titled “Full sequence (newest first)”The buffer is a ring; default size is 50. If you assert on long sequences, bump inspectorBufferSize.
Subscribed listener
Section titled “Subscribed listener”Useful when you want to assert ordering across multiple triggers in one runtime.
Vitest configuration
Section titled “Vitest configuration”A minimal Vitest config that picks up trigger tests anywhere in src/:
environment: 'node' is intentional — unit tests don’t render React, so jsdom buys you nothing and slows the suite. Use environment: 'jsdom' (or happy-dom) only for the integration tests.
A small global helper to keep tests tidy:
Then every test starts with a fresh runtime and dispose() clears any in-flight timers from cancelled async handlers — no leakage between tests.
Jest interop notes
Section titled “Jest interop notes”Triggery packages are ESM-only. Jest’s default config still treats files as CommonJS, which makes import { createTrigger } from '@triggery/core' fail with a SyntaxError: Cannot use import statement outside a module. Two ways out:
Run Jest with the ESM VM flag (recommended)
Section titled “Run Jest with the ESM VM flag (recommended)”This works for Jest 28+ and is the official ESM path. If you’re on an older Jest, upgrade — the ESM story before 28 is too brittle for daily use.
Switch to Vitest
Section titled “Switch to Vitest”If you can choose: Vitest runs Jest-shaped tests with no transform config, native ESM, and a ~10× faster cold start. The migration is import { test, expect, vi } from 'vitest' and a one-page vitest.config.ts. Trigger tests are pure logic — there’s no Jest API you’d miss.
bun:test and node:test
Section titled “bun:test and node:test”Both work out of the box. The kit doesn’t depend on any Vitest-specific globals; the only test-runner adaptation is which mock library you use.