If you’ve ever worked on a large Angular codebase, you’ve probably experienced this frustration: you change a component’s internal structure – maybe by wrapping an element in a div for styling, renaming a CSS class, or changing the element nesting – without touching its actual behavior.
Suddenly, a dozen seemingly unrelated tests fail, and you find yourself hoping they don’t belong to a part of the project you don’t even maintain.
Another common pain point is writing tests that depend on shared UI components, such as checkboxes, tooltips, or dropdowns. You often have to dig into the component’s internal structure to find the right selectors and understand how it works under the hood. And because these components are used throughout the project, every refactor risks breaking tests in multiple places.
Component harnesses, part of the Angular CDK, were designed to solve this exact problem. They let you write tests that interact with components much like a real user would – clicking, typing, and reading text – without depending on their internal implementation details.
If you use Angular Material in your project, you may have already come across harnesses – Angular Material provides a test harness for each component in the library.
The problem harnesses solve
First, let’s look at what a brittle test might look like:
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 reaches directly into the component’s implementation details: it depends on specific class names and a particular DOM structure.
The problem is that if the component author changes it, the test can break even though the component still behaves exactly as expected from the user’s perspective. This is the frustration we described earlier – a simple internal refactor shouldn’t force you to fix tests for behavior that hasn’t changed.
A harness-based version of the same test looks much cleaner:
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');
});
Now the test interacts with the component through a stable, purpose-built API. Its internal implementation can change without affecting every test that uses it, as long as the harness API continues to provide the same behavior.
This isn’t a completely new idea – it’s essentially the Page Object Pattern from end-to-end testing applied to individual Angular components. By introducing an abstraction layer between your tests and the underlying DOM, you get:
- High Maintainability: If the component implementation changes, you update its harness in one place instead of fixing the same selectors across dozens of test files.
- Code Reusability: Common interactions are reused easily across different test suites.
- Readable Tests: Tests describe high-level actions and expected behavior instead of being filled with low-level DOM manipulation.
Building custom harness
Is there a better way to learn something than by getting your hands dirty and building it from scratch? Let’s build a harness for a custom autocomplete component. Harnesses are especially useful for shared components with user interaction.
Before we dive into the code, make sure you have the Angular CDK package installed.
Here’s what our autocomplete and option components look like:
@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 needs to extend the ComponentHarness base class and implement the static hostSelector property. This property identifies which elements in the DOM match the harness subclass.
Here’s the minimal implementation of AutocompleteHarness:
export class AutocompleteHarness extends ComponentHarness {
static hostSelector = 'app-autocomplete';
}
Working with component’s elements
To make the harness actually useful, we need a way to interact with the component and inspect its state. A harness should mimic how a real user interacts with the component, which means we first need access to its elements.
The base class provides several factory methods for creating functions that locate elements:
- locatorFor – for finding an individual element
- locatorForOptional – for finding element that may not exist; returns null instead of throwing an error, making its absence easy to check
- locatorForAll – for finding all elements matching a given query
As argument they can accept:
- DOM query string – created function returns TestElement
- Harness query – created function returns instance of matching element’s harness
One important thing to remember is that locator methods do not directly find elements – they are factory methods for functions which perform the lookup when called. Because the lookup happens each time the function runs, it always reflects the current state of the DOM. This prevents the harness from keeping stale references to elements that may have been 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 approach works for elements inside the component’s host. You can access the host element itself using the host() method provided by ComponentHarness.
Sometimes, though, we need to access elements outside the component’s host. Floating elements and pop-ups are common examples, as they are often attached directly to the document body (in the autocomplete component, that’s exactly what happens with the overlay with options). For cases like this, we can use documentRootLocatorFactory(). It gives us a locator factory rooted at the document level, allowing us to find elements outside 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 we move on, let’s briefly look at TestElement.
It’s an abstraction for interacting with DOM elements across different test environments. It provides methods for actions such as blur, click, focus, and hover, as well as APIs for reading properties such as text, attributes, and classes.
TestElement instances for a component’s internal elements shouldn’t be exposed directly to consumers of the harness. Instead, it’s better to provide focused methods describing specific user actions or observable states.
Now that we have access to the elements, we can create methods for the actions an end user may perform and the states they may want to observe. Additionally we can improve locators for option’s panel and its elements and make sure they never reach to 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);
}
}
Every method here is asynchronous. That’s important because the same harness needs to work across different testing environments, from a TestBed fixture to a real WebDriver session, where interactions are inherently asynchronous.
AutocompleteHarness also uses another harness for its subcomponent: AutocompleteOptionHarness. That’s a fairly common pattern for larger components.
When we need to perform multiple asynchronous harness operations, such as reading the text of every option, we can use the parallel function. It works similarly to Promise.all, allowing the operations to run together rather than one after another. This can make a noticeable difference in WebDriver-based end-to-end tests, where individual operations may introduce additional latency.
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();
}
}
The AutocompleteOptionHarness extends ContentContainerComponentHarness, a specialized subtype of ComponentHarness that supports loading harnesses from content projected through the component’s ng-content.
An option may contain plain text, but it could just as easily include a tooltip, badge, icon, or even a completely custom component. With methods such as getHarness and hasHarness, consumers can access harnesses for components passed in as projected content (search within whatever was projected into this component, not the whole document)..
We’ll see how that works in practice in the next chapter.
Filtering harness instances
It’s fairly common for a page or a large component to contain multiple instances of the same component – a form with many form controls, a search page with multiple filters, or a navigation bar with several menu items.
To test a specific behavior, you need a way to target the right component instance – for example, a button with particular text or a control with a specific ID.
The HarnessPredicate class lets us define criteria like these. While test authors can construct HarnessPredicate instances manually, it’s a good practice to provide a static with() method for common filters. This makes harness queries much easier to read and reuse.
Every harness needs to support base filters:
- selector – to find elements matching a given DOM query selector
- ancestor – to find elements nested under element matching given DOM query selector
Any additional filters should make sense for the particular harness. For our autocomplete, useful filters might include its label and whether it’s disabled. Autocomplete options, on the other hand, can be filtered by the text they display.
HarnessPredicate provides several convenience methods for implementing these 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 adds a filtering condition that is only evaluated when the corresponding option is provided. The predicate can use the harness API itself to inspect each candidate and decide whether it matches.
This also shows why implementing filters directly on a harness is useful. AutocompleteHarness doesn’t need to know how an autocomplete option exposes its text internally – it can simply use AutocompleteOptionHarness.with({ text }) to find the right one.
Waiting for asynchronous tasks
Most of the time, you don’t need to handle Angular stabilization manually – actions performed through TestElement automatically trigger change detection. There are, however, a few edge cases where ComponentHarness provides two additional 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 regular harness code, you’ll rarely need either of these methods. They’re mainly there for cases where the normal automatic stabilization isn’t enough.
Using component harnesses in tests
It’s time to see how all of this works in practice.
Component harnesses can be used across different test environments. Angular CDK provides two built-in environments:
- Unit tests with Angular’s TestBed
- End-to-end tests with WebDriver
Each environment provides a harness loader, which creates the harness instances you use throughout your tests. HarnessLoader provides several methods for finding 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
Now we can use HarnessAutocomplete to test the autocomplete component itself:
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);
});
});
The same harness can then be used to 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” test case shows ContentContainerComponentHarness in action.
AutocompleteOptionHarness doesn’t need to know that a tooltip has been projected into an option. Because it extends ContentContainerComponentHarness, the test can ask it for a harness corresponding to content inside that option. In this case, it can retrieve TooltipHarness only from the option that actually contains a tooltip.
This keeps AutocompleteOptionHarness focused on the autocomplete option itself while still giving consumers access to additional components projected into it.
Best practices
A few best practices are worth keeping in mind when working with component harnesses.
Design harness APIs around behavior, not markup
Methods like isOpen() or enterText() describe what a user can observe or do, rather than how the component is implemented internally.
Maintain harnesses alongside their components
If a component’s public behavior changes, its harness API may need to change as well. Internal refactors might still require updating the harness implementation – for example, when a selector changes – but tests using the harness shouldn’t need to change.
Keep DOM details behind the harness when interacting with the harnessed component
If tests repeatedly reach for fixture.nativeElement.querySelector() to interact with a component that already has a harness, that may be a sign that the harness is missing a useful method. The goal is to keep knowledge of that component’s internal DOM structure in one place.
Build harnesses where they actually pay off
The Angular team recommends them mainly for shared components that are used in many places and involve user interaction. A one-off page component usually gets less benefit from its own harness because its implementation and tests are typically updated together. A harness can still be worthwhile, though, if you want to reuse the same testing API across unit and end-to-end tests.
Summary
Component harnesses shift Angular tests away from “what does the DOM look like?” and toward “what can a user actually do?” That change in perspective makes tests more resilient to refactors, easier to reuse across unit and end-to-end environments, and often much easier to read.
If you maintain a shared component library, or you’re simply tired of tests breaking because of cosmetic changes, harnesses are worth treating as part of your testing strategy rather than an afterthought.