Mert Özen Further With Every Line
Testing & Quality Frontend Testing — #06

Component Harnesses: Using CDK Harnesses, Writing Your Own and Decoupling Tests from Markup

Mert Özen Nov 14, 2026 12 min 12 views
Component Harnesses: Using CDK Harnesses, Writing Your Own and Decoupling Tests from Markup

A component harness puts an API between the test and the DOM: when the markup changes, only the harness changes, not the tests. On Bookstore we show Angular Material's select and dialog harnesses, documentRootLoader and the animation setting, our own harness with HarnessPredicate for RatingInput, and when to choose a harness or Testing Library, all with real runs.

Version note: The code in this post was built and run with Angular 22.2 (TypeScript 6.0), Angular Material 22.2 and @angular/cdk 22.2, Vitest 5.1, jsdom 30 and @testing-library/angular 19.6 on Node.js 24.

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, a customer who has received an order rates the book and the delivery with stars. The first version of the RatingInput component that collects the rating was made of clickable spans:

template: `
  <div class="stars" [attr.aria-label]="label()">
    @for (star of stars(); track star) {
      <span class="star" [class.filled]="star <= value()" (click)="select(star)">★</span>
    }
  </div>
`,

The component's own tests and the tests of the review form that uses it (ReviewForm) found the stars in the DOM:

it('fills the stars up to the clicked one', async () => {
  const fixture = await create();

  fixture.nativeElement.querySelectorAll('.star')[3].click();
  await fixture.whenStable();

  expect(fixture.nativeElement.querySelectorAll('.star.filled')).toHaveLength(4);
});

// review-form.spec.ts
function rate(label: string, stars: number): void {
  const group = document.querySelector(`[aria-label="${label}"]`)!;
  (group.querySelectorAll('.star')[stars - 1] as HTMLElement).click();
}

Because it could not be used with the keyboard and screen readers could not read the stars, the component was made accessible: the spans became buttons with role="radio", and changing the rating with the arrow keys was added. Nothing about the behavior changed, neither the rating selected on click nor the value the form sends:

template: `
  <div role="radiogroup" [attr.aria-label]="label()" (keydown)="onKeydown($event)">
    @for (star of stars(); track star) {
      <button
        type="button"
        role="radio"
        [attr.aria-checked]="star === value()"
        [attr.aria-label]="star === 1 ? '1 star' : star + ' stars'"
        [class.filled]="star <= value()"
        [tabIndex]="star === (value() || 1) ? 0 : -1"
        [disabled]="disabled()"
        (click)="select(star)"
      >
        ★
      </button>
    }
  </div>
`,

How many of the five tests in the two files fail? If the behavior is not broken, what are the failing tests telling you?

Answer: The Tests Knew the Markup, Not the Behavior

All five tests failed:

 ❯  bookstore-web  src/app/catalog/review-form.spec.ts (2 tests | 2 failed) 84ms
     × sends both ratings and the comment 72ms
     × asks for the missing rating 11ms
 ❯  bookstore-web  src/app/shared/rating-input.spec.ts (3 tests | 3 failed) 56ms
     × shows five empty stars by default 38ms
     × fills the stars up to the clicked one 9ms
     × ignores clicks when disabled 9ms

TypeError: Cannot read properties of undefined (reading 'click')
 ❯ rate src/app/catalog/review-form.spec.ts:8:47

Nothing is broken in the app; what broke is the tests' knowledge of the .star class and of the stars being spans. And that knowledge is spread over two files. In a real project where RatingInput is used in ten different forms, you would make the same fix in ten spec files. In post 4 we saw how Testing Library reduces this dependency by querying by role and name. In this post we look at Angular's own answer: component harnesses.

The Harness Idea: An API Between the Test and the DOM

A harness is a small class that sits between a component and the test. The test does not say "click the fourth star", it says setValue(4); the harness knows what that means in the DOM. When the markup changes, only the harness changes and the tests stay the same. The Angular documentation says harnesses are most useful for shared UI components, where tests would otherwise depend on the component's private DOM structure and CSS classes.

The infrastructure lives in the CDK, in the @angular/cdk/testing package. There are three parts:

  • ComponentHarness: the base class of harness classes. hostSelector defines which element it attaches to, and locatorFor and locatorForAll define what it finds inside.
  • HarnessLoader: the object that looks for harnesses on the page. getHarness returns the first match and throws if it finds none; getAllHarnesses returns all of them.
  • Harness environment: where the harness runs. In unit tests it is TestbedHarnessEnvironment; the CDK also ships a Selenium WebDriver environment, so the same harness can be used in an E2E test.

TestbedHarnessEnvironment runs change detection and waits for the app to become stable before every DOM read and after every interaction. In a zoneless Angular 22 app, the await fixture.whenStable() lines we wrote by hand in post 3 are not needed in tests that use harnesses.

Angular Material Harnesses: Select and Dialog

We added a sort selection to Bookstore's catalog page with Angular Material's mat-select, and a MatDialog that asks for confirmation before deleting a cart line. Material's DOM is not under your control: mat-select is not a native select, and its options live in an overlay appended to the end of the page. There is no guarantee that the next Material version will keep this structure. That is why Material publishes a harness for each of its components, in a separate entry point such as @angular/material/select/testing:

<mat-form-field>
  <mat-label>Sort by</mat-label>
  <mat-select [value]="sort()" (valueChange)="sort.set($event)">
    <mat-option value="relevance">Relevance</mat-option>
    <mat-option value="title">Title</mat-option>
    <mat-option value="price">Price: low to high</mat-option>
  </mat-select>
</mat-form-field>
it('sorts the books by price', async () => {
  const { fixture } = await render(CatalogPage, {
    providers: [provideHttpClientTesting()],
    deferBlockBehavior: DeferBlockBehavior.Manual,
  });
  TestBed.inject(HttpTestingController).expectOne('/api/books').flush([dune, hobbit, { ...emma, stock: 2 }]);
  const loader = TestbedHarnessEnvironment.loader(fixture);

  const sort = await loader.getHarness(MatSelectHarness.with({ label: 'Sort by' }));
  await sort.clickOptions({ text: 'Price: low to high' });

  expect(await sort.getValueText()).toBe('Price: low to high');
  const titles = screen.getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent);
  expect(titles).toEqual(['Emma', 'The Hobbit', 'Dune']);
});

clickOptions opens the select, finds the option in the overlay by its text and clicks it. The rest of the test uses Testing Library: the page's result is still read from the headings the user sees. The two work together in the same test without friction; the fixture in the result of Angular Testing Library's render() is all you need to create a harness loader.

The dialog has one difference: it does not open inside the component but in the overlay container under document.body. loader(fixture) searches only inside the fixture, so it cannot find the dialog. For elements outside the fixture you use documentRootLoader:

async function openRemoveDialog(): Promise<MatDialogHarness> {
  const { fixture } = await render(CartSummary);
  const cart = TestBed.inject(CartStore);
  cart.add(dune);
  cart.add(hobbit);

  await userEvent.setup().click(await screen.findByRole('button', { name: 'Remove Dune' }));
  return TestbedHarnessEnvironment.documentRootLoader(fixture).getHarness(MatDialogHarness);
}

it('removes the book after confirmation', async () => {
  const dialog = await openRemoveDialog();

  expect(await dialog.getTitleText()).toBe('Remove Dune?');
  await (await dialog.getHarness(MatButtonHarness.with({ text: 'Remove' }))).click();

  expect(TestBed.inject(CartStore).lines().map((line) => line.book.title)).toEqual(['The Hobbit']);
});

This test failed on its first run: after pressing "Remove" the cart still had two books. The cause is Material's closing animation. The dialog emits afterClosed() after the animation ends; in Material 22's source the closing duration is 75 ms and the wait is done with a plain setTimeout. The Angular stability the harness waits for does not cover this timer. The fix is to turn animations off in tests, as Material's own test helpers do. The providersFile we set up in post 1 is the right place for it, because the setting applies to every test:

// src/test-providers.ts
import { MATERIAL_ANIMATIONS } from '@angular/material/core';
import { localeProviders } from './app/locale';

export default [...localeProviders, { provide: MATERIAL_ANIMATIONS, useValue: { animationsDisabled: true } }];

Material's table has a MatTableHarness built the same way; getRows() gives the rows and getCellTextByIndex() the cell texts. Bookstore has no Material table yet, so we did not use it here.

Writing Your Own Harness: RatingInputHarness

RatingInput's harness gathers everything tests need to know about the component in one place. Here it is, written for the accessible markup:

import { BaseHarnessFilters, ComponentHarness, HarnessPredicate, parallel, TestKey } from '@angular/cdk/testing';

export interface RatingInputHarnessFilters extends BaseHarnessFilters {
  label?: string | RegExp;
  value?: number;
}

export class RatingInputHarness extends ComponentHarness {
  static hostSelector = 'app-rating-input';

  private readonly group = this.locatorFor('[role="radiogroup"]');
  private readonly stars = this.locatorForAll('[role="radio"]');

  static with(options: RatingInputHarnessFilters = {}): HarnessPredicate<RatingInputHarness> {
    return new HarnessPredicate(RatingInputHarness, options)
      .addOption('label', options.label, (harness, label) => HarnessPredicate.stringMatches(harness.getLabel(), label))
      .addOption('value', options.value, async (harness, value) => (await harness.getValue()) === value);
  }

  async getLabel(): Promise<string | null> {
    return (await this.group()).getAttribute('aria-label');
  }

  async getMax(): Promise<number> {
    return (await this.stars()).length;
  }

  async getValue(): Promise<number> {
    const stars = await this.stars();
    const checked = await parallel(() => stars.map((star) => star.getAttribute('aria-checked')));
    return checked.indexOf('true') + 1;
  }

  async setValue(value: number): Promise<void> {
    const stars = await this.stars();
    if (value < 1 || value > stars.length) {
      throw new Error(`RatingInput has ${stars.length} stars; cannot select ${value}.`);
    }
    await stars[value - 1].click();
  }

  async increase(): Promise<void> {
    await this.pressOnCurrentStar(TestKey.RIGHT_ARROW);
  }

  async decrease(): Promise<void> {
    await this.pressOnCurrentStar(TestKey.LEFT_ARROW);
  }

  async isDisabled(): Promise<boolean> {
    const [first] = await this.stars();
    return first.getProperty<boolean>('disabled');
  }

  private async pressOnCurrentStar(key: TestKey): Promise<void> {
    const stars = await this.stars();
    const current = Math.max((await this.getValue()) - 1, 0);
    await stars[current].sendKeys(key);
  }
}

A few details matter. locatorFor and locatorForAll do not find the element right away; they return a function that finds it when called, so the harness always reads the current state of the DOM. The returned elements are of type TestElement: they have async methods such as click, sendKeys, getAttribute, getProperty and text, all independent of the environment. There is no nativeElement or document.querySelector in the harness, which is why the same class can also run in the WebDriver environment. parallel resolves the five getAttribute calls in one go and merges the change detection rounds between them. Every public method returns a Promise, because in an E2E environment every DOM read is a call to the browser.

The component's own tests now talk only to the harness:

async function create(): Promise<RatingInputHarness> {
  fixture = TestBed.createComponent(RatingInput);
  fixture.componentRef.setInput('label', 'Rate the book');
  return TestbedHarnessEnvironment.harnessForFixture(fixture, RatingInputHarness);
}

it('moves with the arrow keys and stops at the last star', async () => {
  const rating = await create();
  await rating.setValue(4);

  await rating.increase();
  await rating.increase();
  expect(await rating.getValue()).toBe(5);

  await rating.decrease();
  expect(await rating.getValue()).toBe(4);
});

harnessForFixture gives you a harness for the fixture's root element directly; when testing the component itself you do not need a loader.

Filtering with HarnessPredicate, and What Happens When the Markup Changes

The review form has two RatingInputs: "Rate the book" and "Rate the delivery". getHarness(RatingInputHarness) returns the first one it finds; which one depends on the DOM order. The static with() method on the harness builds a HarnessPredicate and lets the test pick a harness by its label or value. The with({ label: 'Sort by' }) option of the Material harnesses is the same mechanism:

it('sends both ratings and the comment', async () => {
  const { loader, send } = await setup();
  const user = userEvent.setup();

  await (await loader.getHarness(RatingInputHarness.with({ label: 'Rate the book' }))).setValue(4);
  await (await loader.getHarness(RatingInputHarness.with({ label: 'Rate the delivery' }))).setValue(5);
  await user.type(screen.getByLabelText('Comment'), 'Arrived well packed.');
  await user.click(screen.getByRole('button', { name: 'Send review' }));

  expect(send).toHaveBeenCalledExactlyOnceWith({ book: 4, delivery: 5, comment: 'Arrived well packed.' });
});

it('asks for the missing rating', async () => {
  const { loader, send } = await setup();

  await (await loader.getHarness(RatingInputHarness.with({ label: 'Rate the book' }))).setValue(4);
  await userEvent.setup().click(screen.getByRole('button', { name: 'Send review' }));

  const unrated = await loader.getAllHarnesses(RatingInputHarness.with({ value: 0 }));
  expect(await Promise.all(unrated.map((rating) => rating.getLabel()))).toEqual(['Rate the delivery']);
  expect(await screen.findByText('Rate the delivery.')).toBeInTheDocument();
  expect(send).not.toHaveBeenCalled();
});

RatingInput implements Signal Forms' FormValueControl<number> interface: value is a model(), disabled is an input and touch is an output. That is why it binds in the form with [formField]="reviewForm.book", and the "Rate the delivery." error appears on its own when the form is submitted. Because the harness's click is a real DOM event, the form's value really changes in the test.

The real payoff showed up at the next markup change. The team decided to use the browser's own radio inputs instead of buttons with role="radio": fieldset and legend carry the group's name, and visually hidden input type="radio" elements carry the stars. Running the tests without touching them, all six tests that use the harness failed. This time the error messages came from the harness:

Error: RatingInput has 0 stars; cannot select 4.
AssertionError: expected +0 to be 5 // Object.is equality

The fix is only in the harness, five lines:

private readonly legend = this.locatorFor('legend');
private readonly stars = this.locatorForAll('input[type="radio"]');

async getLabel(): Promise<string | null> {
  return (await this.legend()).text();
}

// inside getValue
const checked = await parallel(() => stars.map((star) => star.getProperty<boolean>('checked')));
return checked.indexOf(true) + 1;

The six tests are green again. None of the spec files changed; the RatingInput in the snapshot is this last version.

Harness or Testing Library?

Switching to radio inputs did one more thing: the component's accessible structure became clear, and Testing Library's role queries can now do the same job without a harness:

it('can also be rated with role queries', async () => {
  const { send } = await setup();
  const user = userEvent.setup();

  await user.click(within(screen.getByRole('group', { name: 'Rate the book' })).getByRole('radio', { name: '4 stars' }));
  await user.click(within(screen.getByRole('group', { name: 'Rate the delivery' })).getByRole('radio', { name: '5 stars' }));
  await user.click(screen.getByRole('button', { name: 'Send review' }));

  expect(send).toHaveBeenCalledExactlyOnceWith({ book: 4, delivery: 5, comment: '' });
});

This test is also independent of the markup; it depends only on accessible names. So how do you choose between the two? This is how Bookstore splits it:

SituationChoiceWhy
Your own component, with clear accessible rolesTesting Libraryno extra code, the test reads what the user sees
A component from Angular Material or another librarythe library's harnessthe overlay and internal DOM are not under your control
A shared UI component used in many placesyour own harnessinteraction knowledge in one place, typed values
A state you cannot express with roles (selected value, open or closed)harnessmethods like getValue() hide the DOM details
The same interaction is needed in E2Eharnessit also runs in the WebDriver environment

They are not alternatives to each other. In the catalog test above we changed the select with a harness and read the result with a role query. The general rule: express what the user does and sees with Testing Library, and express interactions that require knowing a component's internals with a harness.

In Production

Ship the harness with the component. If RatingInput later moves to a separate UI library, its harness should go into a testing entry point, as Material does; for now ours is at src/app/shared/testing/rating-input-harness.ts. Teams using the library import the same harness in their tests, and when the component's markup changes, the library's owner updates the harness. The harness file should not use Vitest globals such as vi or expect, and should not depend on any test tool other than @angular/cdk/testing. That way it compiles in the application build and works with different test runners.

Harnesses have a cost. Every read and interaction runs change detection and waits for stability. In the verbose output the first test of each file, including the first load of the component and Material, took 56–254 ms; the following ones took 5–50 ms. In the review form, the second harness test took 30 ms and the role-query test 51 ms; the difference is not measurable. The suite has 18 files and 66 tests and took 1.58–1.84 seconds over three runs. In tests that do many reads, parallel cuts unnecessary stability rounds.

Do not forget to turn off Material animations. Components such as dialog, bottom sheet and snack bar use timers when closing; with animations on, tests may pass one time and fail the next.

Common Pitfalls

Forgetting await. Every harness method returns a Promise. Without await in front of rating.setValue(4), the interaction was cut off before the test ended:

AssertionError: expected Promise{…} to be 4 // Object.is equality
- Expected:
4
+ Received:
Promise {}

Error: Harness is attempting to use a fixture that has already been destroyed.

The second error comes from the click that was not awaited trying to run after the test had finished; Vitest reported it as an "Unhandled Rejection".

Looking for an overlay harness with the fixture loader. When I looked for the dialog with TestbedHarnessEnvironment.loader(fixture), I got this error:

Error: Failed to find element matching one of the following queries:
(MatDialogHarness with host element matching selector: ".mat-mdc-dialog-container")

Dialogs, menus, the select's option panel and tooltips open outside the fixture; use documentRootLoader for them.

Putting business logic in the harness. It is tempting to add a method like isValidReview() to RatingInputHarness or to compute the expected rating in the harness. A harness should only answer "how do you talk to this component"; the test decides what the right behavior is. Once the harness starts making decisions, a bug in the test disappears inside the harness and has to be fixed in two places. There should be no expect in a harness either.

Writing unnecessary harnesses for your own components. For a component used in a single form with clear roles, a harness is extra code that repeats what Testing Library already gives you. Save harnesses for components that are shared, used in many places, or complex inside.

A Small Experiment

★☆☆ Add a getStarLabels() method to RatingInputHarness and test that the labels "1 star", "2 stars" … are generated correctly.

★★☆ Try the remove confirmation in CartSummary with a test that closes the dialog with MatDialogHarness.close() instead of "Keep it". What value does afterClosed() give when the dialog is closed with the Escape key, and what happens to the cart?

★★★ Add a disabled option to RatingInputHarness.with(). Then change ReviewForm so that both RatingInputs are disabled while it submits, and test this using only the harness.

All the tests in this post still run in jsdom; jsdom has no layout, which is why we triggered the @defer (on viewport) block in post 3 by hand, and IntersectionObserver did not exist at all. In the next post we use Vitest browser mode to run some of these tests in real Chromium, Firefox and WebKit: an infinite-scroll book list, a sticky cart and visual comparison.