Writing Component Tests
apps/docs/src/content/docs/components/testing/vitest Click to copy apps/docs/src/content/docs/components/testing/vitest Writing Component Tests
Section titled “Writing Component Tests”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.
Testing Philosophy
Section titled “Testing Philosophy”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.
Core Principles
Section titled “Core Principles”- Deterministic — Tests never flake. No timing-dependent assertions. Use
oneEvent()andawait updateCompletefor async work. - Comprehensive — Test all properties, events, slots, keyboard interactions, form behavior, and accessibility.
- Isolated — Each test is independent. Use
afterEach(cleanup)to prevent test pollution. - Real Browser — Tests run in actual Chromium, not a DOM simulator.
- 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’sexemptionsmap.
Test Infrastructure
Section titled “Test Infrastructure”Vitest Configuration
Section titled “Vitest Configuration”hx-library uses Vitest browser mode with Playwright provider:
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
Test File Structure
Section titled “Test File Structure”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)Test Utilities
Section titled “Test Utilities”The test-utils.ts file provides five essential helpers for testing web components with Shadow DOM.
fixture()
Section titled “fixture()”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:
- Parses HTML string
- Appends element to persistent fixture container in
document.body - Waits for
updateComplete(Lit’s async render cycle) - 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
shadowQuery()
Section titled “shadowQuery()”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
nullif 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 typeconst btn = shadowQuery<HTMLButtonElement>(el, 'button');
// Query by CSS partconst input = shadowQuery(el, '[part="input"]');
// Query by classconst wrapper = shadowQuery(el, '.field__wrapper');
// Query by roleconst alert = shadowQuery(el, '[role="alert"]');shadowQueryAll()
Section titled “shadowQueryAll()”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
oneEvent()
Section titled “oneEvent()”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:
- Adds event listener with
{ once: true }option - Returns promise that resolves with event object when fired
- 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);cleanup()
Section titled “cleanup()”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
checkA11y()
Section titled “checkA11y()”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:
- Imports axe-core dynamically
- Runs WCAG 2.1 AA audit on shadow root or element
- 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 },});Test Categories
Section titled “Test Categories”Every component must cover these categories. Use nested describe() blocks for organization.
1. Rendering
Section titled “1. Rendering”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); });});2. Properties
Section titled “2. Properties”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); });});3. Events
Section titled “3. Events”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); });});4. Keyboard
Section titled “4. Keyboard”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(); });});5. Slots
Section titled “5. Slots”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('@'); });});6. Form
Section titled “6. Form”Test form-associated components (formAssociated, validation, reset, restore).
Tests to include:
formAssociatedstatic property is trueformgetter returns associated formformResetCallback()resets valueformStateRestoreCallback()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); });});7. Accessibility
Section titled “7. Accessibility”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(); } });});Advanced Testing Patterns
Section titled “Advanced Testing Patterns”Testing Reactive Properties
Section titled “Testing Reactive Properties”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'); });});Testing Methods
Section titled “Testing Methods”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); });});Testing Validation
Section titled “Testing Validation”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(); });});Testing Error States
Section titled “Testing Error States”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(); });});Testing CSS Parts
Section titled “Testing CSS Parts”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(); });});Testing Indeterminate State (Checkboxes)
Section titled “Testing Indeterminate State (Checkboxes)”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); });});Test Coverage Requirements
Section titled “Test Coverage Requirements”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.
Running Coverage
Section titled “Running Coverage”# 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%)What Counts Toward Coverage
Section titled “What Counts Toward Coverage”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)
Improving Coverage
Section titled “Improving Coverage”Low coverage usually means:
- Missing event tests (click, input, change, focus)
- Missing conditional branches (if/else, ternaries)
- Missing error state tests
- Missing keyboard interaction tests
- Missing validation tests
Example: Branch coverage
// This conditional has 2 branchesif (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});Mocking and Stubbing
Section titled “Mocking and Stubbing”Mocking Form Submission
Section titled “Mocking Form Submission”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);});Mocking Timers (Advanced)
Section titled “Mocking Timers (Advanced)”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();});Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: Not Waiting for updateComplete
Section titled “Pitfall 1: Not Waiting for updateComplete”// ❌ BAD: Reads DOM before update completesit('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 updateit('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});Pitfall 2: Not Using cleanup()
Section titled “Pitfall 2: Not Using cleanup()”// ❌ BAD: No cleanupimport { 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 cleanupimport { describe, it, expect, afterEach } from 'vitest';import { fixture, shadowQuery, oneEvent, cleanup } from '../../test-utils.js';
afterEach(cleanup);
describe('hx-button', () => { // Tests are isolated});Pitfall 3: Timing-Dependent Assertions
Section titled “Pitfall 3: Timing-Dependent Assertions”// ❌ BAD: Race conditionit('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 DOMit('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 shadowQueryit('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 testit('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);});Real-World Test Examples
Section titled “Real-World Test Examples”Complete hx-button.test.ts Structure
Section titled “Complete hx-button.test.ts Structure”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 });});Complete hx-text-input.test.ts Structure
Section titled “Complete hx-text-input.test.ts Structure”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 });});Running Tests
Section titled “Running Tests”Run All Tests
Section titled “Run All Tests”npm run test:libraryRun Tests in Watch Mode
Section titled “Run Tests in Watch Mode”npm run test:library -- --watchRun Tests for Specific Component
Section titled “Run Tests for Specific Component”npm run test:library -- hx-buttonRun Tests in UI Mode (Debug)
Section titled “Run Tests in UI Mode (Debug)”npm run test:library -- --uiOpens Vitest UI in browser for interactive debugging.
Run Tests with Coverage
Section titled “Run Tests with Coverage”npm run test:library -- --coverageSummary
Section titled “Summary”Writing tests for hx-library components requires discipline and attention to detail. Follow these rules:
- Always use
afterEach(cleanup)— Prevents test pollution - Use test utilities —
fixture(),shadowQuery(),oneEvent(),checkA11y() - Wait for async updates —
await el.updateCompleteafter property changes - Test all categories — Rendering, properties, events, keyboard, slots, form, accessibility
- No timing dependencies — Use
oneEvent()for events, not manual listeners - 80% coverage minimum — Enforced in CI
- Test disabled state — Events must NOT fire when disabled
- 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.