Skip to main content
Neural UIv2.0.1Documentation
View v1 GitHub

Testing

Test your Neural UI v2 application with public CDK harnesses, Vitest, TestBed, Playwright and hydration helpers.

Imports and test environment

A harness is a test driver: it exposes operations such as setValue or click without making your application tests depend on internal CSS. Neural UI 2.0.1 exports eight Angular CDK harnesses from @neural-ui/core/testing. Import this entry point in test files, not in application components. No separate Neural testing package is required.

The examples below are complete standalone Angular host components and Vitest tests, run with Angular CLI's unit-test builder. They use the installed @angular/cdk/testing/testbed environment, matching the Angular/CDK versions of your application. Keep Angular's normal test setup; these examples do not need a second TestBed.initTestEnvironment call or a hand-written Zone.js setup.

example.spec.ts
import { TestBed } from '@angular/core/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { NeuButtonHarness, detectStableChanges } from '@neural-ui/core/testing';

Test forms and application state

Test the result owned by your application, as well as the rendered control. This host binds value and checked models, derives disabled from consent and saves through neuClick. The test drives Input, Checkbox, Switch and Button, then asserts the saved application output. In Core 2.0.1, NeuInputHarness.setValue writes the native value without dispatching input. The example explicitly dispatches that event on the native input and waits for Angular before asserting the model.

Use NeuButtonHarness.with({ text }) or a HarnessPredicate selector filter to distinguish several controls. A harness stabilizes Angular around its interactions. After changing a host signal directly, await fixture.whenStable() or use detectStableChanges(fixture); do not use arbitrary delays to make signals update. For Signal Forms, bind a real host form and verify validation, touched and disabled results too.

testing-controls.spec.ts
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { describe, expect, it } from 'vitest';
import { NeuButtonComponent } from '@neural-ui/core/button';
import { NeuInputComponent } from '@neural-ui/core/input';
import { NeuCheckboxComponent } from '@neural-ui/core/checkbox';
import { NeuSwitchComponent } from '@neural-ui/core/switch';
import {
  detectStableChanges,
  NeuButtonHarness,
  NeuInputHarness,
  NeuCheckboxHarness,
  NeuSwitchHarness,
} from '@neural-ui/core/testing';

@Component({
  imports: [NeuButtonComponent, NeuInputComponent, NeuCheckboxComponent, NeuSwitchComponent],
  template: `
    <neu-input label="Name" [(value)]="name" />
    <neu-checkbox label="Accept terms" [(checked)]="accepted" />
    <neu-switch label="Notifications" [(checked)]="notifications" />
    <button neu-button [disabled]="!accepted()" (neuClick)="saved.set(name())">Save</button>
    <output>{{ saved() }}</output>
  `,
})
class ProfileHost {
  readonly name = signal('');
  readonly accepted = signal(false);
  readonly notifications = signal(false);
  readonly saved = signal('');
}

describe('Profile integration with Neural harnesses', () => {
  it('updates application models and saves through neuClick', async () => {
    await TestBed.configureTestingModule({ imports: [ProfileHost] }).compileComponents();
    const fixture = TestBed.createComponent(ProfileHost);
    await detectStableChanges(fixture);
    const loader = TestbedHarnessEnvironment.loader(fixture);
    const name = await loader.getHarness(NeuInputHarness);
    const terms = await loader.getHarness(NeuCheckboxHarness);
    const notifications = await loader.getHarness(NeuSwitchHarness);
    const save = await loader.getHarness(NeuButtonHarness.with({ text: 'Save' }));

    expect(await save.isDisabled()).toBe(true);
    await name.setValue('Ada');
    // In Core 2.0.1 setValue writes the native value without an input event.
    const field = fixture.nativeElement.querySelector('neu-input input') as HTMLInputElement;
    field.dispatchEvent(new Event('input', { bubbles: true }));
    await fixture.whenStable();
    await name.blur();
    await terms.toggle();
    await notifications.toggle();
    expect(fixture.componentInstance.name()).toBe('Ada');
    expect(await name.getValue()).toBe('Ada');
    expect(await name.getLabel()).toBe('Name');
    expect(await name.getError()).toBeNull();
    expect(await terms.isChecked()).toBe(true);
    expect(await notifications.isChecked()).toBe(true);
    expect(await save.isDisabled()).toBe(false);
    await save.click();
    await fixture.whenStable();
    expect(fixture.nativeElement.querySelector('output').textContent).toBe('Ada');
  });

  it('does not submit while the application disables the action', async () => {
    await TestBed.configureTestingModule({ imports: [ProfileHost] }).compileComponents();
    const fixture = TestBed.createComponent(ProfileHost);
    await detectStableChanges(fixture);
    const loader = TestbedHarnessEnvironment.loader(fixture);
    await (await loader.getHarness(NeuButtonHarness)).click();
    expect(fixture.componentInstance.saved()).toBe('');
  });
});

