Skip to content
HELiX

Writing Component Tests

apps/docs/src/content/docs/components/testing/vitest Click to copy
Copied! apps/docs/src/content/docs/components/testing/vitest

Testing is not optional. In an enterprise healthcare environment, untested code is unshippable code. Every component in hx-library must have comprehensive test coverage before merge. This guide teaches you how to write deterministic, browser-based tests using Vitest browser mode, Playwright, and our custom test utilities.

hx-library uses real browser testing. No jsdom. No happy-dom. Tests run in Chromium via Playwright to ensure behavior matches production environments. This catches issues that synthetic environments miss: Shadow DOM edge cases, focus management, form participation, and ARIA implementation.

  1. Deterministic — Tests never flake. No timing-dependent assertions. Use oneEvent() and await updateComplete for async work.
  2. Comprehensive — Test all properties, events, slots, keyboard interactions, form behavior, and accessibility.
  3. Isolated — Each test is independent. Use afterEach(cleanup) to prevent test pollution.
  4. Real Browser — Tests run in actual Chromium, not a DOM simulator.
  5. Per-component coverage gate — The blocking floor today is 50% (lines/branches/functions/statements per component in packages/hx-library/coverage-config.json); 95% remains the aspirational target. A short list of components are temporarily exempted per the config’s exemptions map.

hx-library uses Vitest browser mode with Playwright provider:

packages/hx-library/vitest.config.ts
export default defineConfig({
test: {
browser: {
enabled: true,
provider: 'playwright',
headless: true,
instances: [{ browser: 'chromium' }],
},
include: ['src/components/**/*.test.ts'],
coverage: {
enabled: true,
include: ['src/components/**/*.ts'],
exclude: [
'src/components/**/*.test.ts',
'src/components/**/*.stories.ts',
'src/components/**/*.styles.ts',
'src/components/**/index.ts',
],
reporter: ['text', 'json-summary'],
reportsDirectory: '.cache/coverage',
},
},
});

Key settings:

  • Browser mode — Tests run in real Chromium
  • Headless — No visible browser window in CI
  • Coverage threshold — 50% per-component blocking floor (per coverage-config.json); 95% aspirational target
  • Exclusions — Styles, stories, and index files don’t count toward coverage

Every component has a co-located test file:

src/components/hx-button/
├── index.ts # Re-export
├── hx-button.ts # Component class
├── hx-button.styles.ts # Lit CSS tagged template
├── hx-button.stories.ts # Storybook stories
└── hx-button.test.ts # ← Test file (THIS FILE)

The test-utils.ts file provides five essential helpers for testing web components with Shadow DOM.

Creates a component, appends it to the DOM, and waits for Lit’s updateComplete lifecycle.

Type signature:

async function fixture<T extends HTMLElement>(html: string): Promise<T>;

Usage:

import { fixture } from '../../test-utils.js';
import type { HelixButton } from './hx-button.js';
import './index.js';
it('renders with shadow DOM', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
expect(el.shadowRoot).toBeTruthy();
});

What it does:

  1. Parses HTML string
  2. Appends element to persistent fixture container in document.body
  3. Waits for updateComplete (Lit’s async render cycle)
  4. Returns typed element reference

When to use:

  • Every test that needs a component instance
  • First line after it() block

Key details:

  • Returns fully initialized component (constructor + connectedCallback + first render complete)
  • Component remains in DOM until cleanup() is called
  • Type parameter ensures TypeScript knows component methods/properties

Query a single element inside a component’s shadow DOM.

Type signature:

function shadowQuery<T extends Element = Element>(host: HTMLElement, selector: string): T | null;

Usage:

it('exposes "button" CSS part', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
const btn = shadowQuery(el, '[part="button"]');
expect(btn).toBeTruthy();
});

What it does:

  • Returns host.shadowRoot.querySelector<T>(selector)
  • Returns null if element not found or shadow root doesn’t exist

