Skip to content
HELiX

Testing Form Components

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

HELIX form components use the Form Association API (ElementInternals) to participate natively in HTML forms. This means they appear in FormData, respond to form reset, and integrate with the browser’s constraint validation system — exactly like <input> and <select>. Testing this integration requires specific patterns that differ from testing ordinary components.

Every form-associated HELIX component must satisfy these contracts:

  1. static formAssociated = true — declares the element as form-associated
  2. attachInternals() — returns an ElementInternals instance; HELiX components attach lazily on first _internals access (via FormMixin in packages/hx-library/src/mixins/), not in the constructor
  3. setFormValue() — keeps the form data updated as the user interacts
  4. setValidity() — reports constraint violations to the browser
  5. formResetCallback() — called by the form element when it is reset
  6. formStateRestoreCallback() — called on back/forward navigation state restoration

Testing each of these points validates the component’s integration with the browser’s form infrastructure.

The formAssociated static property is the entry point. Without it, none of the other form integration APIs work. Access it through the custom elements registry:

import { describe, it, expect } from 'vitest';
import type { HelixTextInput } from './hx-text-input.js';
import './index.js';
it('has formAssociated = true', () => {
const ctor = customElements.get('hx-text-input') as unknown as {
formAssociated: boolean;
};
expect(ctor.formAssociated).toBe(true);
});

This does not require fixture() — it queries the registry directly and works synchronously.

attachInternals() is called in the constructor. The form getter on the component delegates to this._internals.form. When a component is not inside a <form>, form returns null:

import { fixture, cleanup } from '../../test-utils.js';
import type { HelixTextInput } from './hx-text-input.js';
import './index.js';
afterEach(cleanup);
it('form getter returns null when not inside a form', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input></hx-text-input>');
expect(el.form).toBe(null);
});

When the component is placed inside a <form>, form returns the associated form element:

it('form getter returns the containing 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);
});

Note that fixture() appends to the fixture container, not to a form. For form association tests, build the form manually and append to test-fixture-container so cleanup() can remove it.

Testing Form Value Submission via FormData

Section titled “Testing Form Value Submission via FormData”

The most important form association test: does the component’s value appear in FormData when the form is submitted?

it('value appears in FormData on form submission', async () => {
const form = document.createElement('form');
form.innerHTML = '<hx-text-input name="patientName"></hx-text-input>';
document.getElementById('test-fixture-container')!.appendChild(form);
const el = form.querySelector('hx-text-input') as HelixTextInput;
await el.updateComplete;
// Set the component's value programmatically
el.value = 'Jane Doe';
await el.updateComplete;
// Capture FormData directly — no need to submit
const data = new FormData(form);
expect(data.get('patientName')).toBe('Jane Doe');
});

For a complete end-to-end form test, listen for the submit event and extract FormData from it:

it('participates in form submission with correct value', async () => {
const form = document.createElement('form');
form.innerHTML = `
<hx-text-input name="mrn" value="PAT-2026-00482"></hx-text-input>
<hx-text-input name="email" value="jane@hospital.org"></hx-text-input>
<button type="submit">Submit</button>
`;
document.getElementById('test-fixture-container')!.appendChild(form);
// Wait for both components to render
const inputs = form.querySelectorAll('hx-text-input');
await Promise.all(Array.from(inputs).map((el) => (el as HelixTextInput).updateComplete));
let submittedData: Record<string, FormDataEntryValue> = {};
form.addEventListener('submit', (e: SubmitEvent) => {
e.preventDefault();
const fd = new FormData(form);
submittedData = Object.fromEntries(fd.entries());
});
form.querySelector('button')!.click();
await new Promise((r) => setTimeout(r, 50));
expect(submittedData['mrn']).toBe('PAT-2026-00482');
expect(submittedData['email']).toBe('jane@hospital.org');
});

hx-text-input calls this._internals.setValidity() to register constraint violations. Test validity through the public validity, checkValidity(), and reportValidity() APIs — not by reaching into _internals.

