Testing
Test the state source and the DOM separately. Keep your XState/Redux/MobX logic covered with its own unit tests, then add light DOM tests for the web components.
Behavior tests vs. DOM tests
Section titled “Behavior tests vs. DOM tests”For deterministic command/event assertions without mounting an element, drive the headless runtime (execute, on, getView, …) or the scenario/story helpers directly. Both are documented with full signatures and examples in the Testing DSL reference, built on the Headless runtime. Effect timing in those tests is deterministic — see Deterministic effects for why a single transition never replays mount-time emissions.
The rest of this guide covers the test harness setup and the state-source and DOM tests that sit alongside those behavior tests.
- Use a browser-like runner (e.g., Vitest + JSDOM). If you rely on advanced
@ignite-element/rendererconfig, shared styles, or the legacylitrenderer, import that setup once before tests. - Register the component under test before each suite. Clear the DOM between tests (
document.body.innerHTML = "").
import './ignite.config'; // only if your app uses ignite.config.tsimport '@ignite-element/renderer/lit'; // only if your tests use the lit renderer
// vitest.config.tsexport default defineConfig({ test: { globals: true, environment: 'jsdom', setupFiles: ['./test/setup.ts'], },});State tests (XState example)
Section titled “State tests (XState example)”Write a machine test exactly as you would without Ignite.
import { assign, setup } from 'xstate';
export const counterMachine = setup({ types: {} as { context: { count: number }; events: { type: 'INC' } | { type: 'DEC' }; },}).createMachine({ context: { count: 0 }, on: { INC: { actions: assign(({ context }) => ({ count: context.count + 1 })) }, DEC: { actions: assign(({ context }) => ({ count: context.count - 1 })) }, },});
// counter.machine.test.tsimport { createActor } from 'xstate';import { counterMachine } from './counter.machine';
describe('counter machine', () => { it('increments and decrements', () => { const actor = createActor(counterMachine).start();
actor.send({ type: 'INC' }); expect(actor.getSnapshot().context.count).toBe(1);
actor.send({ type: 'DEC' }); expect(actor.getSnapshot().context.count).toBe(0); });});DOM tests (web component + XState + Lit)
Section titled “DOM tests (web component + XState + Lit)”Register the component once, mount it, and drive it via the shadow DOM. The example below uses the Lit renderer and the same counterMachine.
import { html } from 'lit-html';import { igniteCore } from 'ignite-element/xstate';import { counterMachine } from './counter.machine';
export const registerCounterElement = () => igniteCore({ source: counterMachine, view: ({ context }) => ({ count: context.count }), commands: ({ actor }) => ({ inc: () => actor.send({ type: 'INC' }), dec: () => actor.send({ type: 'DEC' }), }), })('my-counter', ({ count, inc, dec }) => html` <div> <p>Count: ${count}</p> <button data-testid="dec" @click=${dec}>-</button> <button data-testid="inc" @click=${inc}>+</button> </div> `);
// counter.element.test.tsimport { beforeAll, afterEach, describe, expect, it } from 'vitest';import '@ignite-element/renderer/lit';import './ignite.config';import { registerCounterElement } from './counter.element';
beforeAll(() => registerCounterElement());afterEach(() => { document.body.innerHTML = '';});
describe('<my-counter>', () => { it('renders and responds to clicks', async () => { const el = document.createElement('my-counter'); document.body.appendChild(el);
await Promise.resolve(); // allow initial render
const inc = el.shadowRoot!.querySelector<HTMLButtonElement>('[data-testid="inc"]')!; inc.click(); await Promise.resolve(); // allow state flush
expect(el.shadowRoot!.textContent).toContain('Count: 1'); });});Shared adapter teardown checks
Section titled “Shared adapter teardown checks”- For shared sources you own (started actors/stores), stop them in your tests when you own their lifecycle.
- To assert shared-adapter teardown, opt a shared core into it with
cleanup: trueand verify the adapter is released when the last element disconnects. By default a consumer-owned shared adapter stays alive for the core’s lifetime, so there is nothing to assert there.