Component Harnesses
A harness puts a stable API in front of a component's DOM, so restructuring the template stops breaking every test that touched it
Modern Angular only No equivalent exists in the original 100 Days series. Written fresh for Angular v22.
Lead with this: A component harness gives you a stable testing API for a component — interact with its inputs, read its state, trigger its behaviors — without depending on its internal DOM structure. When the component is refactored, your tests don't break.
What it is
When you test a component by querying its internal DOM directly — querySelector('.mdc-button__label'), .triggerEventHandler('click', null) — your test is coupled to implementation details. Rename a CSS class, restructure a template, and every test that touched that DOM breaks. This is especially painful with third-party component libraries like Angular Material, which sometimes restructure their internal HTML between versions.
Component harnesses solve this by providing a stable, documented API that sits in front of the DOM. Instead of reaching into the component's HTML, you call harness.click(), harness.getValue(), harness.isDisabled() — methods that the harness author intentionally exposed and commits to keeping stable across versions.
Angular CDK ships harnesses for all Angular Material components. You can also write your own harnesses for custom components — the same API, the same stability contract.
The harness system lives in @angular/cdk/testing. It has no runtime cost — it's purely a testing utility.
How it works under the hood
Old approach — direct DOM querying
A test for a Material button dialog flow without harnesses looks like this:
// ❌ Direct DOM — coupled to Material's internal HTML structureit('opens dialog on click', () => { const btn = fixture.nativeElement.querySelector('.mat-mdc-button'); btn.click(); fixture.detectChanges();
// Dialog is in the document body — not inside the fixture const dialog = document.querySelector('.mat-mdc-dialog-container'); expect(dialog).toBeTruthy();
const title = document.querySelector('.mat-mdc-dialog-title'); expect(title?.textContent).toContain('Confirm');});Problems:
.mat-mdc-buttonand.mat-mdc-dialog-containerare Material's internal class names, not its public API. They changed significantly from Material v14 to v15 (MDC migration). Every test that used them broke.- The dialog renders outside the fixture root — you have to reach into
document.bodymanually. - No semantic meaning —
.mat-mdc-buttondoesn't tell you what button you're clicking.
New approach — harness API
The same test with harnesses:
// ✅ Harness API — decoupled from internal DOM structureit('opens dialog on click', async () => { const button = await loader.getHarness(MatButtonHarness.with({ text: 'Open Dialog' })); await button.click();
// documentRootLoader finds things rendered outside the fixture (overlays, dialogs) const dialog = await rootLoader.getHarness(MatDialogHarness); expect(await dialog.getTitleText()).toBe('Confirm');});How this works internally: TestbedHarnessEnvironment wraps the fixture and provides HarnessLoader instances. When you call loader.getHarness(MatButtonHarness), the loader queries the DOM using the hostSelector defined in MatButtonHarness (which is Angular Material's internal selector) and wraps the found element in a UnitTestElement. The UnitTestElement is an environment-agnostic wrapper that knows how to trigger events, read values, and interact with Angular's change detection in a way that works correctly in the test environment.
This abstraction layer is why harnesses work regardless of Angular Material's internal DOM changes — the harness class is updated alongside the component, so the method names stay stable even when the underlying HTML structure changes.
documentRootLoader — handling overlays and portals
Many Angular Material components render outside their host element — dialogs, tooltips, select dropdowns, date pickers all use the CDK Overlay and append to document.body. A HarnessLoader rooted at the fixture can't find these. That's what documentRootLoader is for:
// loader — searches inside the fixture's root elementconst loader = TestbedHarnessEnvironment.loader(fixture);
// rootLoader — searches the entire document (for overlays, dialogs, tooltips)const rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture);Basic usage
Setup
import { HarnessLoader } from '@angular/cdk/testing';import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
// Note: two different import paths — intentional// @angular/cdk/testing: environment-agnostic (HarnessLoader, ComponentHarness)// @angular/cdk/testing/testbed: TestBed-specific (TestbedHarnessEnvironment)describe('MyButtonDialogComponent', () => { let fixture: ComponentFixture<MyButtonDialogComponent>; let loader: HarnessLoader; // inside the fixture let rootLoader: HarnessLoader; // entire document — for overlays
beforeEach(async () => { await TestBed.configureTestingModule({ imports: [MyButtonDialogComponent, NoopAnimationsModule], }).compileComponents();
fixture = TestBed.createComponent(MyButtonDialogComponent); loader = TestbedHarnessEnvironment.loader(fixture); rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture); await fixture.whenStable(); });});Use NoopAnimationsModule (or provideNoopAnimations()) in harness tests.
Material animations are async — without disabling them, tests that interact
with animated components (dialogs, menus, tabs) may time out or behave
unexpectedly.
Using Angular Material harnesses
import { MatButtonHarness, MatInputHarness, MatSelectHarness, MatDialogHarness, MatCheckboxHarness,} from '@angular/material/...'; // each component has its own /testing entrypoint
it('fills and submits a form', async () => { // Get individual inputs const nameInput = await loader.getHarness(MatInputHarness.with({ selector: '#name' })); const roleSelect = await loader.getHarness(MatSelectHarness); const agreeCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'I agree' })); const submitBtn = await loader.getHarness(MatButtonHarness.with({ text: 'Submit' }));
// Interact through the harness API — no DOM selectors await nameInput.setValue('Alice'); await roleSelect.open(); await roleSelect.clickOptions({ text: 'Admin' }); await agreeCheckbox.check(); await submitBtn.click();
await fixture.whenStable(); expect(submitSpy).toHaveBeenCalledWith({ name: 'Alice', role: 'admin', agreed: true });});
it('shows success dialog after submission', async () => { // ... fill form ... await submitBtn.click();
// Dialog renders outside fixture — use rootLoader const dialog = await rootLoader.getHarness(MatDialogHarness); expect(await dialog.getTitleText()).toBe('Success!');
const closeBtn = await dialog.getHarness(MatButtonHarness.with({ text: 'Close' })); await closeBtn.click();
// Dialog gone expect(await rootLoader.hasHarness(MatDialogHarness)).toBeFalse();});HarnessLoader query methods
// Get the first matching harness — throws if none foundconst button = await loader.getHarness(MatButtonHarness);
// Get all matching harnessesconst buttons = await loader.getAllHarnesses(MatButtonHarness);expect(buttons.length).toBe(3);
// Check existence without throwingconst hasError = await loader.hasHarness(MatErrorHarness);expect(hasError).toBeTrue();
// Count matchesconst count = await loader.countHarnesses(MatChipHarness);expect(count).toBe(5);
// Get by indexconst secondOption = await loader.getHarnessAtIndex(MatOptionHarness, 1);HarnessPredicate — filtering harnesses
When you have multiple instances of a component, filter with .with():
// By text contentconst saveBtn = await loader.getHarness(MatButtonHarness.with({ text: 'Save' }));const cancelBtn = await loader.getHarness(MatButtonHarness.with({ text: 'Cancel' }));
// By CSS selectorconst disabledInput = await loader.getHarness( MatInputHarness.with({ selector: '[disabled]' }));
// By ancestor — find a button inside a specific sectionconst submitInFooter = await loader.getHarness( MatButtonHarness.with({ ancestor: 'footer' }));parallel() — running queries concurrently
import { parallel } from '@angular/cdk/testing';
// ❌ Sequential — three round-tripsconst mode = await progressBar.getMode();const value = await progressBar.getValue();const isIndeterminate = await progressBar.isIndeterminate();
// ✅ Concurrent — one batch operationconst [mode, value, isIndeterminate] = await parallel(() => [ progressBar.getMode(), progressBar.getValue(), progressBar.isIndeterminate(),]);parallel() runs all the harness queries in a single batch, which is faster
and avoids multiple change detection cycles between reads.
Writing a custom harness
Extend ComponentHarness and implement static hostSelector:
// rating.harness.tsimport { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';
export interface RatingHarnessFilters { maxRating?: number;}
export class RatingHarness extends ComponentHarness { // Must match the component's selector — the loader uses this to find instances static hostSelector = 'app-rating';
// Locators for internal elements — lazy, won't throw if missing private readonly stars = this.locatorForAll('[data-testid="star"]'); private readonly activeStars = this.locatorForAll('[data-testid="star"].active');
/** Gets the number of stars selected. */ async getRating(): Promise<number> { return (await this.activeStars()).length; }
/** Gets the total number of stars. */ async getMaxRating(): Promise<number> { return (await this.stars()).length; }
/** Selects a specific star (1-based). */ async setRating(stars: number): Promise<void> { const allStars = await this.stars(); await allStars[stars - 1].click(); }
/** Checks whether the component is disabled. */ async isDisabled(): Promise<boolean> { return (await this.host()).hasClass('disabled'); }
/** Filtering factory — create a HarnessPredicate */ static with(options: RatingHarnessFilters): HarnessPredicate<RatingHarness> { return new HarnessPredicate(RatingHarness, options) .addOption('maxRating', options.maxRating, async (harness, maxRating) => (await harness.getMaxRating()) === maxRating ); }}Consuming the custom harness:
it('starts at zero stars', async () => { const rating = await loader.getHarness(RatingHarness); expect(await rating.getRating()).toBe(0); expect(await rating.getMaxRating()).toBe(5);});
it('can select a rating', async () => { const rating = await loader.getHarness(RatingHarness); await rating.setRating(3); expect(await rating.getRating()).toBe(3);});
it('can find a 10-star rating by filter', async () => { const bigRating = await loader.getHarness(RatingHarness.with({ maxRating: 10 })); expect(await bigRating.getMaxRating()).toBe(10);});Real-world patterns
Pattern 1 — Harnesses with a dialog workflow
A complete test of a confirm-delete dialog using both loader (for the trigger)
and rootLoader (for the dialog, which renders in the overlay):
describe('DeleteUserComponent', () => { let loader: HarnessLoader; let rootLoader: HarnessLoader; let userService: jasmine.SpyObj<UserService>;
beforeEach(async () => { userService = jasmine.createSpyObj('UserService', ['delete']); userService.delete.and.returnValue(Promise.resolve());
await TestBed.configureTestingModule({ imports: [DeleteUserComponent, NoopAnimationsModule], providers: [{ provide: UserService, useValue: userService }], }).compileComponents();
const fixture = TestBed.createComponent(DeleteUserComponent); loader = TestbedHarnessEnvironment.loader(fixture); rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture); await fixture.whenStable(); });
it('deletes user on confirm', async () => { const deleteBtn = await loader.getHarness( MatButtonHarness.with({ text: 'Delete User' }) ); await deleteBtn.click();
// Dialog opens in overlay — use rootLoader const dialog = await rootLoader.getHarness(MatDialogHarness); expect(await dialog.getTitleText()).toContain('Delete');
const confirmBtn = await dialog.getHarness( MatButtonHarness.with({ text: 'Confirm' }) ); await confirmBtn.click();
expect(userService.delete).toHaveBeenCalled(); });
it('cancels without deleting', async () => { await (await loader.getHarness(MatButtonHarness.with({ text: 'Delete User' }))).click();
const dialog = await rootLoader.getHarness(MatDialogHarness); await (await dialog.getHarness(MatButtonHarness.with({ text: 'Cancel' }))).click();
expect(userService.delete).not.toHaveBeenCalled(); expect(await rootLoader.hasHarness(MatDialogHarness)).toBeFalse(); });});Pattern 2 — Testing a list of items with harnesses
it('renders the correct number of chips and allows removal', async () => { const chips = await loader.getAllHarnesses(MatChipHarness); expect(chips.length).toBe(3);
// Read text via harness const labels = await parallel(() => chips.map(c => c.getText())); expect(labels).toEqual(['Angular', 'TypeScript', 'RxJS']);
// Remove the first chip await chips[0].remove(); expect(await loader.countHarnesses(MatChipHarness)).toBe(2);});Common mistakes
Mistake 1 — Using the wrong loader for overlays
The most common harness bug: dialogs, menus, tooltips, date pickers all render
in document.body via CDK Overlay. Using loader (fixture-rooted) instead of
rootLoader (document-rooted) returns null or throws:
// ❌ Dialog renders outside the fixture — getHarness throws "not found"const dialog = await loader.getHarness(MatDialogHarness);
// ✅ documentRootLoader searches the whole documentconst rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture);const dialog = await rootLoader.getHarness(MatDialogHarness);The rule: if the component uses CdkOverlay (dialogs, selects, autocomplete,
menus, tooltips), use rootLoader.
Mistake 2 — Forgetting NoopAnimationsModule / provideNoopAnimations()
Material components with animations (dialogs, drawers, expansion panels) have async open/close sequences. Without disabling animations, harness operations may resolve before the animation completes:
// ❌ Real animations — dialog may not be "open" when harness checksTestBed.configureTestingModule({ imports: [MyComponent, BrowserAnimationsModule],});
// ✅ Noop animations — synchronous state transitions in testsTestBed.configureTestingModule({ imports: [MyComponent, NoopAnimationsModule], // or: providers: [provideNoopAnimations()]});Mistake 3 — Not awaiting harness methods
Every harness method is async. Missing an await silently passes on the
unresolved Promise:
// ❌ No await — assigns a Promise, not the value; assertion always passesconst text = loader.getHarness(MatInputHarness);expect(text).toBeTruthy(); // Promise is always truthy
// ✅ Await every harness callconst input = await loader.getHarness(MatInputHarness);const value = await input.getValue();expect(value).toBe('Alice');Mistake 4 — Mixing harness and direct DOM assertions on the same element
Harness methods trigger Angular's change detection internally. Mixing them with direct DOM reads can produce inconsistent state:
// ❌ Harness click triggers CD internally; direct DOM read may see stale stateawait button.click();expect(fixture.nativeElement.querySelector('.active')).toBeTruthy();
// ✅ Use harness for everything, or flush manually before raw DOM readawait button.click();await fixture.whenStable();expect(fixture.nativeElement.querySelector('.active')).toBeTruthy();How this evolved
-
Angular 9 / Material 9 (2020): Component harnesses introduced in
@angular/cdk/testing. All Angular Material components got corresponding harnesses. Two environments shipped:TestbedHarnessEnvironment(unit/ integration tests) andProtractorHarnessEnvironment(E2E with Protractor). -
Angular 12 (2021): Material underwent the MDC migration — internal DOM restructured to use Material Design Components primitives. Every direct DOM test using Material's internal CSS classes (
.mat-button,.mat-dialog-container) broke. Tests using harnesses were unaffected — the harnesses were updated alongside the components. This is the clearest real-world demonstration of why harnesses exist. -
Angular 15 (2022):
ProtractorHarnessEnvironmentdeprecated alongside Protractor. The CDK docs now recommend implementing a customHarnessEnvironmentfor Playwright or Cypress if E2E harness support is needed. -
Angular 22 (now): Harnesses are the recommended testing strategy for any Angular Material component. The harness API is stable and unchanged. The two environments remain
TestbedHarnessEnvironment(unit/integration) andSeleniumWebDriverHarnessEnvironment(Selenium WebDriver). Writing custom harnesses for your own component library is considered best practice for any shared component library team.
See also
- Unit Tests —
TestBedsetup that harnesses plug into - Integration Tests — component trees where harnesses replace direct DOM queries
- Angular Material — the component library whose harnesses you'll use most
- Official docs — Using component harnesses
- Official docs — Creating component harnesses
- Material harness catalog — every Material component lists its harness and available query options