it('validity.valueMissing is true when required and empty', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
expect(el.validity.valueMissing).toBe(true);
});
it('checkValidity returns false when required and 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 and filled', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input required value="filled"></hx-text-input>',
);
expect(el.checkValidity()).toBe(true);
});

When a component is invalid, validationMessage must be a non-empty string:

it('validationMessage is set when required and empty', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
await el.updateComplete;
expect(el.validationMessage).toBeTruthy();
expect(el.validationMessage.length).toBeGreaterThan(0);
});
it('custom error message appears in validationMessage', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input required error="MRN is required."></hx-text-input>',
);
await el.updateComplete;
expect(el.validationMessage).toBe('MRN is required.');
});

reportValidity() returns a boolean and triggers browser constraint validation UI:

it('reportValidity returns false when required and empty', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
expect(el.reportValidity()).toBe(false);
});
it('reportValidity returns true when required and filled', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input required value="has-a-value"></hx-text-input>',
);
expect(el.reportValidity()).toBe(true);
});

After a required field receives a value, the valueMissing flag must clear:

it('validity clears when required field receives a value', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
expect(el.validity.valueMissing).toBe(true);
el.value = 'now filled';
await el.updateComplete;
expect(el.validity.valueMissing).toBe(false);
expect(el.checkValidity()).toBe(true);
});

formResetCallback() is called by the browser when the owning form is reset. The component must clear its value and sync form data:

it('formResetCallback resets value to empty string', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input value="hello"></hx-text-input>');
expect(el.value).toBe('hello');
el.formResetCallback();
await el.updateComplete;
expect(el.value).toBe('');
});

More realistic: test that a native form reset triggers formResetCallback:

it('value resets when the parent form is reset', async () => {
const form = document.createElement('form');
form.innerHTML = '<hx-text-input name="firstName" value="Jane"></hx-text-input>';
document.getElementById('test-fixture-container')!.appendChild(form);
const el = form.querySelector('hx-text-input') as HelixTextInput;
await el.updateComplete;
expect(el.value).toBe('Jane');
form.reset();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 50));
expect(el.value).toBe('');
// Also verify FormData is cleared
const data = new FormData(form);
expect(data.get('firstName')).toBe('');
});

When a form with multiple HELIX components is reset, all of them must clear:

it('all hx-text-input fields reset on form.reset()', async () => {
const form = document.createElement('form');
form.innerHTML = `
<hx-text-input name="a" value="Alice"></hx-text-input>
<hx-text-input name="b" value="Bob"></hx-text-input>
`;
document.getElementById('test-fixture-container')!.appendChild(form);
const [a, b] = Array.from(form.querySelectorAll('hx-text-input')) as HelixTextInput[];
await a.updateComplete;
await b.updateComplete;
form.reset();
await a.updateComplete;
await b.updateComplete;
await new Promise((r) => setTimeout(r, 50));
expect(a.value).toBe('');
expect(b.value).toBe('');
});

When a form is disabled (via <fieldset disabled>), all form-associated elements inside it should be disabled. The browser handles this for native elements; HELIX components that use ElementInternals also receive this automatically.

it('component is disabled inside a disabled fieldset', async () => {
const form = document.createElement('form');
form.innerHTML = `
<fieldset disabled>
<hx-text-input name="field"></hx-text-input>
</fieldset>
`;
document.getElementById('test-fixture-container')!.appendChild(form);
const el = form.querySelector('hx-text-input') as HelixTextInput;
await el.updateComplete;
// ElementInternals-associated elements respond to fieldset[disabled]
// Verify the native input inside the shadow DOM is disabled
const input = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
expect(input.disabled).toBe(true);
});

Testing Required Attribute and aria-required

Section titled “Testing Required Attribute and aria-required”

The required property must propagate to both the native input and the ARIA attribute:

it('required propagates to native input', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
const input = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
expect(input.required).toBe(true);
});
it('required is reflected to the native input via the required attribute', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
const input = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
// hx-text-input intentionally relies on native `required` semantics; it does
// not set aria-required="true" because the browser exposes the same state
// through the implicit ARIA mapping of the required attribute.
expect(input.required).toBe(true);
});
it('required adds asterisk marker to label', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input label="Email" required></hx-text-input>',
);
const marker = el.shadowRoot!.querySelector('.field__required-marker');
expect(marker).toBeTruthy();
expect(marker?.textContent).toBe('*');
});

Components accept a custom error prop that is displayed in the error container and exposed via the component’s visual + ARIA error surface. The error string is used as the setValidity() validation message only when the component is independently invalid (e.g. required + empty); for browser-default constraint messages on otherwise-valid input, the native validation message still wins.

it('custom error message is displayed in the error div', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input error="MRN format must be PAT-YYYY-NNNNN."></hx-text-input>',
);
const errorDiv = el.shadowRoot!.querySelector('[role="alert"]');
expect(errorDiv).toBeTruthy();
expect(errorDiv?.textContent?.trim()).toBe('MRN format must be PAT-YYYY-NNNNN.');
});
it('custom error sets aria-invalid="true" on native input', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input error="Invalid input"></hx-text-input>');
const input = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
expect(input.getAttribute('aria-invalid')).toBe('true');
});

Testing aria-invalid and aria-describedby Updates

Section titled “Testing aria-invalid and aria-describedby Updates”

aria-invalid on the internal native input is driven by the error property / error slot (presence + non-empty), not by every validity-state mutation — internal validity is also tracked via ElementInternals but the visible ARIA attribute follows the displayed error. Test the visible signal:

it('aria-invalid is not set when input is valid', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input label="Name"></hx-text-input>');
const input = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
expect(input.hasAttribute('aria-invalid')).toBe(false);
});
it('aria-invalid="true" is set when error prop is provided', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input error="Required"></hx-text-input>');
const input = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
expect(input.getAttribute('aria-invalid')).toBe('true');
});

When there is an error, aria-describedby on the native input must reference the error element’s id:

it('aria-describedby references the error element ID', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input error="Bad input"></hx-text-input>');
const input = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
const errorDiv = el.shadowRoot!.querySelector('.field__error')!;
const describedBy = input.getAttribute('aria-describedby');
expect(describedBy).toContain(errorDiv.id);
});

When there is no error but help text is present, aria-describedby must reference the help text element:

it('aria-describedby references the help text element ID', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input help-text="Enter your MRN"></hx-text-input>',
);
const input = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
const helpDiv = el.shadowRoot!.querySelector('.field__help-text')!;
const describedBy = input.getAttribute('aria-describedby');
expect(describedBy).toContain(helpDiv.id);
});

Error + Help Text — aria-describedby Composition

Section titled “Error + Help Text — aria-describedby Composition”

When error is set on hx-text-input, the error container is rendered with role="alert" and the slotted-help-text container is hidden (the help-text only renders when no error is active). aria-describedby on the internal native input references both the error ID and any present help-text/help-slot ID — they compose, with the error first:

it('error renders alert + aria-describedby composes error+help IDs', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input error="Bad" help-text="Some guidance"></hx-text-input>',
);
const input = el.shadowRoot!.querySelector<HTMLInputElement>('input')!;
const errorDiv = el.shadowRoot!.querySelector('[part="error"]');
expect(errorDiv).toBeTruthy();
expect(errorDiv?.getAttribute('role')).toBe('alert');
const describedBy = input.getAttribute('aria-describedby');
expect(describedBy).toContain(errorDiv!.id);
// help-text id is also referenced when help-text/slot is present
});

The constraint validation API (checkValidity, reportValidity, validity, validationMessage) is the standard interface for form validation. Test it as a complete contract:

describe('Constraint Validation API', () => {
it('checkValidity returns true for valid required input', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input required value="valid"></hx-text-input>',
);
expect(el.checkValidity()).toBe(true);
});
it('checkValidity returns false for invalid required input', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
expect(el.checkValidity()).toBe(false);
});
it('reportValidity returns true for valid required input', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input required value="valid"></hx-text-input>',
);
expect(el.reportValidity()).toBe(true);
});
it('reportValidity returns false for invalid required input', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
expect(el.reportValidity()).toBe(false);
});
it('validity.valueMissing is true for empty required input', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
expect(el.validity.valueMissing).toBe(true);
});
it('validity is valid when optional input is empty', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input></hx-text-input>');
expect(el.validity.valid).toBe(true);
});
it('validationMessage is non-empty when invalid', async () => {
const el = await fixture<HelixTextInput>('<hx-text-input required></hx-text-input>');
await el.updateComplete;
expect(el.validationMessage.length).toBeGreaterThan(0);
});
it('validationMessage is empty when valid', async () => {
const el = await fixture<HelixTextInput>(
'<hx-text-input required value="filled"></hx-text-input>',
);
await el.updateComplete;
expect(el.validationMessage).toBe('');
});
});

This test exercises the complete form lifecycle: render, fill, validate, submit, and reset.

it('complete form lifecycle — fill, validate, submit, reset', async () => {
const form = document.createElement('form');
form.innerHTML = `
<hx-text-input
name="patientName"
label="Patient Name"
required
></hx-text-input>
<hx-text-input
name="mrn"
label="MRN"
required
value="PAT-2026-00482"
></hx-text-input>
<button type="submit">Submit</button>
<button type="reset">Reset</button>
`;
document.getElementById('test-fixture-container')!.appendChild(form);
const [nameInput, mrnInput] = Array.from(
form.querySelectorAll('hx-text-input'),
) as HelixTextInput[];
await nameInput.updateComplete;
await mrnInput.updateComplete;
// 1. Empty required field is invalid
expect(nameInput.checkValidity()).toBe(false);
// 2. Pre-filled required field is valid
expect(mrnInput.checkValidity()).toBe(true);
// 3. Fill in the empty field
nameInput.value = 'Jane Doe';
await nameInput.updateComplete;
expect(nameInput.checkValidity()).toBe(true);
// 4. Submit — collect FormData
let submittedData: Record<string, FormDataEntryValue> = {};
form.addEventListener('submit', (e: SubmitEvent) => {
e.preventDefault();
submittedData = Object.fromEntries(new FormData(form).entries());
});
form.querySelector<HTMLButtonElement>('[type="submit"]')!.click();
await new Promise((r) => setTimeout(r, 50));
expect(submittedData['patientName']).toBe('Jane Doe');
expect(submittedData['mrn']).toBe('PAT-2026-00482');
// 5. Reset — all values cleared
form.querySelector<HTMLButtonElement>('[type="reset"]')!.click();
await nameInput.updateComplete;
await mrnInput.updateComplete;
await new Promise((r) => setTimeout(r, 50));
expect(nameInput.value).toBe('');
expect(mrnInput.value).toBe('');
});
ContractWhat to TestKey Assertion
formAssociatedStatic property existsctor.formAssociated === true
form getterReturns form or nullel.form === form / null
FormData participationValue in FormDatanew FormData(form).get(name) === value
setValidityvalidity flagsel.validity.valueMissing === true
checkValidity()Returns booleantrue / false per state
reportValidity()Returns booleantrue / false per state
validationMessageNon-empty when invalid.length > 0
formResetCallback()Value clearsel.value === '' after reset
Form reset propagationBrowser triggers resetform.reset() clears all fields
aria-invalidSet when errorinput.getAttribute('aria-invalid') === 'true'
aria-describedbyReferences error/help IDdescribedBy.includes(errorDiv.id)
Required asteriskMarker in label.field__required-marker exists

Related: