Testing Events
apps/docs/src/content/docs/components/testing/event-testing Click to copy apps/docs/src/content/docs/components/testing/event-testing Interactive HELiX components dispatch custom events to communicate state changes to consumers. (Presentational components like hx-divider, hx-stack, hx-grid have no event API.) Testing events thoroughly means verifying that events fire with the correct payload, that they bubble correctly across shadow boundaries, that they are suppressed when they should not fire, and that no event listener leaks accumulate over time. This guide covers every event testing pattern used in the HELIX test suite.
The oneEvent Utility
Section titled “The oneEvent Utility”oneEvent() is an internal test helper from packages/hx-library/src/test-utils.ts. It is not exported from the published @helixui/library npm package — when writing tests in a component file under packages/hx-library/src/components/<name>/, import it via the relative path:
import { oneEvent } from '../../test-utils.js';It wraps addEventListener in a Promise that resolves on the next occurrence of a named event, with a 5-second default timeout to fail fast when an expected event never fires:
/** * Returns a Promise that resolves on the next occurrence of an event on the element, * rejecting after `timeoutMs` (default 5000) if the event never fires. */export function oneEvent<T extends Event = Event>( el: EventTarget, eventName: string, timeoutMs = 5000,): Promise<T> { return new Promise<T>((resolve, reject) => { const timer = setTimeout(() => { el.removeEventListener(eventName, handler as EventListener); reject(new Error(`[oneEvent] Timed out after ${timeoutMs}ms waiting for "${eventName}"`)); }, timeoutMs);
const handler = (e: Event) => { clearTimeout(timer); resolve(e as T); };
el.addEventListener(eventName, handler as EventListener, { once: true }); });}The { once: true } option removes the listener automatically after the first event. The timeout guarantees tests fail fast instead of hanging when an expected event never fires.
Basic Usage
Section titled “Basic Usage”Set up the promise before triggering the action that dispatches the event. Setting it up after introduces a race condition where the event fires before the listener is registered.
import { fixture, shadowQuery, oneEvent, cleanup } from '../../test-utils.js';import type { HelixButton } from './hx-button.js';import './index.js';
afterEach(cleanup);
it('dispatches hx-click on click', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
// Register listener BEFORE triggering the action const eventPromise = oneEvent(el, 'hx-click'); btn.click(); const event = await eventPromise;
expect(event).toBeTruthy();});Typed Events
Section titled “Typed Events”HELIX events carry typed detail objects. Use the TypeScript generic to get a typed event object:
it('hx-click detail contains originalEvent', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent<CustomEvent<{ originalEvent: MouseEvent }>>(el, 'hx-click'); btn.click(); const event = await eventPromise;
expect(event.detail.originalEvent).toBeInstanceOf(MouseEvent);});Testing Event Payloads
Section titled “Testing Event Payloads”Many HELiX custom events carry a detail object — for those, test that the detail contains the correct values, not just that the event fired. Some events are CustomEvent<void> (no detail payload) — for those, assert the event fired plus bubbles/composed flags via the event object itself.
hx-click: originalEvent
Section titled “hx-click: originalEvent”hx-button includes the native MouseEvent in its detail for consumers who need the raw event (e.g., to read clientX, shiftKey):
it('hx-click detail.originalEvent is a MouseEvent', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent<CustomEvent<{ originalEvent: MouseEvent }>>(el, 'hx-click'); btn.click(); const event = await eventPromise;
expect(event.detail.originalEvent).toBeInstanceOf(MouseEvent); expect(event.detail.originalEvent.type).toBe('click');});hx-input: value
Section titled “hx-input: value”hx-text-input dispatches hx-input on every keystroke with the current input value:
it('hx-input detail.value is the current input value', async () => { const el = await fixture<HelixTextInput>('<hx-text-input></hx-text-input>'); const input = shadowQuery<HTMLInputElement>(el, 'input')!;
const eventPromise = oneEvent<CustomEvent<{ value: string }>>(el, 'hx-input'); input.value = 'hello'; input.dispatchEvent(new Event('input', { bubbles: true })); const event = await eventPromise;
expect(event.detail.value).toBe('hello');});hx-change: value after blur
Section titled “hx-change: value after blur”hx-change fires when the input loses focus and its value has changed. The detail carries the final committed value:
it('hx-change detail.value reflects committed value', async () => { const el = await fixture<HelixTextInput>('<hx-text-input></hx-text-input>'); const input = shadowQuery<HTMLInputElement>(el, 'input')!;
const eventPromise = oneEvent<CustomEvent<{ value: string }>>(el, 'hx-change'); input.value = 'committed'; input.dispatchEvent(new Event('change', { bubbles: true })); const event = await eventPromise;
expect(event.detail.value).toBe('committed');});Testing Events That Must NOT Fire
Section titled “Testing Events That Must NOT Fire”Disabled components suppress their events. This is critical to test—a disabled button that still dispatches hx-click is a bug.
oneEvent() is not suitable here because it waits indefinitely for an event that should never come. Instead, register a spy with addEventListener, trigger the interaction, wait a fixed delay, and assert the spy was never called.
it('does NOT dispatch hx-click when disabled', async () => { const el = await fixture<HelixButton>('<hx-button disabled>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
let fired = false; el.addEventListener('hx-click', () => { fired = true; });
btn.click();
// Wait longer than any async dispatch could take await new Promise((r) => setTimeout(r, 50));
expect(fired).toBe(false);});The 50ms wait is intentional. It gives any async event dispatch (even in micro-tasks or promise chains) enough time to resolve before asserting the absence of the event.
Using vi.fn() for Spy-Based Assertions
Section titled “Using vi.fn() for Spy-Based Assertions”vi.fn() provides more detail than a boolean flag — you can assert call count, arguments, and more:
import { vi } from 'vitest';
it('does not dispatch hx-click when disabled (vi.fn spy)', async () => { const el = await fixture<HelixButton>('<hx-button disabled>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const handler = vi.fn(); el.addEventListener('hx-click', handler);
btn.click(); await new Promise((r) => setTimeout(r, 50));
expect(handler).not.toHaveBeenCalled();});
it('dispatches hx-click exactly once per click', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const handler = vi.fn(); el.addEventListener('hx-click', handler);
btn.click(); btn.click(); btn.click(); await new Promise((r) => setTimeout(r, 50));
expect(handler).toHaveBeenCalledTimes(3);});Testing Event Bubbling and Composition
Section titled “Testing Event Bubbling and Composition”HELIX events are dispatched with bubbles: true, composed: true. Both properties must be tested — they are part of the public contract.
bubbles: true— the event traverses up the DOM treecomposed: true— the event crosses shadow DOM boundaries
If either property is missing, consumers listening on a parent element will not receive the event.
it('hx-click bubbles and is composed', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent<CustomEvent>(el, 'hx-click'); btn.click(); const event = await eventPromise;
expect(event.bubbles).toBe(true); expect(event.composed).toBe(true);});Verifying Events Cross Shadow Boundaries
Section titled “Verifying Events Cross Shadow Boundaries”Test that a parent element outside the shadow tree also receives the event. This confirms composed: true is working:
it('hx-click reaches a parent listener outside the shadow tree', async () => { // Wrap the component in a parent container const wrapper = document.createElement('div'); wrapper.innerHTML = '<hx-button>Click</hx-button>'; document.getElementById('test-fixture-container')!.appendChild(wrapper);
const el = wrapper.querySelector('hx-button') as HelixButton; await el.updateComplete;
const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
// Listen on the PARENT, not the component itself const eventPromise = oneEvent<CustomEvent>(wrapper, 'hx-click'); btn.click(); const event = await eventPromise;
// Event crossed the shadow boundary and bubbled to the parent expect(event).toBeTruthy(); expect(event.composed).toBe(true);});Event Retargeting
Section titled “Event Retargeting”When a composed event crosses a shadow boundary, the browser retargets event.target to the shadow host. This is correct browser behavior but is worth documenting in tests when the target matters to consumers:
it('hx-click target is the hx-button host element', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent<CustomEvent>(el, 'hx-click'); btn.click(); const event = await eventPromise;
// Target is the custom element host, not the internal <button> expect(event.target).toBe(el);});Testing Keyboard Events That Trigger Custom Events
Section titled “Testing Keyboard Events That Trigger Custom Events”Keyboard interactions must trigger the same events as pointer interactions. Test keyboard events directly on the shadow element that handles them.
Enter and Space on hx-button
Section titled “Enter and Space on hx-button”Native <button> elements activate on Enter and Space natively. Since hx-button wraps a native button, keyboard activation goes through the native element’s click handler, which then dispatches hx-click:
it('Enter activates hx-button and dispatches hx-click', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent<CustomEvent>(el, 'hx-click');
// Fire the keyboard event on the native button, then click it btn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); btn.click();
const event = await eventPromise; expect(event).toBeTruthy();});
it('Space activates hx-button and dispatches hx-click', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent<CustomEvent>(el, 'hx-click');
btn.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); btn.click();
const event = await eventPromise; expect(event).toBeTruthy();});Keyboard Events on Custom-Handled Components
Section titled “Keyboard Events on Custom-Handled Components”For components that handle keyboard events directly (e.g., a custom dropdown), dispatch KeyboardEvent on the element and await the expected custom event:
it('Escape key on hx-dialog closes the dialog and fires hx-after-close', async () => { const el = await fixture<HelixDialog>('<hx-dialog heading="Confirm"><p>Body</p></hx-dialog>');
// Open the modal first el.showModal(); await el.updateComplete;
const eventPromise = oneEvent(el, 'hx-after-close'); el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true })); await eventPromise;
expect(el.open).toBe(false);});For hx-select, the close-after-Escape signal is the synchronous open state flip, not an hx-close event — hx-select does not emit hx-close. Assert el.open === false after dispatching Escape:
it('Escape key on open hx-select sets el.open=false', async () => { const el = await fixture<HelixSelect>('<hx-select label="Pick one"></hx-select>'); el.dispatchEvent(new MouseEvent('click', { bubbles: true })); await el.updateComplete;
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await el.updateComplete;
expect(el.open).toBe(false);});Testing Async Events
Section titled “Testing Async Events”Some events fire asynchronously — after a fetch, after a debounce, or after a transition completes. Use oneEvent() combined with the action that triggers the async dispatch, and await the promise. oneEvent has a 5-second timeout (the 3rd argument) — long-running async transitions should pass an explicit larger value:
// Example anchored at a real async pattern: hx-skeleton fading out after content loadsit('hx-skeleton dispatches hx-load-complete after transition', async () => { const el = await fixture<HelixSkeleton>('<hx-skeleton></hx-skeleton>');
// Wait up to 10s for the loaded transition const eventPromise = oneEvent<CustomEvent<void>>(el, 'hx-load-complete', 10000);
el.loaded = true; await el.updateComplete;
const event = await eventPromise; expect(event.bubbles).toBe(true); expect(event.composed).toBe(true);});There is no shipped <org-async-component> or HelixDataComponent — earlier drafts of this page used those as placeholders. Use the real hx-skeleton.test.ts or hx-toast.test.ts patterns for the canonical async-event template.
For events that have a reasonable timeout bound, wrap oneEvent() in a Promise.race() against a timeout:
it('hx-loaded fires within 2 seconds', async () => { const el = await fixture<HelixDataComponent>( '<org-async-component src="/api/data"></org-async-component>', );
const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error('hx-loaded did not fire within 2s')), 2000), );
const event = await Promise.race([oneEvent<CustomEvent>(el, 'hx-loaded'), timeout]);
expect(event).toBeTruthy();});Testing Event Listener Cleanup
Section titled “Testing Event Listener Cleanup”Event listeners attached in connectedCallback must be removed in disconnectedCallback. Leaking listeners causes memory issues and incorrect behavior when components are re-mounted. Test cleanup by disconnecting the element and verifying the listener is gone.
Pattern: Spy on removeEventListener
Section titled “Pattern: Spy on removeEventListener”it('removes its internal listeners on disconnect', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
// Removing the element from the DOM triggers disconnectedCallback el.remove();
// After disconnect, clicking the internal button should not dispatch hx-click let fired = false; document.addEventListener('hx-click', () => { fired = true; });
btn.click(); await new Promise((r) => setTimeout(r, 50));
expect(fired).toBe(false);
// Cleanup the test listener document.removeEventListener('hx-click', () => {});});Pattern: Reconnect and Verify
Section titled “Pattern: Reconnect and Verify”A more thorough approach: disconnect, reconnect, and verify the component works normally again. If listeners were duplicated instead of replaced, events would fire multiple times.
it('does not duplicate listeners on reconnect', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const handler = vi.fn(); el.addEventListener('hx-click', handler);
// Disconnect and reconnect const parent = el.parentElement!; parent.removeChild(el); parent.appendChild(el); await el.updateComplete;
btn.click(); await new Promise((r) => setTimeout(r, 50));
// Should fire exactly once, not twice expect(handler).toHaveBeenCalledTimes(1);});Complete Example: Testing hx-button Events
Section titled “Complete Example: Testing hx-button Events”This is the full event test suite from hx-button.test.ts, annotated with the patterns above:
import { describe, it, expect, afterEach, vi } from 'vitest';import { fixture, shadowQuery, oneEvent, cleanup } from '../../test-utils.js';import type { HelixButton } from './hx-button.js';import './index.js';
afterEach(cleanup);
describe('hx-button — Events', () => { // 1. Event fires on click it('dispatches hx-click on click', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent(el, 'hx-click'); btn.click(); const event = await eventPromise;
expect(event).toBeTruthy(); });
// 2. Event has correct properties it('hx-click bubbles and is composed', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent<CustomEvent>(el, 'hx-click'); btn.click(); const event = await eventPromise;
expect(event.bubbles).toBe(true); expect(event.composed).toBe(true); });
// 3. Event payload is correct it('hx-click detail contains the original MouseEvent', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent<CustomEvent<{ originalEvent: MouseEvent }>>(el, 'hx-click'); btn.click(); const event = await eventPromise;
expect(event.detail.originalEvent).toBeInstanceOf(MouseEvent); });
// 4. Event is suppressed when disabled it('does NOT dispatch hx-click when disabled', async () => { const el = await fixture<HelixButton>('<hx-button disabled>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
let fired = false; el.addEventListener('hx-click', () => { fired = true; }); btn.click();
await new Promise((r) => setTimeout(r, 50)); expect(fired).toBe(false); });
// 5. Keyboard: Enter triggers the event it('Enter key dispatches hx-click', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent<CustomEvent>(el, 'hx-click'); btn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); btn.click(); const event = await eventPromise;
expect(event).toBeTruthy(); });
// 6. Keyboard: Space triggers the event it('Space key dispatches hx-click', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const eventPromise = oneEvent<CustomEvent>(el, 'hx-click'); btn.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); btn.click(); const event = await eventPromise;
expect(event).toBeTruthy(); });
// 7. Call count verification with vi.fn it('dispatches one hx-click per click', async () => { const el = await fixture<HelixButton>('<hx-button>Click</hx-button>'); const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
const handler = vi.fn(); el.addEventListener('hx-click', handler);
btn.click(); btn.click(); await new Promise((r) => setTimeout(r, 50));
expect(handler).toHaveBeenCalledTimes(2); });});Summary of Patterns
Section titled “Summary of Patterns”| Scenario | Pattern |
|---|---|
| Event fires | oneEvent() before action, await after |
| Typed detail | oneEvent<CustomEvent<DetailType>>() |
| Event does not fire | vi.fn() spy + 50ms wait + not.toHaveBeenCalled() |
| Bubbles + composed | Assert event.bubbles and event.composed |
| Crosses shadow boundary | Listen on parent wrapper outside shadow tree |
| Event retargeting | Assert event.target === el (the host element) |
| Keyboard triggers event | dispatchEvent(KeyboardEvent) + .click() |
| Async events | await oneEvent() directly — it waits as long as needed |
| Listener cleanup | Disconnect element, verify subsequent actions don’t fire |
| Call count | vi.fn() + toHaveBeenCalledTimes(n) |
Common Mistakes
Section titled “Common Mistakes”Setting up oneEvent after the action. The event fires before the listener is registered:
// Bad — race conditionbtn.click();const event = await oneEvent(el, 'hx-click'); // May already have fired
// Good — listener registered firstconst eventPromise = oneEvent(el, 'hx-click');btn.click();const event = await eventPromise;Using oneEvent to test event absence. It waits forever for an event that never comes:
// Bad — test never finishesit('does not fire when disabled', async () => { const eventPromise = oneEvent(el, 'hx-click'); btn.click(); await eventPromise; // Hangs});
// Good — spy + timeoutlet fired = false;el.addEventListener('hx-click', () => { fired = true;});btn.click();await new Promise((r) => setTimeout(r, 50));expect(fired).toBe(false);Not asserting bubbles and composed. These are contractual obligations:
// Incomplete — doesn't verify the event works across shadow boundariesexpect(event).toBeTruthy();
// Completeexpect(event.bubbles).toBe(true);expect(event.composed).toBe(true);Related:
- Vitest Setup — Browser-mode Vitest configuration and shared test utils (
shadowQuery,fixture,cleanup) - Testing Form Components — ElementInternals, validation events
- Storybook Interaction Tests —
userEventinplay()functions