When to use:

  • Querying internal DOM (button, input, label, etc.)
  • Checking CSS parts are exposed
  • Reading internal state (classes, attributes, text content)

Common patterns:

// Query by element type
const btn = shadowQuery<HTMLButtonElement>(el, 'button');
// Query by CSS part
const input = shadowQuery(el, '[part="input"]');
// Query by class
const wrapper = shadowQuery(el, '.field__wrapper');
// Query by role
const alert = shadowQuery(el, '[role="alert"]');

Query multiple elements inside a component’s shadow DOM.

Type signature:

function shadowQueryAll<T extends Element = Element>(host: HTMLElement, selector: string): T[];

Usage:

it('renders multiple option elements', async () => {
const el = await fixture<HelixSelect>(`
<hx-select>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</hx-select>
`);
const options = shadowQueryAll<HTMLOptionElement>(el, 'option');
expect(options).toHaveLength(3);
});

What it does:

  • Returns Array.from(host.shadowRoot.querySelectorAll<T>(selector))
  • Returns empty array if none found

When to use:

  • Testing lists (options, radio buttons, checkboxes)
  • Verifying slot content rendering
  • Counting elements

Returns a promise that resolves on the next occurrence of an event.

Type signature:

function oneEvent<T extends Event = Event>(
el: EventTarget,
eventName: string,
timeoutMs?: number, // defaults to 5000ms
): Promise<T>;
// Resolves on the next matching event; rejects with a timeout error if the
// event does not fire within `timeoutMs`.

Usage:

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();
});

What it does:

  1. Adds event listener with { once: true } option
  2. Returns promise that resolves with event object when fired
  3. Automatically removes listener after event fires

When to use:

  • Testing custom events (hx-click, hx-input, hx-change)
  • Verifying event properties (bubbles, composed, detail)
  • Async event assertions

Key patterns:

Basic event check:

const eventPromise = oneEvent(el, 'hx-click');
btn.click();
const event = await eventPromise;
expect(event).toBeTruthy();

Type-safe custom event:

const eventPromise = oneEvent<CustomEvent>(el, 'hx-input');
input.dispatchEvent(new Event('input', { bubbles: true }));
const event = await eventPromise;
expect(event.detail.value).toBe('hello');

Verify bubbles and composed:

const eventPromise = oneEvent<CustomEvent>(el, 'hx-change');
input.dispatchEvent(new Event('change', { bubbles: true }));
const event = await eventPromise;
expect(event.bubbles).toBe(true);
expect(event.composed).toBe(true);

Clears the fixture container between tests to prevent test pollution.

Type signature:

function cleanup(): void;

Usage:

import { afterEach } from 'vitest';
import { cleanup } from '../../test-utils.js';
afterEach(cleanup);

What it does:

  • Sets fixtureContainer.innerHTML = ''
  • Removes all elements created by fixture()
  • Ensures test isolation

When to use:

  • Always. Every test file must have afterEach(cleanup); after imports.

Why it’s critical: Without cleanup, elements from previous tests remain in the DOM:

  • Tests become dependent on execution order
  • Event listeners leak between tests
  • Form associations persist incorrectly
  • Memory usage grows unbounded

Runs axe-core WCAG 2.1 AA accessibility audit on a component.

Type signature:

async function checkA11y(
el: HTMLElement,
options?: { rules?: Record<string, { enabled: boolean }> },
): Promise<{ violations: AxeViolation[]; passes: AxePass[] }>;

Usage:

it('has no axe violations in default state', async () => {
const el = await fixture<HelixButton>('<hx-button>Click me</hx-button>');
const { violations } = await checkA11y(el);
expect(violations).toEqual([]);
});

What it does:

  1. Imports axe-core dynamically
  2. Runs WCAG 2.1 AA audit on shadow root or element
  3. Returns violations and passes

When to use:

  • Every component must have at least one axe test
  • Test all interactive states (default, disabled, error, checked, etc.)

Common patterns:

Test all variants:

