The Anatomy of a Component Harness

To see what a component harness looks like in practice, let's examine the MatButtonHarness that shipped with Angular Material version 9.

The MatButton harness exposes the following API:

  • blur(): Promise<void>
  • click(): Promise<void>
  • focus(): Promise<void>
  • getText(): Promise<string>
  • host(): Promise<TestElement>
  • isDisabled(): Promise<boolean>

These methods capture the essential user-facing interactions and observable characteristics of a Material button.

Every method in a component harness returns a promise. This consistency not only makes the API predictable for consumers, but also allows the harness environment to manage asynchronous operations, timers, and change detection behind the scenes. As a result, async-await becomes the natural choice for writing tests that leverage component harnesses, as we'll demonstrate shortly.

The host method stands out from the rest. It resolves to a TestElement, which deserves a closer look. Before diving into that, it's worth emphasizing that TestElement instances should only be surfaced to consumers when they wrap elements that the consumer owns — for instance, the host elements of directives. Since this is always true for MatButton, exposing host is appropriate here.

Like all harnesses, MatButtonHarness also includes a standard static with method. This method takes an object containing filter criteria to locate the desired directive instance in the DOM.

For example, we can target a button displaying the text Sign up, as shown in Listing 1.

// sign-up-form.spec.ts
it('submits a form when the sign up button is clicked', async () => {
  const signUpButton = await harnessLoader.getHarness(
    MatButtonHarness.with({ text: 'Sign up' }));

  await signUpButton.click();

  expect(formSpy).toHaveBeenCalled();
});
Enter fullscreen mode Exit fullscreen mode
Listing 1. Selecting a harness for a specific button by using a harness filter.

Building Your Own Component Harness

Now let's walk through creating a harness for a favourite ocean creature picker component, which is built on top of Angular Material components.

Figures 1 and 2 illustrate the component's layout and interactions.

Figure 1. Favourite ocean creature picked.

Figure 1. Favourite ocean creature picked.

Figure 2. Favourite ocean creature options.

Figure 2. Favourite ocean creature options.

When we build and use the harness for this component, we'll adopt a test-as-a-user perspective. The internal structure of the component's model, its data binding approach, and the template's DOM layout don't matter for our tests — we won't rely on them directly.

// favorite-ocean-creature.harness.ts
import { ComponentHarness } from '@angular/cdk/testing';

export class FavoriteOceanCreatureHarness extends ComponentHarness {
  static hostSelector = 'app-favorite-ocean-creature';
}
Enter fullscreen mode Exit fullscreen mode
Listing 2. Minimal component harness specifying a selector.

Listing 2 shows a minimal harness. It extends the ComponentHarness class from @angular/cdk/testing and declares a CSS selector that points to the top-level DOM element of the component's template. Here, we're targeting the <app-favorite-ocean-creature> element.

With this setup, test cases can access the host property, a promise that resolves to a TestElement.

The TestElement interface provides these methods for interacting with a DOM node:

  • blur(): Promise<void>
  • clear(): Promise<void>
  • click(relativeX?: number, relativeY?: number): Promise<void>
  • getAttribute(name: string): Promise<string | null>
  • getCssValue(property: string): Promise<string>
  • getDimensions(): Promise<ElementDimensions>*
  • getProperty(name: string): Promise<any>
  • isFocused(): Promise<boolean>
  • focus(): Promise<void>
  • hasClass(name: string): Promise<string>
  • hover(): Promise<void>
  • matchesSelector(selector: string): Promise<boolean>
  • sendKeys(...keys: (string | TestKey)[]): Promise<void>**
  • text(): Promise<string>

* ElementDimensions is an interface with number fields: top, left, width, and height.

** TestKey is an enum containing keycodes for non-character keys, including BACKSPACE, TAB, ENTER, LEFT_ARROW, and F10.

We can request a TestElement for any element in the component's DOM. However, it's best practice to only hand out TestElements for elements that consumers directly manage — such as the host element of a component. In our case, that's the <app-favorite-ocean-creature> element, which parent components control through their templates.