Select and Dialog: overlays and proposals

Select options can live outside the fixture root. NeuSelectHarness searches its options from the document root internally. Open the control before reading getOptions; these are rendered options, not every item in a lazy or virtual data source. Use documentRootLoader when the harness host itself lives outside the fixture.

Dialog's open input belongs to your application. The close button emits closeRequested; calling harness.close() does not override your host state. This test rejects one request, accepts the next and checks both application state and rendered panel. Keep only the intended overlay open so document-wide option queries remain unambiguous.

testing-overlays.spec.ts
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { describe, expect, it } from 'vitest';
import { NeuSelectComponent } from '@neural-ui/core/select';
import { NeuDialogComponent } from '@neural-ui/core/modal';
import { detectStableChanges, NeuSelectHarness, NeuDialogHarness } from '@neural-ui/core/testing';

@Component({
  imports: [NeuSelectComponent, NeuDialogComponent],
  template: `
    <neu-select label="Destination" [options]="destinations" [(value)]="destination" />
    <neu-dialog title="Review destination" [open]="open()" (closeRequested)="onClose()">
      <p>Check your destination before continuing.</p>
    </neu-dialog>
  `,
})
class TravelHost {
  readonly destinations = ['Madrid', 'Lisbon', 'Paris'];
  readonly destination = signal<string | null>(null);
  readonly open = signal(false);
  readonly acceptClose = signal(false);
  onClose(): void {
    if (this.acceptClose()) this.open.set(false);
  }
}

describe('Neural overlays and application-owned state', () => {
  it('selects a real option from the document overlay', async () => {
    await TestBed.configureTestingModule({ imports: [TravelHost] }).compileComponents();
    const fixture = TestBed.createComponent(TravelHost);
    await detectStableChanges(fixture);
    const select = await TestbedHarnessEnvironment.loader(fixture).getHarness(NeuSelectHarness);
    await select.open();
    expect(await select.isOpen()).toBe(true);
    expect(await select.getOptions()).toEqual(['Madrid', 'Lisbon', 'Paris']);
    await select.selectOption('Lisbon');
    expect(fixture.componentInstance.destination()).toBe('Lisbon');
    expect(await select.getValueText()).toContain('Lisbon');
    await select.close();
    expect(await select.isOpen()).toBe(false);
  });

  it('closes Dialog only when the host accepts closeRequested', async () => {
    await TestBed.configureTestingModule({ imports: [TravelHost] }).compileComponents();
    const fixture = TestBed.createComponent(TravelHost);
    fixture.componentInstance.open.set(true);
    await detectStableChanges(fixture);
    const root = TestbedHarnessEnvironment.documentRootLoader(fixture);
    const dialog = await root.getHarness(NeuDialogHarness);
    expect(await dialog.isOpen()).toBe(true);
    expect(await dialog.getTitle()).toBe('Review destination');
    expect(await dialog.getBodyText()).toContain('Check your destination');
    await dialog.close();
    expect(fixture.componentInstance.open()).toBe(true);
    fixture.componentInstance.acceptClose.set(true);
    await dialog.close();
    await fixture.whenStable();
    expect(fixture.componentInstance.open()).toBe(false);
    expect(await dialog.isOpen()).toBe(false);
  });
});

Table and DateInput

NeuTableHarness reads rendered tbody rows and cells with zero-based indices. Header text can include interaction columns; virtual or paginated tables expose only their currently rendered rows. Verify query, selection or editing proposals through your host when testing those features.

NeuDateInputHarness.selectDay selects a visible, enabled day number; it does not choose a month or year. Set a deterministic initial CivilDate, locale and any limits before opening the calendar, then assert the resulting application model rather than a locale-dependent display string.

testing-data.spec.ts
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { describe, expect, it } from 'vitest';
import { NeuTableComponent, type NeuTableColumn } from '@neural-ui/core/table';
import { NeuDateInputComponent, civilDate, type CivilDate } from '@neural-ui/core/date-input';
import { detectStableChanges, NeuTableHarness, NeuDateInputHarness } from '@neural-ui/core/testing';

interface Customer {
  id: number;
  name: string;
}

@Component({
  imports: [NeuTableComponent, NeuDateInputComponent],
  template: `
    <neu-table [data]="customers" [columns]="columns" [rowKey]="rowKey" />
    <neu-date-input label="Delivery" locale="en-US" [(value)]="delivery" />
  `,
})
class DeliveryHost {
  readonly customers: readonly Customer[] = [
    { id: 1, name: 'Ada' },
    { id: 2, name: 'Linus' },
  ];
  readonly columns: readonly NeuTableColumn<Customer, 'name'>[] = [
    { id: 'name', field: 'name', header: 'Customer' },
  ];
  readonly rowKey = (customer: Customer) => customer.id;
  readonly delivery = signal<CivilDate | null>(civilDate('2026-10-15'));
}

describe('Neural data and date integration', () => {
  it('reads rendered table rows and zero-based cells', async () => {
    await TestBed.configureTestingModule({ imports: [DeliveryHost] }).compileComponents();
    const fixture = TestBed.createComponent(DeliveryHost);
    await detectStableChanges(fixture);
    const table = await TestbedHarnessEnvironment.loader(fixture).getHarness(NeuTableHarness);
    expect(await table.getRowCount()).toBe(2);
    expect(await table.getHeaderTexts()).toEqual(['Customer']);
    expect(await table.getCellText(0, 0)).toBe('Ada');
    expect(await table.getCellText(1, 0)).toBe('Linus');
  });

  it('chooses a day in the month established by the initial value', async () => {
    await TestBed.configureTestingModule({ imports: [DeliveryHost] }).compileComponents();
    const fixture = TestBed.createComponent(DeliveryHost);
    await detectStableChanges(fixture);
    const date = await TestbedHarnessEnvironment.loader(fixture).getHarness(NeuDateInputHarness);
    expect(await date.getDisplayValue()).not.toBe('');
    await date.open();
    expect(await date.isOpen()).toBe(true);
    await date.selectDay(16);
    expect(fixture.componentInstance.delivery()).toEqual(civilDate('2026-10-16'));
    await date.close();
    expect(await date.isOpen()).toBe(false);
  });
});

Public harness API

All interaction and read methods below return Promises; await them. The harnesses inherit Angular CDK's host and locator APIs. Only NeuButtonHarness adds the text predicate and its with factory. For another harness, pass new HarnessPredicate(NeuInputHarness, { selector: '...' }) to loader.getHarness; import HarnessPredicate from @angular/cdk/testing. There is no public harness for every Neural component. For other controls, use accessible roles and labels or write an application-specific CDK harness.

Harness
Public methods
Scope and behavior
NeuButtonHarnesswith({ text?, selector?, ancestor? }); getText(); isDisabled(); isLoading(); click(); focus()button[neu-button]. text accepts string or RegExp; loading reads aria-busy.
NeuInputHarnessgetValue(); setValue(value: string); getLabel(); getError(); isDisabled(); focus(); blur()neu-input. Label/error return string | null. In 2.0.1 setValue does not emit input: dispatch it explicitly to update the host model.
NeuCheckboxHarnessisChecked(); isDisabled(); toggle(); getLabel()neu-checkbox. Drives the native checkbox.
NeuSwitchHarnessisChecked(); isDisabled(); toggle(); getLabel()neu-switch. Reads checked and disabled from its native input.
NeuSelectHarnessopen(); close(); isOpen(); getValueText(); getOptions(); selectOption(text: string | RegExp)neu-select. Reads rendered options from the document; throws when an option is absent. close sends Escape.
NeuDateInputHarnessopen(); close(); isOpen(); getDisplayValue(); selectDay(day: number)neu-date-input. Selects an enabled visible day; throws if absent. Does not navigate months.
NeuTableHarnessgetRowCount(); getHeaderTexts(); getCellText(row: number, column: number)neu-table. Counts rendered tbody tr elements; cell indices start at 0 and missing cells throw.
NeuDialogHarnessisOpen(); getTitle(); getBodyText(); close()neu-dialog. Title/body may be null. close clicks the close button if present; the host decides whether to accept.

Helpers and hydration diagnostics

The snapshot utilities compare descendant structure and id/for/ARIA relationships. Capture the server DOM before client bootstrap and the same subtree after hydration when building a real SSR test. The small example below only proves that the helper detects relationship drift; it is not an SSR hydration test. Empty differences do not prove correct event listeners, focus, text, styling or application behavior.

The CVA collectors are low-level utilities for objects implementing NeuCvaLike. They replace the registered callback, so do not attach them to a live Angular form or assume that v2 Signal Forms controls implement CVA. dispatchKeyboard creates a synthetic keydown; use a real browser for Tab traversal, native default actions and touch behavior.

Export
Signature / result
Behavior
detectStableChanges<T>(fixture: ComponentFixture<T>): Promise<void>Calls detectChanges, then awaits whenStable.
dispatchKeyboard(target: EventTarget, key: string): KeyboardEventDispatches a bubbling, cancellable keydown and returns the event.
collectCvaChanges<T>(component: NeuCvaLike<T>): T[]Registers an onChange collector; returns the array populated by future callbacks.
collectCvaTouches(component: NeuCvaLike<unknown>): boolean[]Registers an onTouched collector; appends true on each callback.
captureNeuHydrationSnapshot(root: ParentNode): NeuHydrationSnapshotCaptures descendants, their structural paths, tags and id/for/ARIA attributes.
compareNeuHydrationSnapshots(server: NeuHydrationSnapshot, client: NeuHydrationSnapshot): string[]Returns structural and relationship differences; [] means those captured properties match.
isNeuHydrationError(message: unknown): booleanRecognizes NG05xx and hydration mismatch/error messages. Does not install an error handler.
NeuCvaLike<T>writeValue(value: T | null | undefined); registerOnChange(fn); registerOnTouched(fn); setDisabledState(isDisabled: boolean)Structural contract for CVA adapters; not a claim that all v2 controls implement CVA.
NeuTextHarnessFiltersBaseHarnessFilters & { text?: string | RegExp }Filter accepted by NeuButtonHarness.with.
NeuHydrationSnapshot / NeuHydrationElementSnapshotreadonly elements[] / path, tag, id, htmlFor, ariaControls, ariaDescribedBy, ariaLabelledBy, ariaActiveDescendantRead-only snapshot structures. Relationship values are string | null.
testing-utilities.spec.ts
import { describe, expect, it } from 'vitest';
import {
  captureNeuHydrationSnapshot,
  compareNeuHydrationSnapshots,
  dispatchKeyboard,
  isNeuHydrationError,
} from '@neural-ui/core/testing';

describe('Neural testing utilities', () => {
  it('detects drift in accessibility relationships', () => {
    const root = document.createElement('div');
    root.innerHTML = '<label for="name">Name</label><input id="name">';
    const before = captureNeuHydrationSnapshot(root);
    expect(compareNeuHydrationSnapshots(before, captureNeuHydrationSnapshot(root))).toEqual([]);
    root.querySelector('input')!.id = 'renamed';
    expect(compareNeuHydrationSnapshots(before, captureNeuHydrationSnapshot(root))).toContain(
      'input:1 id changed: name -> renamed',
    );
    expect(isNeuHydrationError('NG0500: Hydration Node Mismatch')).toBe(true);
    expect(isNeuHydrationError('Network request failed')).toBe(false);
  });

  it('dispatches a cancellable, bubbling keydown event', () => {
    const button = document.createElement('button');
    button.addEventListener('keydown', (event) => {
      if (event.key === 'Escape') event.preventDefault();
    });
    const event = dispatchKeyboard(button, 'Escape');
    expect(event.defaultPrevented).toBe(true);
    expect(event.bubbles).toBe(true);
  });
});

End-to-end testing with Playwright

Use Playwright for complete tasks, keyboard focus, responsive layouts and interactions after hydration. This executable example targets the real Spanish home of this showcase: it adds a priority task and clears the list. It uses public roles and accessible names, without internal Neural CSS selectors or sleeps. Adapt the route, labels and expected outcome to your own application.

Install and configure @playwright/test with its browsers in your E2E project, save this file as showcase.spec.ts and run npx playwright test showcase.spec.ts. By default it uses the public showcase; set SHOWCASE_URL to your local or preview origin to check that deployment. These browser tests do not use TestbedHarnessEnvironment: that environment belongs to Angular unit tests.

testing-browser.example.ts
import { expect, test } from '@playwright/test';

// Override SHOWCASE_URL to run against a local or preview deployment.
test.use({ baseURL: process.env['SHOWCASE_URL'] ?? 'https://ui.neuralui.org' });

test('adds a priority task in the Spanish showcase', async ({ page }) => {
  await page.goto('/v2/es');
  const rejectCookies = page.getByRole('button', { name: 'Rechazar', exact: true });
  if (await rejectCookies.isVisible()) await rejectCookies.click();

  await page
    .getByRole('textbox', { name: 'Nombre de la tarea', exact: true })
    .fill('Preparar publicaciรณn');
  const priority = page.getByRole('checkbox', { name: 'Marcar como prioritaria', exact: true });
  await priority.press('Space');
  await expect(priority).toBeChecked();
  await page.getByRole('button', { name: 'Aรฑadir tarea', exact: true }).click();
  const task = page.getByRole('listitem').filter({ hasText: 'Preparar publicaciรณn' });
  await expect(task).toContainText('Prioritaria');
  await page.getByRole('button', { name: 'Vaciar lista', exact: true }).click();
  await expect(task).toHaveCount(0);
});

Run and extend your tests

Put Angular tests in *.spec.ts files and run them with the application's Angular CLI test target. The shown tests use Vitest imports explicitly. Follow your existing project's runner setup rather than mixing its bootstrap with another runner. Keep unit tests independent of the public website; the separate Playwright example selects its own origin.

Extend each task with invalid input, disabled/readonly states, rejected proposals and keyboard behavior. Add real browser checks for focus restoration and narrow touch layouts, plus useful server HTML and post-hydration interaction for SSR applications. The verification guide describes release checks; the examples here show how to start writing your own tests.

Terminal
npx ng test

Public Types

Inspect the definitions of types linked on this page.