Changing how a component is built internally – wrapping an element in a div for styling purposes, renaming a CSS class, or reorganizing the DOM – shouldn’t affect its behavior. Yet in large Angular codebases, such changes routinely break tests that have nothing to do with the modified component.
You fix one thing, then find yourself chasing failures in a dozen unrelated test files – possibly for code you don’t even own.
The same problem shows up with shared UI building blocks: checkboxes, tooltips, dropdowns. Testing them usually means digging into their internals to figure out the right selectors, and since these components are reused everywhere, a single refactor can ripple through tests across the whole project.
Component harnesses, shipped as part of the Angular CDK, exist precisely to address this. They let tests drive a component the way a user would – clicking, typing, reading text – with no reliance on implementation details.
If Angular Material is part of your stack, you’ve likely already met harnesses: the library ships a test harness for every one of its components.
The issue harnesses address
Consider a typical brittle test:
it('should show an error on invalid zip code format', () => {
const input = fixture.nativeElement.querySelector('.zip-code input');
input.value = 'abc';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
const error = fixture.nativeElement.querySelector('.zip-code .error-message');
expect(error.textContent).toContain('Invalid zip code');
});
This test is coupled to implementation details: it assumes particular class names and a specific DOM layout.
If the component’s author alters those details, the test fails – even though the component still does exactly what a user expects. That’s the pain point from the opening: an internal refactor shouldn’t require you to repair tests for unchanged behavior.
Rewritten with a harness, the same test reads much more cleanly:
it('should show an error on invalid zip code format', async () => {
const zipCodeHarness = await loader.getHarness(
FormControlHarness.with({ selector: '.zip-code' })
);
await zipCodeHarness.setValue('abc');
expect(await zipCodeHarness.getErrorText()).toContain('Invalid zip code');
});
The test now talks to the component through a stable API built for that purpose. Internal changes no longer propagate into every test that uses the component, provided the harness API keeps exposing the same behavior.
The concept isn’t new – it’s the Page Object Pattern from end-to-end testing, applied to a single Angular component. Placing an abstraction layer between your tests and the DOM buys you:
- High Maintainability: When the component’s implementation shifts, you adjust its harness once instead of hunting down the same selectors through dozens of test files.
- Code Reusability: Shared interactions become easy to reuse across test suites.
- Readable Tests: Tests express high-level actions and expected outcomes rather than a pile of low-level DOM manipulation.
Building custom harness
What better way to learn than to build one yourself? Let’s write a harness for a custom autocomplete component. Harnesses pay off most for shared components that involve user interaction.
Before starting, make sure the Angular CDK package is installed.
Here are our autocomplete and option components:
@Component({
selector: 'app-option',
template: `<ng-content />`,
host: {
role: 'option',
class: 'autocomplete-option',
'[class.autocomplete-option--active]': 'active()',
'[aria-selected]': 'active()',
'[hidden]': 'hidden()',
'(mousedown)': '$event.preventDefault()',
'(click)': 'onClick()',
},
})
export class Option {
private readonly _autocomplete = inject(Autocomplete);
readonly value = input.required<string>();
readonly hidden = computed(() => {
const term = this._autocomplete.query().trim().toLowerCase();
return !!term && !this.value().toLowerCase().includes(term);
});
readonly active = computed(() => this._autocomplete.activeValue() === this.value());
onClick(): void {
this._autocomplete.select(this.value());
}
}
@Component({
selector: 'app-autocomplete',
template: `
@if (label()) {
<label [for]="inputId">{{ label() }}</label>
}
<input
#inputEl
[id]="inputId"
type="text"
role="combobox"
aria-autocomplete="list"
[aria-expanded]="open()"
[aria-controls]="panelId"
[disabled]="disabled()"
[placeholder]="placeholder()"
[value]="query()"
(input)="onInput($event)"
(focus)="openPanel()"
(keydown)="onKeydown($event)"
(blur)="closePanel()"
/>
<ng-template #panel>
<ul [id]="panelId" class="autocomplete-options" role="listbox">
<ng-content />
@if (visibleOptions().length === 0) {
<li class="autocomplete-option autocomplete-option--empty">No options</li>
}
</ul>
</ng-template>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: Autocomplete,
multi: true,
},
],
})
export class Autocomplete implements ControlValueAccessor {
private readonly _overlay = inject(Overlay);
private readonly _viewContainerRef = inject(ViewContainerRef);
private readonly _inputEl = viewChild.required<ElementRef<HTMLInputElement>>('inputEl');
private readonly _panel = viewChild.required<TemplateRef<unknown>>('panel');
private readonly _options = contentChildren(Option);
private _overlayRef: OverlayRef | null = null;
private _onChange: (value: string) => void = () => {};
private _onTouched: () => void = () => {};
readonly label = input('');
readonly placeholder = input('');
readonly inputId = generateElementId('autocomplete');
readonly panelId = `${this.inputId}-panel`;
readonly query = signal('');
readonly open = signal(false);
readonly activeIndex = signal(-1);
protected readonly disabled = signal(false);
readonly visibleOptions = computed(() => this._options().filter((option) => !option.hidden()));
readonly activeValue = computed(() => this.visibleOptions()[this.activeIndex()]?.value() ?? null);
onInput(event: Event): void {
this.query.set((event.target as HTMLInputElement).value);
this.activeIndex.set(-1);
this.openPanel();
}
onKeydown(event: KeyboardEvent): void {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.openPanel();
this.activeIndex.set(Math.min(this.activeIndex() + 1, this.visibleOptions().length - 1));
break;
case 'ArrowUp':
event.preventDefault();
this.activeIndex.set(Math.max(this.activeIndex() - 1, -1));
break;
case 'Enter': {
const value = this.activeValue();
if (this.open() && value !== null) {
event.preventDefault();
this.select(value);
}
break;
}
case 'Escape':
this.closePanel();
break;
}
}
select(value: string): void {
this.query.set(value);
this._onChange(value);
this.closePanel();
}
openPanel(): void {
if (this.disabled() || this._overlayRef) {
return;
}
const inputElement = this._inputEl().nativeElement;
const positionStrategy = this._overlay
.position()
.flexibleConnectedTo(inputElement)
.withPositions([
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 },
]);
this._overlayRef = this._overlay.create({
positionStrategy,
width: inputElement.getBoundingClientRect().width,
scrollStrategy: this._overlay.scrollStrategies.reposition(),
});
this._overlayRef.attach(new TemplatePortal(this._panel(), this._viewContainerRef));
this.open.set(true);
}
closePanel(): void {
this._overlayRef?.dispose();
this._overlayRef = null;
this.open.set(false);
this.activeIndex.set(-1);
}
writeValue(value: string): void {
this.query.set(value);
}
registerOnChange(fn: (value: string) => void): void {
this._onChange = fn;
}
registerOnTouched(fn: () => void): void {
this._onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
}
Every harness must extend the ComponentHarness base class and define a static hostSelector property. That property determines which DOM elements a given harness subclass matches.
Here is the minimal AutocompleteHarness:
export class AutocompleteHarness extends ComponentHarness {
static hostSelector = 'app-autocomplete';
}
Working with component’s elements
For the harness to earn its keep, it has to interact with the component and expose its state – and since it should mirror real user interaction, the first step is getting hold of the component’s elements.
The base class offers several factory methods that produce element-locating functions:
- locatorFor – for finding an individual element
- locatorForOptional – for finding an element that may not exist; returns null rather than throwing, so its absence is easy to check
- locatorForAll – for finding all elements matching a given query
Their argument can be either:
- a DOM query string – the created function then returns a TestElement
- a harness query – the created function then returns an instance of the matching element’s harness
One detail matters here: locator methods don’t find elements themselves. They are factories for functions that do the lookup when invoked. Because the lookup runs on every call, it always sees the DOM as it currently is, so the harness never holds onto stale references to elements that were removed or recreated.
export class AutocompleteHarness extends ComponentHarness {
static hostSelector = 'app-autocomplete';
private readonly _input = this.locatorFor('input');
private readonly _label = this.locatorForOptional('label');
}
This covers elements inside the component’s host. The host element itself is reachable through the host() method inherited from ComponentHarness.
But some elements live outside the host. Floating elements and pop-ups commonly end up attached directly to the document body – which is exactly what happens with the options overlay in our autocomplete. For these, use documentRootLocatorFactory(): it returns a locator factory rooted at the document level, so you can reach elements beyond the component’s own host.
export class AutocompleteHarness extends ComponentHarness {
static hostSelector = 'app-autocomplete';
private readonly _input = this.locatorFor('input');
private readonly _label = this.locatorForOptional('label');
private readonly _documentLocator = this.documentRootLocatorFactory();
private readonly _panel = this._documentLocator.locatorForOptional('.autocomplete-options');
private readonly _options = this._documentLocator.locatorForAll(AutocompleteOptionHarness);
}
Before moving on, a quick look at TestElement.
It abstracts interaction with DOM elements so the same code works across testing environments. It exposes actions like blur, click, focus and hover, along with APIs to read properties such as text, attributes and classes.
TestElement instances that wrap a component’s internal elements shouldn’t leak out of the harness. Prefer focused methods that describe concrete user actions or observable states.
With element access in place, we can add methods for the actions an end user might take and the states they might observe. We can also tighten the locators for the options panel and its items, making sure they never resolve to a different autocomplete.
export class AutocompleteHarness extends ComponentHarness {
static hostSelector = 'app-autocomplete';
private readonly _label = this.locatorForOptional('label');
private readonly _input = this.locatorFor('input');
private readonly _documentLocator = this.documentRootLocatorFactory();
/** Selector for this instance's overlay panel, from the input's `aria-controls`. */
private async _getPanelSelector(): Promise<string> {
return `#${await (await this._input()).getAttribute('aria-controls')}`;
}
private async _panel() {
return this._documentLocator.locatorForOptional(await this._getPanelSelector())();
}
private async _noOptions() {
return this._documentLocator.locatorForOptional(
`${await this._getPanelSelector()} .autocomplete-option--empty`,
)();
}
private async _options() {
return this._documentLocator.locatorForAll(
AutocompleteOptionHarness.with({ ancestor: await this._getPanelSelector() }),
)();
}
async getLabel(): Promise<string | null> {
return (await this._label())?.text() ?? null;
}
async isDisabled(): Promise<boolean> {
return (await this._input()).getProperty<boolean>('disabled');
}
async getValue(): Promise<string> {
return (await this._input()).getProperty<string>('value');
}
/** Whether the overlay panel is currently attached at all. */
async isOpen(): Promise<boolean> {
return (await this._panel()) !== null;
}
/** Focuses the field without typing anything, e.g. to open the panel with the full list. */
async focus(): Promise<void> {
await (await this._input()).focus();
}
async enterText(text: string): Promise<void> {
const input = await this._input();
await input.focus();
await input.sendKeys(text);
}
async showsEmptyState(): Promise<boolean> {
return (await this._noOptions()) !== null;
}
private async _getVisibleOptions(): Promise<AutocompleteOptionHarness[]> {
const options = await this._options();
const visibility = await parallel(() => options.map((option) => option.isVisible()));
return options.filter((_, i) => visibility[i]);
}
/** Resolves the text of every visible option, batched into one round of change detection. */
async getOptions(): Promise<string[]> {
const options = await this._getVisibleOptions();
return parallel(() => options.map((option) => option.getText()));
}
/** Selects an option the way a keyboard user would: arrow keys, then Enter. */
async selectOptionWithKeyboard(steps: number): Promise<void> {
const input = await this._input();
for (let i = 0; i < steps; i++) {
await input.sendKeys(TestKey.DOWN_ARROW);
}
await input.sendKeys(TestKey.ENTER);
}
/** Text of the currently keyboard-highlighted option, if any. */
async getActiveOptionText(): Promise<string | null> {
for (const option of await this._getVisibleOptions()) {
if (await option.isActive()) {
return option.getText();
}
}
return null;
}
async pressEscape(): Promise<void> {
await (await this._input()).sendKeys(TestKey.ESCAPE);
}
}
Note that every method is asynchronous. The same harness has to serve different testing environments – from a TestBed fixture to a real WebDriver session, where every interaction is inherently async.
AutocompleteHarness also delegates to another harness for its subcomponent: AutocompleteOptionHarness. That’s a common arrangement for larger components.
When several asynchronous harness operations need to run – reading the text of every option, say – use the parallel function. It behaves like Promise.all, letting operations run concurrently rather than sequentially. In WebDriver-based end-to-end tests, where each operation adds latency, the difference can be quite noticeable.
export class AutocompleteOptionHarness extends ContentContainerComponentHarness {
static hostSelector = 'app-option';
async getText(): Promise<string> {
return (await this.host()).text();
}
async isActive(): Promise<boolean> {
return (await this.host()).hasClass('autocomplete-option--active');
}
// Options stay in the DOM even when filtered out - they're just hidden.
async isVisible(): Promise<boolean> {
return !(await (await this.host()).getProperty<boolean>('hidden'));
}
async select(): Promise<void> {
await (await this.host()).click();
}
}
AutocompleteOptionHarness extends ContentContainerComponentHarness, a specialized subtype of ComponentHarness that can load harnesses from content projected through ng-content.
An option might hold nothing but text, but it could equally contain a tooltip, a badge, an icon or an entirely custom component. Through methods such as getHarness and hasHarness, consumers can obtain harnesses for components supplied as projected content (searching within whatever was projected into this component, not across the whole document).
We’ll see that in practice in the next chapter.
Filtering harness instances
Pages and large components frequently contain several instances of the same component – a form with many controls, a search page with multiple filters, a navigation bar with several menu items.
Targeting a specific instance is therefore essential – a button with particular text, a control with a given ID.
The HarnessPredicate class is how we express criteria like these. Test authors can construct HarnessPredicate instances by hand, but exposing a static with() method for common filters is good practice: harness queries become far easier to read and reuse.
Every harness has to support the base filters:
- selector – to find elements matching a given DOM query selector
- ancestor – to find elements nested under element matching given DOM query selector
Beyond those, any additional filter should make sense for the harness at hand. For our autocomplete, a label and a disabled flag are reasonable candidates. Autocomplete options, in contrast, can be filtered by the text they display.
HarnessPredicate ships with several convenience methods for building such filters.
export interface AutocompleteOptionHarnessFilters extends BaseHarnessFilters {
text?: string | RegExp;
}
export class AutocompleteOptionHarness extends ContentContainerComponentHarness {
static hostSelector = 'app-option';
static with(
options: AutocompleteOptionHarnessFilters = {},
): HarnessPredicate<AutocompleteOptionHarness> {
return new HarnessPredicate(AutocompleteOptionHarness, options).addOption(
'text',
options.text,
async (harness, text) => HarnessPredicate.stringMatches(await harness.getText(), text),
);
}
// ...
}
export interface AutocompleteHarnessFilters extends BaseHarnessFilters {
label?: string | RegExp;
disabled?: boolean;
}
export class AutocompleteHarness extends ComponentHarness {
static hostSelector = 'app-autocomplete';
static with(options: AutocompleteHarnessFilters = {}): HarnessPredicate<AutocompleteHarness> {
return new HarnessPredicate(AutocompleteHarness, options)
.addOption('label', options.label, async (harness, label) =>
HarnessPredicate.stringMatches(await harness.getLabel(), label),
)
.addOption(
'disabled',
options.disabled,
async (harness, disabled) => (await harness.isDisabled()) === disabled,
);
}
// ...
// Utilizing the AutocompleteOptionHarness filters to find the option to select
async getOption(text: string): Promise<AutocompleteOptionHarness> {
return this._documentLocator.locatorFor(
AutocompleteOptionHarness.with({ text, ancestor: await this._getPanelSelector() }),
)();
}
async selectOption(text: string): Promise<void> {
const option = await this.getOption(text);
await option.select();
}
}
The addOption() method registers a filtering condition that is only evaluated when the matching option is supplied. Inside the predicate, the harness API itself can be used to inspect each candidate and decide whether it qualifies.
This illustrates why implementing filters on the harness pays off. AutocompleteHarness never needs to know how an autocomplete option reveals its text internally – it simply calls AutocompleteOptionHarness.with({ text }) to pick the right one.
Waiting for asynchronous tasks
In most situations Angular stabilization is handled for you: actions performed through TestElement automatically trigger change detection. A handful of edge cases remain, and for those ComponentHarness offers two extra methods:
- forceStabilize() – flushes change detection and async tasks in the Angular zone. It might be needed to fully slush animation events
- waitForTasksOutsideAngular() – waits for all scheduled or running async tasks to complete. This allows us to wait for async tasks outside the Angular zone.
In ordinary harness code you’ll seldom reach for either. They exist for cases where automatic stabilization on its own doesn’t suffice.
Using component harnesses in tests
Time to put it all together.
Component harnesses work across multiple testing environments. Angular CDK ships two built-in ones:
- Unit tests with Angular’s TestBed
- End-to-end tests with WebDriver
Each environment supplies a harness loader, which produces the harness instances your tests use. HarnessLoader offers several ways to find them:
- getHarness – returns the first harness instance matching the given query
- getAllHarnesses – returns all harness instances matching the given query
- getHarnessAtIndex – returns the matching harness at a specific index
- countHarnesses – counts the number of harness instances matching the given query
- hasHarness – checks whether at least one matching harness exists
With that, we can test the autocomplete component itself using HarnessAutocomplete:
const FRAMEWORKS = ['Angular', 'React', 'Vue', 'Svelte', 'Solid', 'Qwik'];
@Component({
selector: 'autocomplete-test-host',
imports: [Autocomplete, Option, ReactiveFormsModule],
template: `
<app-autocomplete label="Frameworks">
@for (framework of frameworks; track framework) {
<app-option [value]="framework">{{ framework }}</app-option>
}
</app-autocomplete>
<app-autocomplete label="Disabled example" [formControl]="disabledControl">
@for (framework of frameworks; track framework) {
<app-option [value]="framework">{{ framework }}</app-option>
}
</app-autocomplete>
`,
})
class TestHost {
readonly frameworks = FRAMEWORKS;
readonly disabledControl = new FormControl({ value: '', disabled: true });
}
describe('AutocompleteHarness', () => {
let loader: HarnessLoader;
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [TestHost] }).compileComponents();
const fixture = TestBed.createComponent(TestHost);
fixture.detectChanges();
loader = TestbedHarnessEnvironment.loader(fixture);
});
it('finds a specific instance with a HarnessPredicate filter', async () => {
const harness = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
expect(await harness.getLabel()).toBe('Frameworks');
});
it('finds every instance on the page', async () => {
const harnesses = await loader.getAllHarnesses(AutocompleteHarness);
expect(harnesses.length).toBe(2);
});
it('reports the disabled state of a specific instance', async () => {
const harness = await loader.getHarness(AutocompleteHarness.with({ disabled: true }));
expect(await harness.isDisabled()).toBe(true);
});
it('is closed until the field is focused', async () => {
const harness = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
expect(await harness.isOpen()).toBe(false);
});
it('shows the full list in the overlay panel on focus', async () => {
const harness = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
await harness.focus();
expect(await harness.isOpen()).toBe(true);
expect(await harness.getOptions()).toEqual(FRAMEWORKS);
});
it('filters the options as the user types', async () => {
const harness = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
await harness.enterText('u');
expect(await harness.isOpen()).toBe(true);
expect(await harness.getOptions()).toEqual(['Angular', 'Vue']);
});
it('shows an empty state when nothing matches', async () => {
const harness = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
await harness.enterText('zzz');
expect(await harness.showsEmptyState()).toBe(true);
});
it('selects an option with a click, found by a predicate on its text', async () => {
const harness = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
await harness.enterText('a');
await harness.selectOption('React');
expect(await harness.getValue()).toBe('React');
expect(await harness.isOpen()).toBe(false);
});
it('selects an option with the keyboard, tracking the active option', async () => {
const harness = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
await harness.enterText('a');
expect(await harness.getActiveOptionText()).toBeNull();
// Highlights "Angular" (step 1), then "React" (step 2), then selects it.
await harness.selectOptionWithKeyboard(2);
expect(await harness.getValue()).toBe('React');
});
it('closes the panel on Escape', async () => {
const harness = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
await harness.enterText('a');
expect(await harness.isOpen()).toBe(true);
await harness.pressEscape();
expect(await harness.isOpen()).toBe(false);
});
});
And the same harness can test a component that consumes our autocomplete:
describe('AutocompleteDemo', () => {
let loader: HarnessLoader;
let summary: () => string | null;
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [AutocompleteDemo] }).compileComponents();
const fixture = TestBed.createComponent(AutocompleteDemo);
fixture.detectChanges();
loader = TestbedHarnessEnvironment.loader(fixture);
summary = () =>
(fixture.nativeElement as HTMLElement).querySelector('.profile-summary')?.textContent ??
null;
});
it('shows no summary until both a framework and a country are selected', async () => {
expect(summary()).toBeNull();
const frameworks = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
await frameworks.focus();
await frameworks.selectOption('Angular');
expect(summary()).toBeNull(); // still missing a country
});
it('puts together a summary once the user selects both values', async () => {
const frameworks = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
const countries = await loader.getHarness(AutocompleteHarness.with({ label: 'Country' }));
await frameworks.focus();
await frameworks.selectOption('Angular');
await countries.focus();
await countries.selectOption('Poland');
expect(summary()).toBe('Angular developer based in Poland.');
});
it('updates the summary when a selection changes', async () => {
const frameworks = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
const countries = await loader.getHarness(AutocompleteHarness.with({ label: 'Country' }));
await frameworks.focus();
await frameworks.selectOption('Angular');
await countries.focus();
await countries.selectOption('Poland');
expect(summary()).toBe('Angular developer based in Poland.');
await frameworks.focus();
await frameworks.selectOption('Vue');
expect(summary()).toBe('Vue developer based in Poland.');
});
it('reacts to a keyboard-driven selection the same way as a click', async () => {
const frameworks = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
const countries = await loader.getHarness(AutocompleteHarness.with({ label: 'Country' }));
await frameworks.enterText('u'); // "Angular", "Vue"
await frameworks.selectOptionWithKeyboard(2); // highlights "Angular", then "Vue"
await countries.focus();
await countries.selectOption('Japan');
expect(summary()).toBe('Vue developer based in Japan.');
});
it("exposes the Angular option's tooltip", async () => {
const frameworks = await loader.getHarness(AutocompleteHarness.with({ label: 'Frameworks' }));
await frameworks.focus();
const angularOption = await frameworks.getOption('Angular');
const reactOption = await frameworks.getOption('React');
// Only the option that projects an <app-tooltip> resolves one.
expect(await reactOption.getHarnessOrNull(TooltipHarness)).toBeNull();
const tooltip = await angularOption.getHarness(TooltipHarness);
expect(await tooltip.isOpen()).toBe(false);
await tooltip.show();
expect(await tooltip.getText()).toBe(FRAMEWORK_TOOLTIPS['Angular']);
// Hovering the tooltip is not a selection, so it doesn't affect the summary.
expect(summary()).toBeNull();
});
});
The “exposes the Angular option’s tooltip” case shows ContentContainerComponentHarness at work.
AutocompleteOptionHarness has no idea a tooltip was projected into an option. Because it extends ContentContainerComponentHarness, the test can ask it for a harness matching content inside that option – here retrieving TooltipHarness only from the option that actually contains a tooltip.
AutocompleteOptionHarness therefore stays focused on the autocomplete option itself, while still giving consumers access to the additional components projected into it.
Best practices
There are a handful of guidelines worth following when you build and use component harnesses.
Model harness APIs on behavior rather than markup
Methods such as isOpen() or enterText() express what a user can see or do, instead of exposing how the component happens to be built on the inside.
Keep harnesses and components in sync
When a component's public behavior changes, its harness API may need to change too. Internal refactoring can also force updates to the harness implementation – a changed selector, for instance – but the tests that consume the harness should remain untouched.
Hide DOM details inside the harness when you interact with the harnessed component
If a test keeps falling back on fixture.nativeElement.querySelector() to work with a component that already has a harness, that usually means the harness is missing a method it ought to offer. The point is to concentrate knowledge of that component's internal DOM structure in a single place.
Write harnesses only where they earn their keep
The Angular team advises using them primarily for shared components that appear in many places and involve user interaction. A one-off page component tends to benefit less from having its own harness, since its implementation and its tests usually change together. That said, a harness can still make sense if you want one testing API shared between unit and end-to-end tests.
Summary
Component harnesses move Angular tests away from the question “what does the DOM look like?” and toward “what can a user actually do?” That shift in perspective leaves tests more resistant to refactoring, easier to reuse across unit and end-to-end environments, and frequently far more readable.
If you own a shared component library, or you are simply worn down by tests that break over cosmetic changes, harnesses deserve a place in your testing strategy rather than a spot on the afterthought pile.