it('has no axe violations for all variants', async () => {
for (const variant of ['primary', 'secondary', 'tertiary', 'danger', 'ghost', 'outline']) {
const el = await fixture<HelixButton>(`<hx-button variant="${variant}">Click me</hx-button>`);
const { violations } = await checkA11y(el);
expect(violations, `variant="${variant}" should have no violations`).toEqual([]);
el.remove();
}
});

Disable specific rules:

const { violations } = await checkA11y(el, {
rules: {
'color-contrast': { enabled: false }, // Skip contrast check
},
});

Every component must cover these categories. Use nested describe() blocks for organization.

Test that the component renders with correct structure and default state.

Tests to include:

  • Shadow DOM exists
  • CSS parts are exposed
  • Native elements render (button, input, etc.)
  • Default classes/attributes applied

Example:

describe('Rendering', () => {
it('renders with shadow DOM', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
expect(el.shadowRoot).toBeTruthy();
});
it('exposes "button" CSS part', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
const btn = shadowQuery(el, '[part="button"]');
expect(btn).toBeTruthy();
});
it('renders native <button> element', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
const btn = shadowQuery(el, 'button');
expect(btn).toBeInstanceOf(HTMLButtonElement);
});
it('applies default variant=primary class', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
const btn = shadowQuery(el, 'button')!;
expect(btn.classList.contains('button--primary')).toBe(true);
});
});

Test every public property (reflected attributes, reactive state, variants, sizes, disabled, etc.).

Tests to include:

  • Attribute reflection (does attribute sync to host?)
  • Class application (does variant/size apply correct CSS classes?)
  • Boolean properties (disabled, checked, required, etc.)
  • Enum properties (variant, size, type)

Example:

describe('Property: variant', () => {
it('reflects variant attr to host', async () => {
const el = await fixture<HelixButton>('<hx-button variant="secondary">Click</hx-button>');
expect(el.getAttribute('variant')).toBe('secondary');
});
it('applies secondary class', async () => {
const el = await fixture<HelixButton>('<hx-button variant="secondary">Click</hx-button>');
const btn = shadowQuery(el, 'button')!;
expect(btn.classList.contains('button--secondary')).toBe(true);
});
it('applies ghost class', async () => {
const el = await fixture<HelixButton>('<hx-button variant="ghost">Click</hx-button>');
const btn = shadowQuery(el, 'button')!;
expect(btn.classList.contains('button--ghost')).toBe(true);
});
});
describe('Property: disabled', () => {
it('sets native disabled attribute', async () => {
const el = await fixture<HelixButton>('<hx-button disabled>Click</hx-button>');
const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
expect(btn.disabled).toBe(true);
});
it('reflects disabled to the native button', async () => {
const el = await fixture<HelixButton>('<hx-button disabled>Click</hx-button>');
const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
// hx-button intentionally uses the native disabled attribute (and the
// implicit ARIA mapping that comes with it); it does NOT set
// aria-disabled="true" on the internal button.
expect(btn.disabled).toBe(true);
});
it('applies host opacity 0.5 via disabled attribute', async () => {
const el = await fixture<HelixButton>('<hx-button disabled>Click</hx-button>');
expect(el.hasAttribute('disabled')).toBe(true);
});
});

Test custom events (dispatch, bubbles, composed, detail, disabled suppression).

Tests to include:

  • Event fires on interaction (click, input, change, etc.)
  • Event has correct properties (bubbles, composed)
  • Event detail contains correct data
  • Event does NOT fire when disabled

Example:

describe('Events', () => {
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();
});
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);
});
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>(el, 'hx-click');
btn.click();
const event = await eventPromise;
expect(event.detail.originalEvent).toBeInstanceOf(MouseEvent);
});
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);
});
});

Test keyboard interaction (Enter, Space, Escape, Arrow keys).

Tests to include:

  • Enter activates buttons
  • Space activates buttons/checkboxes
  • Escape closes dialogs/modals
  • Arrow keys navigate lists/radio groups