Guidance for harness authors: Generally, avoid exposing TestElements directly. Do so only for DOM elements that consumers themselves control.

Why this caution? We don't want consumers to become dependent on our DOM structure, which is an implementation detail they shouldn't need to know about. It's our responsibility, as the maintainers of components and directives, to ensure our harnesses stay aligned with the corresponding DOM structures.

Setting Up the Test Suite

Let's design the test suite for our component, letting it guide the harness's API.

Our first test will verify the initially selected ocean creature. To do this, we need to set up the Angular testing module with a test host component that uses our favourite ocean creature component.

// favorite-ocean-creature.spec.ts
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';

import {
  FavoriteOceanCreatureHarness,
} from './favorite-ocean-creature.harness';
import { FavoriteOceanCreatureModule } from './favorite-ocean-creature.module';

describe('Favorite ocean creature', () => {
  @Component({
    template: '<app-favorite-ocean-creature></app-favorite-ocean-creature>',
  })
  class TestHostComponent {}

  let harness: FavoriteOceanCreatureHarness;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      declarations: [TestHostComponent],
      imports: [NoopAnimationsModule, FavoriteOceanCreatureModule],
    });

    const fixture = TestBed.createComponent(TestHostComponent);
    const loader = TestbedHarnessEnvironment.loader(fixture);
    harness = await loader.getHarness(FavoriteOceanCreatureHarness);
  });
});
Enter fullscreen mode Exit fullscreen mode
Listing 3. Staging a test suite that tests our component using its component harness.

Listing 3 demonstrates how to create the test host component, configure the testing module by disabling animations, declare the test host, and import the module that provides our component.

Once the testing module is configured, we create a fixture for the test host component. We then build a HarnessLoader by passing the fixture to TestbedHarnessEnvironment.loader. Finally, we pass FavoriteOceanCreatureHarness to HarnessLoader#getHarness and await the resulting promise to get the harness for the favourite ocean creature component in the host's template.

Component Harness Environments

For unit and integration tests, TestbedHarnessEnvironment is the way to create a HarnessLoader. This environment works with the Karma and Jest test runners, and likely others that provide a DOM.

When using Protractor for end-to-end testing, ProtractorHarnessEnvironment is available for creating HarnessLoaders. For other E2E frameworks like Cypress, you'd need to either implement a custom HarnessEnvironment or wait for the community to provide one.

If you're keen on adding support for another E2E framework, check the official component harness guide's section titled "API for harness environment authors". That section explains what's needed to build a HarnessEnvironment and a matching TestElement — the core pieces that let component harnesses interact with the DOM as we saw in the API above.

Locating a nested harness

With the test setup shown in Listing 3, we have all the pieces needed to begin writing test cases for our test-as-a-user component suite.

Given that the majestic manta ray is widely considered the top ocean creature pick, it's the default selection for our component. Our first test verifies this choice.

// favorite-ocean-creature.spec.ts
it('manta ray is initially picked', async () => {
  const mantaRay = 'Manta ray';

  const pickedOceanCreature = await harness.getFavoriteOceanCreature();

  expect(pickedOceanCreature).toBe(mantaRay);
});
Enter fullscreen mode Exit fullscreen mode
Listing 4. Asserting the initially picked ocean creature.

Listing 4 outlines the public interface our harness should expose — a method named getFavoriteOceanCreature that returns a promise resolving to a string containing the display name of a selectable ocean creature.

// favorite-ocean-creature.harness.ts
import { AsyncFactoryFn, ComponentHarness } from '@angular/cdk/testing';
import { MatSelectHarness } from '@angular/material/select/testing';

export class FavoriteOceanCreatureHarness extends ComponentHarness {
  static hostSelector = 'app-favorite-ocean-creature';

  protected getDropDown: AsyncFactoryFn<MatSelectHarness> =
    this.locatorFor(MatSelectHarness);

  async getFavoriteOceanCreature(): Promise<string> {
    const select = await this.getDropDown();

    return select.getValueText();
  }
}
Enter fullscreen mode Exit fullscreen mode
Listing 5. Querying for a child harness to delegate a consumer query.

In Listing 5, we introduce a protected method whose returned promise resolves to a component harness. Here, MatSelectHarness stands in for a MatSelect directive — specifically, the select directive employed by the favourite ocean picker component.

The AsyncFactoryFn<T> type from the @angular/cdk/testing sub-package describes a function that yields Promise<T>, such as an async function.

Although getDropDown may appear to be a property, it's actually a method because we're assigning it the output of invoking another method. ComponentHarness#locatorFor serves as a common utility for building both internal and public query methods.

By calling this.locatorFor(MatSelectHarness), we search for the harness that corresponds to the first select directive nested within the favourite ocean creature component.

ComponentHarness#locatorFor ranks among the built-in helpers of the ComponentHarness base class. It offers several strategies for locating child elements or their associated harnesses, including DOM selectors and HarnessPredicate instances.

Following that, we code the public method responsible for determining the display name of the selected ocean creature. This relies on the asynchronous child harness locator, getDropDown.

Keep in mind that getFavoriteOceanCreature is an async method, which means any value we return gets wrapped in a promise, and the await operator is available within its body.

Working with a child harness

Once we've awaited the promise from this.getDropDown(), the select constant holds a MatSelectHarness.

How can we extract the selected option's display text from the select harness? Regrettably, as of this writing, the MatSelectHarness lacks official documentation in Angular Material's reference. However, since we're working in TypeScript, its type definitions are available to us.

Let's examine MatSelectHarness' API for what we need:

  • blur(): Promise<void>
  • clickOptions(filter?: OptionHarnessFilters): Promise<void>
  • close(): Promise<void>
  • focus(): Promise<void>
  • getOptionGroups(filter?: Omit<OptionHarnessFilters, 'ancestor'>): Promise<MatOptgroupHarness[]>* **
  • getOptions(filter?: Omit<OptionHarnessFilters, 'ancestor'>): Promise<MatOptionHarness[]>* **
  • getValueText(): Promise<string>
  • isDisabled(): Promise<boolean>
  • isEmpty(): Promise<boolean>
  • isOpen(): Promise<boolean>
  • isRequired(): Promise<boolean>
  • isMultiple(): Promise<boolean>
  • isValid(): Promise<boolean>
  • open(): Promise<void>

*OptionHarnessFilters is an interface that builds on BaseHarnessFilters by adding isSelected?: boolean and text?: string | RegExp. The BaseHarnessFilters interface from @angular/cdk/testing includes ancestor?: string and selector?: string.

**It's worth noting that MatSelectHarness itself enables querying for its own nested harnesses.

Did you notice a method that fits our needs? Exactly — it's getValueText, as you likely spotted earlier in Listing 5.

The async-await pattern used in getFavoriteOceanCreature is widespread and fundamental to both creating and using component harnesses, given that every method returns a promise.

Avoiding implementation coupling

Returning to Listing 4, we see that we've enabled a test case without the consumer (our first test) having any insight into the component's DOM structure or its public API.

// favorite-ocean-creature.spec.ts
it('manta ray is initially picked', async () => {
  const mantaRay = 'Manta ray';

  const pickedOceanCreature = await harness.getFavoriteOceanCreature();

  expect(pickedOceanCreature).toBe(mantaRay);
});
Enter fullscreen mode Exit fullscreen mode
Listing 4 (repeated). Asserting the initially picked ocean creature.

The test is completely unaware that we rely on Angular Material's select directive or which elements must be clicked to open the dropdown or choose an option. In fact, we didn't need any of that knowledge about MatSelect while building our harness.

The outcome is a readable test that speaks in terms akin to a user story.

Fetching multiple items

Next, we'll confirm that the component renders a list of ocean creatures available for selection.

// favorite-ocean-creature.spec.ts
it('show awesome ocean creatures', async () => {
  const blueWhale = 'Blue whale';

  const options = await harness.getOptions();

  expect(options).toContain(blueWhale);
});
Enter fullscreen mode Exit fullscreen mode
Listing 6. Asserting that multiple ocean creatures are presented to the user.

With dropdowns, it's common to let the consumer provide the displayed options. Yet this component presents a fixed set of impressive ocean creatures, as illustrated in Figure 2.

Consequently, our test verifies that a blue whale — a different creature than the initially selected manta ray — shows up in the list.

Returning data from queries

What do you suppose the resolved type of the getOptions method is? <option> elements? MatOptions? Not at all — we want to avoid exposing details that would tie consumers to our implementation. If we swap out the MatSelect directive or the select directive stops using <option> tags, we don't want to disrupt our own tests or those of third parties.

Instead, we'll simply yield an array of text strings to consumers. You may have already inferred this, since the test asserts that the options list contains the 'Blue whale' string.

Engaging with a child harness

To back this test case, we solely need the getDropDown locator introduced in the prior section.

// favorite-ocean-creature.harness.ts
import { AsyncFactoryFn, ComponentHarness } from '@angular/cdk/testing';
import { MatSelectHarness } from '@angular/material/select/testing';

export class FavoriteOceanCreatureHarness extends ComponentHarness {
  static hostSelector = 'app-favorite-ocean-creature';

  protected getDropDown: AsyncFactoryFn<MatSelectHarness> =
    this.locatorFor(MatSelectHarness);

  async getOptions(): Promise<ReadonlyArray<string>> {
    const select = await this.getDropDown();
    await select.open();
    const options = await select.getOptions();
    const optionTexts = options.map(option => option.getText());

    return Promise.all(optionTexts);
  }
}
Enter fullscreen mode Exit fullscreen mode
Listing 7. Interacting with a child harness.

Within getOptions, we obtain a select harness just as before. However, rather than returning immediately, we engage with the child select harness.

Consulting the MatSelectHarness API, we start by invoking open to reveal the dropdown list, then call getOptions to fetch MatOptionHarness instances.

As mentioned earlier, we transform the option harnesses into their display texts so consumers never encounter implementation specifics.

Given that MatOptionHarness#getText returns a promise, like all harness methods, we bundle the resulting promises inside a Promise.all call to resolve them collectively into an array of strings.

Observe how async-await keeps each step in our method straightforward, employing a synchronous control flow.

The hierarchy of component harnesses

As the previous section hints, component harnesses create a hierarchy that mirrors the DOM and component tree closely.

Alt Text

Figure 3. Our component harness hierarchy.

Figure 3 illustrates this relationship. Our tests interact with FavoriteOceanCreatureHarness, which internally relies on MatSelectHarness, which in turn grants access to its nested harnesses, MatOptionHarness.

Inspecting the DOM rendered by our favourite ocean creature component would reveal a parallel structure.

Importantly, consumers of FavoriteOceanCreatureHarness remain oblivious to <mat-select> or MatSelectHarness. We expose only meaningful data, not internal mechanics. This approach prevents consumers from being tightly coupled to our component's underlying use of MatSelect.

If we ever wanted consumers to manipulate the dropdown options directly, we'd need to wrap MatOptionHarness in our own abstraction, such as FavoriteOceanCreatureOption.

Enabling user actions with harness filters

Our third test case demonstrates the user selecting a different favourite ocean creature and confirming that its display text appears in the content.

// favorite-ocean-creature.spec.ts
it('pick your favorite ocean creature', async () => {
  const greatWhiteShark = 'Great white shark';

  await harness.pickOption({ text: greatWhiteShark });

  const pickedOceanCreature = await harness.getFavoriteOceanCreature();
  expect(pickedOceanCreature).toBe(greatWhiteShark);
});
Enter fullscreen mode Exit fullscreen mode
Listing 8. Testing user interaction with a component harness.

As Listing 8 shows, we let the consumer supply a text filter to identify the option they wish to choose. In this instance, the test picks the great white shark option. We maintain consistent use of async-await for all harness interactions.

Finally, we reuse the getFavoriteOceanCreature query to verify the content reflects the new selection.

To enable this, we must implement the pickOption method, which accepts a component harness filter as its argument.

// favorite-ocean-creature.harness.ts
import { AsyncFactoryFn, ComponentHarness } from '@angular/cdk/testing';
import { MatSelectHarness } from '@angular/material/select/testing';

import {
  FavoriteOceanCreatureFilters,
} from './favorite-ocean-creature-filters';

export class FavoriteOceanCreatureHarness extends ComponentHarness {
  static hostSelector = 'app-favorite-ocean-creature';

  protected getDropDown: AsyncFactoryFn<MatSelectHarness> =
    this.locatorFor(MatSelectHarness);

  async pickOption(filter: FavoriteOceanCreatureFilters): Promise<void> {
    const select = await this.getDropDown();

    return select.clickOptions({ text: filter.text });
  }
}
Enter fullscreen mode Exit fullscreen mode
Listing 9. Supporting a component harness filter

Listing 9 presents the relevant methods and properties of the favourite ocean creature harness that support the test from Listing 8.

pickOption is a fresh addition. It takes a FavoriteOceanCreatureFilters parameter, which we'll examine shortly.

Inside the method, we fetch the child MatSelectHarness via the familiar getDropDown locator.

We forward the text filter to MatSelectHarness#clickOptions, which selects the first matching option for single-value dropdowns.

// favorite-ocean-creature-filters.ts
import { BaseHarnessFilters } from '@angular/cdk/testing';

export interface FavoriteOceanCreatureFilters extends BaseHarnessFilters {
  readonly text?: string | RegExp;
}
Enter fullscreen mode Exit fullscreen mode
Listing 10. A custom component harness filter.

Listing 10 demonstrates a straightforward custom component harness filter. We define an interface extending BaseHarnessFilters from @angular/cdk/testing. Earlier, we noted that base harness filters include optional ancestor and selector properties. We don't yet support them, as we only pass our text filter to the child select harness, as seen in Listing 9.

A more sensible approach might be to avoid extending the base filters until we handle their properties, or to use Omit — similar to how MatSelectHarness treats option and option group harness filters.

For illustrative purposes, we extend the full base harness filter here, meaning consumers can supply selector and ancestor filters even though they're ignored. We could implement these base filters using locators, but we'll leave that aside to keep the example straightforward.

We've now successfully added our first user interaction via a custom harness paired with a custom filter. NiceCreate a component harness for your tests with Angular CDK — figure 4

Narrowing down the displayed content

Our last test scenario checks that after choosing a preferred ocean animal, the resulting sentence reads My favorite ocean creature is <ocean creature display text>.

// favorite-ocean-creature.spec.ts
it('put your favorite ocean creature in a sentence', async () => {
  const octopus = 'Octopus';

  await harness.pickOption({ text: octopus });

  const text = await harness.getText();
  expect(text).toBe(`My favorite ocean creature is ${octopus}`);
});
Enter fullscreen mode Exit fullscreen mode
Listing 11. Verifying that our picked favorite ocean creature is used in a sentence.

In Listing 11, the test begins by invoking the pickOption method to select the octopus as the favourite. Once that selection is made, we pull the text content from the favourite ocean creature component and verify that it matches the expected sentence structure and contains the word Octopus.

// favorite-ocean-creature.harness.ts
import { AsyncFactoryFn, ComponentHarness } from '@angular/cdk/testing';
import { MatSelectHarness } from '@angular/material/select/testing';

import {
  FavoriteOceanCreatureFilters,
} from './favorite-ocean-creature-filters';

export class FavoriteOceanCreatureHarness extends ComponentHarness {
  static hostSelector = 'app-favorite-ocean-creature';

  protected getDropDown: AsyncFactoryFn<MatSelectHarness> =
    this.locatorFor(MatSelectHarness);

  async getText(): Promise<string> {
    const host = await this.host();
    const text = await host.text();
  const label = 'Pick your favorite';

    return text.replace(label, '').trim();
  }

  async pickOption(filter: FavoriteOceanCreatureFilters): Promise<void> {
    const select = await this.getDropDown();

    return select.clickOptions({ text: filter.text });
  }
}
Enter fullscreen mode Exit fullscreen mode
Listing 12. Filtering queried content to make consumption easy.

Listing 12 contains the methods that support the sentence assertion from Listing 11. The pickOption interaction method, along with the getDropDown locator and its filter, have already been covered.

Let's now examine the getText query method, which requires no arguments. We begin by fetching the DOM text content from the host element. This is done by obtaining a TestElement that points to the host element via the inherited ComponentHarness#host method.

Next, we retrieve the text by calling and awaiting the TestElement#text method on our host variable. After that, we strip out the label belonging to the favourite ocean creature picker—an internal detail that isn't relevant to the public testing surface our custom harness exposes.

Additionally, we trim the text because HTML tends to add extra whitespace around content. Handling this cleanup inside the harness shields several consumers from repeating the same sanitisation work, which might otherwise introduce false positives in scenarios that exercise the favourite ocean creature component.

The complete test suite

Let's close by reviewing the entire test suite.

// favorite-ocean-creature.spec.ts
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';

import {
  FavoriteOceanCreatureHarness,
} from './favorite-ocean-creature.harness';
import { FavoriteOceanCreatureModule } from './favorite-ocean-creature.module';

describe('Favorite ocean creature', () => {
  @Component({
    template: '<app-favorite-ocean-creature></app-favorite-ocean-creature>',
  })
  class TestHostComponent {}

  let harness: FavoriteOceanCreatureHarness;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      declarations: [TestHostComponent],
      imports: [NoopAnimationsModule, FavoriteOceanCreatureModule],
    });

    const fixture = TestBed.createComponent(TestHostComponent);
    const loader = TestbedHarnessEnvironment.loader(fixture);
    harness = await loader.getHarness(FavoriteOceanCreatureHarness);
  });

  it('manta ray is initially picked', async () => {
    const mantaRay = 'Manta ray';

    const pickedOceanCreature = await harness.getFavoriteOceanCreature();

    expect(pickedOceanCreature).toBe(mantaRay);
  });

  it('show awesome ocean creatures', async () => {
    const blueWhale = 'Blue whale';

    const options = await harness.getOptions();

    expect(options).toContain(blueWhale);
  });

  it('pick your favorite ocean creature', async () => {
    const greatWhiteShark = 'Great white shark';

    await harness.pickOption({ text: greatWhiteShark });

    const pickedOceanCreature = await harness.getFavoriteOceanCreature();
    expect(pickedOceanCreature).toBe(greatWhiteShark);
  });

  it('put your favorite ocean creature in a sentence', async () => {
    const octopus = 'Octopus';

    await harness.pickOption({ text: octopus });

    const text = await harness.getText();
    expect(text).toBe(`My favorite ocean creature is ${octopus}`);
  });
});
Enter fullscreen mode Exit fullscreen mode
Listing 13. The favorite ocean creature test suite.

For both our unit and integration tests, we still set up an Angular testing module via the test bed—but only to instantiate a component fixture backed by a test host component. That fixture is then supplied to the test bed harness environment so we can obtain a harness loader.

In this suite, we only need to load one component harness, whose reference is stored in the shared harness variable. The component fixture and the harness loader themselves are kept out of the test cases' reach.

Straightforward test cases

When we look at the tests in Listing 13, it's evident that they involve very few steps. Each test has only one or two lines covering the arrange, act, and assert phases. This brevity comes from the testing API our custom component harness offers.

No explicit change detection or task queue flushing

If you've written component tests for Angular apps or UI libraries with the test bed, you're likely aware of the usual need to call ComponentFixture#detectChanges, use tick within fakeAsync, or wait for ComponentFixture#whenStable to settle pending async work and let Angular's render cycle finish.

With component harnesses, none of those calls are necessary. The harness environment takes care of stabilising the component behind the scenes. The trade-off is that every harness method must be asynchronous and return a promise, but async-await handles that smoothly in both the harness methods and the consuming test cases.

End-to-end tests

Up to now, we've only demonstrated unit tests consuming our component harness. A key advantage of component harnesses is their portability: the same harness can be used in unit, integration, and end-to-end tests alike.

Let's take one of our test cases and adapt it into an end-to-end test.

import { ProtractorHarnessEnvironment } from '@angular/cdk/testing/protractor';
import { browser } from 'protractor';

import {
  FavoriteOceanCreatureHarness,
} from '../../src/app/favorite-ocean-creature/favorite-ocean-creature.harness';

describe('Favorite ocean creature app', () => {
  beforeEach(async () => {
    browser.get('/');
    const harnessLoader = ProtractorHarnessEnvironment.loader();
    harness = await harnessLoader.getHarness(FavoriteOceanCreatureHarness);
  });

  let harness: FavoriteOceanCreatureHarness;

  it('put your favorite ocean creature in a sentence', async () => {
    const octopus = 'Octopus';

    await harness.pickOption({ text: octopus });

    const text = await harness.getText();
    expect(text).toBe(`My favorite ocean creature is ${octopus}`);

  });
});
Enter fullscreen mode Exit fullscreen mode
Listing 14. End-to-end test suite using the favorite ocean creature component harness.

The e2e test in Listing 14 is an exact copy of the unit test version.

What differs is the setup. Since this test exercises the full application inside a browser, we don't configure an Angular testing module through TestBed.

Instead, we rely on Protractor to drive the browser and navigate to the URL where our component is served. We also swap TestbedHarnessEnvironment for ProtractorHarnessEnvironment to obtain a HarnessLoader.

Apart from that, nothing else changes. Once we have a harness loader, consuming the component harness is identical in both environments.

Minor discrepancies

I mentioned that component harnesses work across unit, integration, and end-to-end tests. While that holds true, running the test uncovers a few wrinkles.

Whitespace discrepancies

The first, and one that doesn't surface as a clear failure, is that the text filter used in the pickOption method doesn't behave as expected. Evidently, whitespace handling differs between unit and end-to-end tests in our setup.

// favorite-ocean-creatures.harness.ts
import { AsyncFactoryFn, ComponentHarness } from '@angular/cdk/testing';
import { MatSelectHarness } from '@angular/material/select/testing';

import {
  FavoriteOceanCreatureFilters,
} from './favorite-ocean-creature-filters';

export class FavoriteOceanCreatureHarness extends ComponentHarness {
  static hostSelector = 'app-favorite-ocean-creature';

  protected getDropDown: AsyncFactoryFn<MatSelectHarness> =
    this.locatorFor(MatSelectHarness);

  private coerceRegExp(textFilter: string | RegExp): RegExp {
    return typeof textFilter === 'string'
      ? new RegExp(`^\s*${textFilter}\s*$`)
      : textFilter;
  }

  async getText(): Promise<string> {
    const host = await this.host();
    const text = await host.text();
    const label = 'Pick your favorite';

    return text.replace(label, '').trim().replace(/\r?\n+/g, ' ');
  }

  async pickOption(filter: FavoriteOceanCreatureFilters): Promise<void> {
    const select = await this.getDropDown();

    await select.clickOptions({ text: this.coerceRegExp(filter.text || '') });
  }
}
Enter fullscreen mode Exit fullscreen mode
Listing 15. Supporting whitespace differences.

Remember that the text filter option supports either a string or a RegExp? That's because the MatSelect#clickOptions methods accept both, and now we'll need the latter.

To accommodate the whitespace variations, we convert a string text filter into a regular expression that permits optional whitespace on either side. The private coerceRegExp method shown in Listing 15 handles this conversion and always returns a regular expression.

Within the test case, we also rely on FavoriteOceanCreatureHarness#getText, which likewise exposes whitespace differences between unit and end-to-end runs. We bridge those by collapsing one or more newline characters into a single space.

Animation flakiness

According to the official component harnesses guide, Angular animations may need multiple change detection cycles and NgZone task intercepting before things settle.

In our unit tests, we import NoopAnimationsModule to turn off animations, which several Angular Material components rely on.

In the end-to-end tests, the app runs with actual browser animations because our AppModule imports BrowserAnimationsModule.

The test above has proven flaky, failing roughly half the time, because animations don't always finish before the dropdown option is clicked and the DOM element showing the selected value gets re-rendered.

Here we follow the guidance from the component harness guide. After clicking an option, we call ComponentHarness#forceStabilize as demonstrated in Listing 16.

// favorite-ocean-creatures.harness.ts
import { AsyncFactoryFn, ComponentHarness } from '@angular/cdk/testing';
import { MatSelectHarness } from '@angular/material/select/testing';

import {
  FavoriteOceanCreatureFilters,
} from './favorite-ocean-creature-filters';

export class FavoriteOceanCreatureHarness extends ComponentHarness {
  static hostSelector = 'app-favorite-ocean-creature';

  protected getDropDown: AsyncFactoryFn<MatSelectHarness> =
    this.locatorFor(MatSelectHarness);

  private coerceRegExp(textFilter: string | RegExp): RegExp {
    return typeof textFilter === 'string'
      ? new RegExp(`^\s*${textFilter}\s*$`)
      : textFilter;
  }

  async pickOption(filter: FavoriteOceanCreatureFilters): Promise<void> {
    const select = await this.getDropDown();

    await select.clickOptions({ text: this.coerceRegExp(filter.text || '') });
    await this.forceStabilize();
  }
}
Enter fullscreen mode Exit fullscreen mode
Listing 16. Forcing NgZone and change detection to stabilize after clicking a dropdown option.

With these two tweaks to our harness, the test passes consistently using the same test and harness code in both unit and end-to-end environments.

Protractor limitation

One unfortunate shortcoming, as of Angular CDK version 10.1, is that ProtractorHarnessEnvironment does not yet implement waitForTasksOutsideAngular.

That means async tasks running outside NgZone can't be intercepted or awaited by the Protractor harness environment. This gap can lead to false positives in Protractor tests or push us to write extra code inside the tests themselves, especially when non-Angular UI libraries are involved.

Wrapping up

I began by describing a component harness as a wrapper around a component or directive. In reality, component harnesses are far more versatile—they can serve as a testing API around any slice of DOM.

A harness isn't limited to wrapping a single component or element. As we've seen, it can model a hierarchy of harnesses, which might include multiple types, several instances of the same type, or a combination of both.

In our example, we built one harness that interacted with every part of the favourite ocean creature scenario. We could have split it into separate harnesses, or even created one that exposed an entire page or the whole application for interaction.

By the way, how many components does this use case actually contain? Did you notice that throughout this article, we never once touched an Angular component class or template? That's a testament to the test-as-a-user philosophy that component harnesses encourage.

Further areas to explore

My aim was to craft a mid-level case study that walked you through writing your own harness, using Angular Material's harnesses, composing child harnesses, and consuming a custom harness in both unit and end-to-end tests.

Naturally, there's plenty more to learn about component harnesses. Here are a few topics worth investigating:

  • Building custom locators
  • Implementing the static with method to load specific harnesses
  • Using TestElements to query and manipulate the DOM
  • Finding overlays outside the app's DOM, such as dropdowns and dialogs
  • Creating a custom HarnessEnvironment and a matching TestElement for non-Protractor e2e frameworks

We also skipped over testing the harnesses themselves. Should we write tests for our own testing APIs? Absolutely—but that's a story for another day. Until then, browse the Angular Components source to see how harness test suites are structured in practice.

Resources

Dive deeper into custom harness creation, writing consumers, and implementing a custom harness environment in the official component harness guide.

Learn how to apply Angular Material's component harnesses in your tests and why they're beneficial in the guide "Using Angular Material's component harnesses in your tests".

The favourite ocean creature app we equipped with a harness, plus its test suites, live in the GitHub repository LayZeeDK/ngx-component-harness.

Peer reviewers

Last but not least, a heartfelt thanks to my colleagues who helped review this piece: