Unit tests cover handler logic — given a payload and a snapshot, did the right actions fire. They tell you nothing about the part that gets the snapshot in there in the first place: the useCondition in some provider, the useAction in some reactor, the useEvent in some producer. If wiring is the bug, a unit test won’t catch it.
Integration tests render the actual component tree under a TriggerRuntimeProvider, fire events the way the UI does, and assert observable side effects in the DOM. They’re slower than unit tests (jsdom + render + commit), so you’ll have fewer of them — but they cover the layer where most “I forgot to mount the reactor” bugs live.
The bug is in registration — a provider doesn’t mount on some route, a reactor unmounts during a transition, a scope is misnamed.
The trigger’s effect is visible in the DOM — a toast appears, a class flips, a focus moves. Asserting that the side effect happened is easier than asserting that the action was called if the action’s whole purpose is to call a third-party UI library.
You’re testing a scenario across two scopes / runtimes / micro-frontends.
You’re testing integration with a network mock (MSW) — the data fetched on an event needs to flow through the runtime to the reactor.
For everything else, prefer the unit-testing approach in Unit testing — same logic, ~10× faster.
The single most important rule: never share a runtime across tests. Without isolation, triggers registered in one test leak into the next, providers from yesterday’s render still hold registrations, and you spend an afternoon staring at “why does this test only fail when the previous one ran first”.
The standard pattern is a fresh createRuntime wrapped around the component under test:
src/test-utils/render-with-runtime.tsx (React)
import { createRuntime, type Runtime } from '@triggery/core';import { TriggerRuntimeProvider } from '@triggery/react';import { render, type RenderOptions } from '@testing-library/react';import type { ReactElement } from 'react';export function renderWithRuntime( ui: ReactElement, options: RenderOptions & { runtime?: Runtime } = {},) { const runtime = options.runtime ?? createRuntime(); const result = render(ui, { ...options, wrapper: ({ children }) => ( <TriggerRuntimeProvider runtime={runtime}>{children}</TriggerRuntimeProvider> ), }); return { ...result, runtime };}
Then every test gets its own runtime for free — and you can still pass one in explicitly when a test wants to assert against the inspector:
The trigger is defined at module scope, exactly as it would be in production. The runtime is per-test; the trigger module is shared. That works because createTrigger(config) without a second argument registers against getDefaultRuntime(), but <TriggerRuntimeProvider runtime={fresh}> overrides which runtime the hooks talk to.
For full isolation (no shared default runtime), pass the runtime explicitly: createTrigger(config, runtime) inside the test factory. The trade-off is more setup code, less production-like.
await waitFor(...) covers the microtask gap between fire and the handler running. await new Promise(r => setTimeout(r, 0)) works too — pick whichever feels more native to your test suite.
Solid’s testing library exports the same render / screen / fireEvent surface as React’s, but render(() => <App />) takes a function instead of a JSX element (Solid renders are reactive functions, not snapshots). The two await Promise.resolve() calls flush the microtask scheduler — same trick flushMicrotasks uses inside @triggery/testing.
Vue’s mount provides the standard wrapper API. The cleanest pattern is a small Root component per test (or per file) that pins the runtime. The exported TriggerRuntimeProvider is the same component used in production — there’s no separate test-only provider to learn.
The big shift from unit to integration is what you assert.
Unit tests assert that an action was called — they own the action mock, so they can. Integration tests don’t mock the action; they assert that the real side effect happened, somewhere in the DOM:
The DOM assertion catches bugs the inspector misses — a reactor that calls setToast but renders nothing, a portal that doesn’t mount, a CSS rule that hides the toast. Use the inspector as a secondary assertion when the DOM check has already passed (or as the primary one when the side effect is non-visual, e.g. a localStorage write).
render(...) from React Testing Library auto-unmounts between tests in Vitest with @testing-library/jest-dom/vitest. Solid and Vue do the same with their respective configs. Confirm in your test setup:
Auto-unmount disposes the React tree, which unregisters every condition and action via the cleanup paths. The trigger itself stays registered against the runtime — disposing the runtime in afterEach is belt-and-suspenders:
afterEach(() => { // If you stash the runtime somewhere global per-test: currentRuntime?.dispose(); currentRuntime = undefined;});
With the per-test wrapper above, this is automatic — each test gets a fresh createRuntime(), the old one becomes unreachable and gets garbage-collected.
Now the async handler runs against a controlled fetch, and your DOM assertions wait for the action that consumes the response.
Test
test('fetched message renders in toast', async () => { renderWithRuntime(<App />); fireEvent.click(screen.getByRole('button', { name: 'load' })); // Two waits: one for the microtask scheduler, one for the fetch resolution. // waitFor handles both. await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('mocked'));});
If your trigger uses debounce / throttle / defer, use createFakeScheduler from @triggery/testing instead. It’s framework-agnostic and doesn’t fight with vi. Mixing the two leads to “the test hangs forever” puzzles.
Run your integration suite with <StrictMode> wrapping the runtime provider. Triggery’s hooks are designed to be StrictMode-safe (the mount→unmount→mount cycle clears the unmount token before the second mount runs, so last-write-wins for conditions / actions and useAction channel subscriptions land cleanly) — wrapping makes sure your components are too. See StrictMode (React) for the lifecycle details.
If you createTrigger(config) without a runtime argument, you register against the global default runtime. Two tests sharing that — even via the wrapper that overrides the React context — can still see each other’s trigger registrations (the wrapper overrides the React lookup, not the registration target). Two safer options:
Pass the runtime to createTrigger inside a factory that the test calls:
Call rt.dispose() in afterEach and accept that the global default runtime will still hold the trigger — fine for read-after-render assertions, problematic for “the trigger doesn’t exist yet” tests.
For 90% of integration tests, the global default is fine. Reach for the factory pattern only when test isolation is materially broken.
fire queues. fireSync runs immediately. React Testing Library’s fireEventis synchronous, so a click on a button that calls fire returns before the handler runs:
fireEvent.click(screen.getByRole('button')); // returns immediatelyexpect(screen.queryByRole('status')).toBeNull(); // not yet renderedawait waitFor(() => /* now it's there */ );
await waitFor(...) from React Testing Library polls until the assertion passes — that handles both the microtask drain and the React commit phase. Use it. Don’t reach for act(() => …) unless you’ve measured a specific need.