Example:

describe('Keyboard', () => {
it('Enter activates native button', 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();
});
it('Space activates native button', 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();
});
});

Test slot content rendering (default slot, named slots, empty state).

Tests to include:

  • Default slot renders text
  • Default slot renders HTML
  • Named slots render (prefix, suffix, help-text, etc.)

Example:

describe('Slots', () => {
it('default slot renders text', async () => {
const el = await fixture<HelixButton>('<hx-button>Hello World</hx-button>');
expect(el.textContent?.trim()).toBe('Hello World');
});
it('default slot renders HTML', async () => {
const el = await fixture<HelixButton>('<hx-button><span class="icon">+</span> Add</hx-button>');
const span = el.querySelector('span.icon');
expect(span).toBeTruthy();
expect(span?.textContent).toBe('+');
});
it('prefix slot renders', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input><span slot="prefix">@</span></hx-text-input>',
);
const prefix = el.querySelector('[slot="prefix"]');
expect(prefix).toBeTruthy();
expect(prefix?.textContent).toBe('@');
});
});

Test form-associated components (formAssociated, validation, reset, restore).

Tests to include:

  • formAssociated static property is true
  • form getter returns associated form
  • formResetCallback() resets value
  • formStateRestoreCallback() restores value
  • Type=“submit” triggers form submission
  • Type=“reset” triggers form reset

Example:

describe('Form', () => {
it('has formAssociated=true', () => {
const ctor = customElements.get('hx-button') as unknown as { formAssociated: boolean };
expect(ctor.formAssociated).toBe(true);
});
it('has ElementInternals attached', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
expect(el.form).toBe(null); // null when not inside a form
});
it('form getter returns associated form', async () => {
const form = document.createElement('form');
form.innerHTML = '<hx-text-input name="test"></hx-text-input>';
document.getElementById('test-fixture-container')!.appendChild(form);
const el = form.querySelector('hx-text-input') as HelixTextInput;
await el.updateComplete;
expect(el.form).toBe(form);
});
it('formResetCallback resets value to empty', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input value="hello"></hx-text-input>');
el.formResetCallback();
await el.updateComplete;
expect(el.value).toBe('');
});
it('formStateRestoreCallback restores value', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input></hx-text-input>');
el.formStateRestoreCallback('restored');
await el.updateComplete;
expect(el.value).toBe('restored');
});
it('calls form.requestSubmit on type=submit click', async () => {
const form = document.createElement('form');
form.innerHTML = '<hx-button type="submit">Submit</hx-button>';
document.getElementById('test-fixture-container')!.appendChild(form);
const el = form.querySelector('hx-button') as HelixButton;
await el.updateComplete;
let submitted = false;
form.addEventListener('submit', (e) => {
e.preventDefault();
submitted = true;
});
const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
btn.click();
await new Promise((r) => setTimeout(r, 50));
expect(submitted).toBe(true);
});
});

Test ARIA attributes, focus management, and axe-core audits.

Tests to include:

  • aria-label, aria-labelledby, aria-describedby
  • aria-invalid when error present
  • aria-required when required
  • aria-disabled when disabled
  • No axe violations for all states

Example:

describe('Accessibility', () => {
it('aria-describedby references error ID when error set', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input error="Bad input"></hx-text-input>');
const input = shadowQuery<HTMLInputElement>(el, 'input')!;
const errorDiv = shadowQuery(el, '.field__error')!;
const describedBy = input.getAttribute('aria-describedby');
expect(describedBy).toContain(errorDiv.id);
});
it('aria-describedby references help text ID when helpText set', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input help-text="Some help"></hx-text-input>');
const input = shadowQuery<HTMLInputElement>(el, 'input')!;
const helpDiv = shadowQuery(el, '.field__help-text')!;
const describedBy = input.getAttribute('aria-describedby');
expect(describedBy).toContain(helpDiv.id);
});
it('no aria-invalid when no error', async () => {
const el = await fixture<HelixCheckbox>('<hx-checkbox></hx-checkbox>');
const input = shadowQuery<HTMLInputElement>(el, 'input')!;
expect(input.hasAttribute('aria-invalid')).toBe(false);
});
});
describe('Accessibility (axe-core)', () => {
it('has no axe violations in default state', async () => {
const el = await fixture<HelixButton>('<hx-button>Click me</hx-button>');
const { violations } = await checkA11y(el);
expect(violations).toEqual([]);
});
it('has no axe violations when disabled', async () => {
const el = await fixture<HelixButton>('<hx-button disabled>Click me</hx-button>');
const { violations } = await checkA11y(el);
expect(violations).toEqual([]);
});
it('has no axe violations for all variants', async () => {
for (const variant of ['primary', 'secondary', 'ghost']) {
const el = await fixture<HelixButton>(`<hx-button variant="${variant}">Click me</hx-button>`);
const { violations } = await checkA11y(el);
expect(violations, `variant="${variant}" should have no violations`).toEqual([]);
el.remove();
}
});
});

Reactive properties trigger async updates. Always wait for updateComplete.

describe('Property: value', () => {
it('syncs value to native input', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input value="hello"></hx-text-input>');
const input = shadowQuery<HTMLInputElement>(el, 'input')!;
expect(input.value).toBe('hello');
});
it('programmatic value update is reflected', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input></hx-text-input>');
el.value = 'updated';
await el.updateComplete; // Wait for Lit to process change
const input = shadowQuery<HTMLInputElement>(el, 'input')!;
expect(input.value).toBe('updated');
});
});

Public methods like focus(), select(), checkValidity(), reportValidity().

describe('Methods', () => {
it('focus() moves focus to native input', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input></hx-text-input>');
el.focus();
await new Promise((r) => setTimeout(r, 50)); // Allow focus to settle
const input = shadowQuery<HTMLInputElement>(el, 'input')!;
expect(el.shadowRoot?.activeElement).toBe(input);
});
it('select() selects text in native input', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input value="hello world"></hx-text-input>');
el.focus();
el.select();
await new Promise((r) => setTimeout(r, 50));
const input = shadowQuery<HTMLInputElement>(el, 'input')!;
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe('hello world'.length);
});
});

Test checkValidity(), reportValidity(), validity, and validationMessage.

describe('Validation', () => {
it('checkValidity returns false when required + empty', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
expect(el.checkValidity()).toBe(false);
});
it('checkValidity returns true when required + filled', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input required value="filled"></hx-text-input>',
);
expect(el.checkValidity()).toBe(true);
});
it('valueMissing validity flag is set when required + empty', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
expect(el.validity.valueMissing).toBe(true);
});
it('reportValidity returns false when required + empty', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
expect(el.reportValidity()).toBe(false);
});
it('validationMessage is set when required + empty', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
await el.updateComplete;
expect(el.validationMessage).toBeTruthy();
});
});

Error properties should render error message, set aria-invalid, and hide help text.

describe('Property: error', () => {
it('renders error message in role="alert" div', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input error="Required field"></hx-text-input>');
const errorDiv = shadowQuery(el, '[role="alert"]');
expect(errorDiv).toBeTruthy();
expect(errorDiv?.textContent?.trim()).toBe('Required field');
});
it('error container uses role="alert" (which implies aria-live=assertive)', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input error="Required"></hx-text-input>');
const errorDiv = shadowQuery(el, '[part="error"]');
expect(errorDiv).toBeTruthy();
expect(errorDiv?.getAttribute('role')).toBe('alert');
// role="alert" implies aria-live="assertive"; the component does not set
// a literal aria-live attribute, and adding aria-live="polite" alongside
// role="alert" would create a contradictory signal.
expect(errorDiv?.hasAttribute('aria-live')).toBe(false);
});
it('sets aria-invalid="true" on input', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input error="Required"></hx-text-input>');
const input = shadowQuery<HTMLInputElement>(el, 'input')!;
expect(input.getAttribute('aria-invalid')).toBe('true');
});
it('error hides help text', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input error="Error" help-text="Help"></hx-text-input>',
);
const helpText = shadowQuery(el, '.field__help-text');
expect(helpText).toBeNull();
});
});

Verify all CSS parts are exposed for theming.

describe('CSS Parts', () => {
it('label part exposed', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input label="Test"></hx-text-input>');
const label = shadowQuery(el, '[part="label"]');
expect(label).toBeTruthy();
});
it('input-wrapper part exposed', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input></hx-text-input>');
const wrapper = shadowQuery(el, '[part="input-wrapper"]');
expect(wrapper).toBeTruthy();
});
it('error part exposed when error set', async () => {
const el = await fixture<HelixCheckbox>('<hx-checkbox error="Error msg"></hx-checkbox>');
const errorPart = shadowQuery(el, '[part="error"]');
expect(errorPart).toBeTruthy();
});
});
describe('Property: indeterminate', () => {
it('applies indeterminate class when set', async () => {
const el = await fixture<HelixCheckbox>('<hx-checkbox></hx-checkbox>');
el.indeterminate = true;
await el.updateComplete;
const container = shadowQuery(el, '.checkbox');
expect(container?.classList.contains('checkbox--indeterminate')).toBe(true);
});
it('clears indeterminate on toggle', async () => {
const el = await fixture<HelixCheckbox>('<hx-checkbox></hx-checkbox>');
el.indeterminate = true;
await el.updateComplete;
const control = shadowQuery<HTMLElement>(el, '.checkbox__control')!;
control.click();
await el.updateComplete;
expect(el.indeterminate).toBe(false);
});
});

Blocking gate: 50% per-component (lines/branches/functions/statements). Per-component thresholds and exemptions live in packages/hx-library/coverage-config.json. The 95% aspirational target tracks the long-term aim.

Terminal window
# Run library tests WITH coverage (the workspace test:library script is
# coverage-off by default for fast loops; opt in via the filtered command)
pnpm --filter=@helixui/library run test:coverage
# Coverage output appears in terminal:
# ✓ src/components/hx-button/hx-button.ts (95.12%)
# ✓ src/components/hx-text-input/hx-text-input.ts (88.43%)

Included:

  • Component class files (hx-button.ts, hx-text-input.ts)
  • Public methods and properties
  • Event handlers
  • Lifecycle methods
  • Validation logic

Excluded:

  • Test files (*.test.ts)
  • Story files (*.stories.ts)
  • Style files (*.styles.ts)
  • Index re-exports (index.ts)

Low coverage usually means:

  1. Missing event tests (click, input, change, focus)
  2. Missing conditional branches (if/else, ternaries)
  3. Missing error state tests
  4. Missing keyboard interaction tests
  5. Missing validation tests

Example: Branch coverage

// This conditional has 2 branches
if (this.disabled) {
return; // Branch 1: disabled
}
this.dispatchEvent(new CustomEvent('hx-click')); // Branch 2: enabled
// Tests needed:
it('does NOT dispatch hx-click when disabled', async () => {
// Tests Branch 1
});
it('dispatches hx-click when enabled', async () => {
// Tests Branch 2
});
it('calls form.requestSubmit on type=submit click', async () => {
const form = document.createElement('form');
form.innerHTML = '<hx-button type="submit">Submit</hx-button>';
document.getElementById('test-fixture-container')!.appendChild(form);
const el = form.querySelector('hx-button') as HelixButton;
await el.updateComplete;
let submitted = false;
form.addEventListener('submit', (e) => {
e.preventDefault(); // Prevent actual submission
submitted = true;
});
const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
btn.click();
await new Promise((r) => setTimeout(r, 50)); // Allow event to propagate
expect(submitted).toBe(true);
});

If a component uses setTimeout or setInterval, consider using Vitest’s fake timers:

import { vi } from 'vitest';
it('updates after delay', async () => {
vi.useFakeTimers();
// hx-toast uses `open` (boolean) + `duration` (ms) for the auto-dismiss
// contract — there is no `visible` property. Assert on `open` instead.
const el = await fixture<HelixToast>('<hx-toast open duration="3000">Message</hx-toast>');
expect(el.open).toBe(true);
vi.advanceTimersByTime(3000);
await el.updateComplete;
expect(el.open).toBe(false);
vi.useRealTimers();
});

Mocking Observers (ResizeObserver, IntersectionObserver)

Section titled “Mocking Observers (ResizeObserver, IntersectionObserver)”
// Pattern only — HELiX does not ship hx-lazy-image. If you're testing a
// custom consumer-built component that uses IntersectionObserver, mock the
// constructor the same way:
it('responds to intersection', async () => {
const mockObserve = vi.fn();
const mockDisconnect = vi.fn();
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: mockObserve,
disconnect: mockDisconnect,
})) as unknown as typeof IntersectionObserver;
// Replace 'my-lazy-image' with the consumer component you're testing.
const el = await fixture<HTMLElement>('<my-lazy-image src="/image.jpg"></my-lazy-image>');
expect(mockObserve).toHaveBeenCalled();
el.remove();
expect(mockDisconnect).toHaveBeenCalled();
});
// ❌ BAD: Reads DOM before update completes
it('programmatic value update is reflected', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input></hx-text-input>');
el.value = 'updated';
const input = shadowQuery<HTMLInputElement>(el, 'input')!;
expect(input.value).toBe('updated'); // FAILS! DOM not updated yet
});
// ✅ GOOD: Wait for update
it('programmatic value update is reflected', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input></hx-text-input>');
el.value = 'updated';
await el.updateComplete; // Wait for Lit to process change
const input = shadowQuery<HTMLInputElement>(el, 'input')!;
expect(input.value).toBe('updated'); // PASSES
});
// ❌ BAD: No cleanup
import { describe, it, expect } from 'vitest';
import { fixture, shadowQuery, oneEvent } from '../../test-utils.js';
describe('hx-button', () => {
it('test 1', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
// Test logic...
});
it('test 2', async () => {
// Element from test 1 still in DOM! Can cause interference
});
});
// ✅ GOOD: Always cleanup
import { describe, it, expect, afterEach } from 'vitest';
import { fixture, shadowQuery, oneEvent, cleanup } from '../../test-utils.js';
afterEach(cleanup);
describe('hx-button', () => {
// Tests are isolated
});
// ❌ BAD: Race condition
it('dispatches hx-click on click', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
const btn = shadowQuery<HTMLButtonElement>(el, 'button')!;
let eventFired = false;
el.addEventListener('hx-click', () => {
eventFired = true;
});
btn.click();
expect(eventFired).toBe(true); // FLAKY! Event may not have fired yet
});
// ✅ GOOD: Use oneEvent()
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; // Wait for event
expect(event).toBeTruthy(); // RELIABLE
});

Pitfall 4: Querying Light DOM Instead of Shadow DOM

Section titled “Pitfall 4: Querying Light DOM Instead of Shadow DOM”
// ❌ BAD: Querying light DOM
it('renders button', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
const btn = el.querySelector('button'); // null! Button is in shadow DOM
expect(btn).toBeTruthy(); // FAILS
});
// ✅ GOOD: Use shadowQuery
it('renders button', async () => {
const el = await fixture<HelixButton>('<hx-button>Click</hx-button>');
const btn = shadowQuery(el, 'button'); // Correct!
expect(btn).toBeTruthy(); // PASSES
});

Pitfall 5: Not Testing Disabled State Event Suppression

Section titled “Pitfall 5: Not Testing Disabled State Event Suppression”

Every interactive component must test that events do NOT fire when disabled.

// ✅ Required test
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)); // Allow event propagation
expect(fired).toBe(false);
});
import { describe, it, expect, afterEach } from 'vitest';
import { fixture, shadowQuery, oneEvent, cleanup, checkA11y } from '../../test-utils.js';
import type { HelixButton } from './hx-button.js';
import './index.js';
afterEach(cleanup);
describe('hx-button', () => {
describe('Rendering', () => {
// 5 tests: shadow DOM, CSS parts, native button, default classes
});
describe('Property: variant', () => {
// 3 tests: reflection, class application for each variant
});
describe('Property: size', () => {
// 3 tests: class application for sm, md, lg
});
describe('Property: disabled', () => {
// 4 tests: native disabled, aria-disabled, host attribute, opacity
});
describe('Property: type', () => {
// 3 tests: default button, submit, reset
});
describe('Events', () => {
// 4 tests: dispatch, bubbles/composed, detail, disabled suppression
});
describe('Keyboard', () => {
// 2 tests: Enter, Space
});
describe('Slots', () => {
// 2 tests: text, HTML
});
describe('Form', () => {
// 4 tests: formAssociated, ElementInternals, submit, reset
});
describe('Accessibility (axe-core)', () => {
// 3 tests: default, disabled, all variants
});
});
import { describe, it, expect, afterEach } from 'vitest';
import { fixture, shadowQuery, oneEvent, cleanup, checkA11y } from '../../test-utils.js';
import type { HelixTextInput } from './hx-text-input.js';
import './index.js';
afterEach(cleanup);
describe('hx-text-input', () => {
describe('Rendering', () => {
// 4 tests: shadow DOM, native input, CSS parts
});
describe('Property: label', () => {
// 3 tests: renders, empty state, required asterisk
});
describe('Property: placeholder', () => {
// 1 test: sets placeholder attr
});
describe('Property: value', () => {
// 2 tests: initial value, programmatic update
});
describe('Property: type', () => {
// 8 tests covering the full hx-text-input type union:
// text | email | password | tel | url | search | number | date
});
describe('Property: required', () => {
// 2 tests: native required, aria-required
});
describe('Property: disabled', () => {
// 2 tests: native disabled, host attribute
});
describe('Property: error', () => {
// 4 tests: renders alert, aria-live, aria-invalid, hides help
});
describe('Property: helpText', () => {
// 2 tests: renders, hidden when error
});
describe('Events', () => {
// 4 tests: hx-input, detail.value, hx-change, bubbles/composed
});
describe('Slots', () => {
// 3 tests: prefix, suffix, help-text
});
describe('CSS Parts', () => {
// 2 tests: label, input-wrapper
});
describe('Form', () => {
// 5 tests: formAssociated, form getter, reset, restore
});
describe('Validation', () => {
// 6 tests: checkValidity, validity flags, reportValidity, validationMessage
});
describe('Methods', () => {
// 2 tests: focus(), select()
});
describe('aria-describedby', () => {
// 2 tests: references error ID, references help ID
});
describe('Accessibility (axe-core)', () => {
// 4 tests: default, error, disabled, required
});
});
Terminal window
npm run test:library
Terminal window
npm run test:library -- --watch
Terminal window
npm run test:library -- hx-button
Terminal window
npm run test:library -- --ui

Opens Vitest UI in browser for interactive debugging.

Terminal window
npm run test:library -- --coverage

Writing tests for hx-library components requires discipline and attention to detail. Follow these rules:

  1. Always use afterEach(cleanup) — Prevents test pollution
  2. Use test utilitiesfixture(), shadowQuery(), oneEvent(), checkA11y()
  3. Wait for async updatesawait el.updateComplete after property changes
  4. Test all categories — Rendering, properties, events, keyboard, slots, form, accessibility
  5. No timing dependencies — Use oneEvent() for events, not manual listeners
  6. 80% coverage minimum — Enforced in CI
  7. Test disabled state — Events must NOT fire when disabled
  8. Test accessibility — Every component needs axe-core tests

Golden rule: If it’s a public API (property, method, event, slot, CSS part), it must have a test.