skip to content

How would you write an Angular CDK ComponentHarness for a custom dropdown so that consuming tests never depend on its internal DOM?

level: seniorimportance: should knowfreq 30%

answer

  1. extend the base class, set a selector
  2. protected locators, public intentions
  3. return values, not elements
  4. a static with() for filtering

basics

~10 s

Extend ComponentHarness with hostSelector 'app-dropdown', keep locatorFor/locatorForOptional/locatorForAll protected, expose intent-level async methods like selectOption(label) that return plain values, and add a static with() returning a HarnessPredicate.

solid answer

~40 s

I extend `ComponentHarness` from `@angular/cdk/testing` and set `static hostSelector = 'app-dropdown'`. The markup knowledge goes into **protected** locators: `locatorFor` for the trigger that must exist, `locatorForOptional` for the panel that is absent when closed, `locatorForAll` for the options. The public API is what a user does or sees: `open()`, `isOpen()`, `getSelectedLabel()`, `getOptionLabels()`, `selectOption(label)`, each async and returning strings or booleans, never internal `TestElement`s. `selectOption` opens the dropdown itself and throws a clear error for an unknown label. A static `with()` builds a `HarnessPredicate` from `BaseHarnessFilters` plus a `label` filter via `addOption`. The harness ships next to the component, so a markup change touches one file.

code

ts · 51 lines
ts
import { BaseHarnessFilters, ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';

export interface DropdownHarnessFilters extends BaseHarnessFilters {
  label?: string | RegExp;
}

export class DropdownHarness extends ComponentHarness {
  static hostSelector = 'app-dropdown';

  static with(options: DropdownHarnessFilters = {}): HarnessPredicate<DropdownHarness> {
    return new HarnessPredicate(DropdownHarness, options).addOption(
      'label',
      options.label,
      (harness, label) => HarnessPredicate.stringMatches(harness.getSelectedLabel(), label),
    );
  }

  protected trigger = this.locatorFor('button.dropdown-trigger');
  protected panel = this.locatorForOptional('[role="listbox"]');
  protected optionElements = this.locatorForAll('[role="option"]');

  async getSelectedLabel(): Promise<string> {
    return (await this.trigger()).text();
  }

  async isOpen(): Promise<boolean> {
    return (await this.panel()) !== null;
  }

  async open(): Promise<void> {
    if (!(await this.isOpen())) {
      await (await this.trigger()).click();
    }
  }

  async getOptionLabels(): Promise<string[]> {
    await this.open();
    const options = await this.optionElements();
    return Promise.all(options.map((option) => option.text()));
  }

  async selectOption(label: string): Promise<void> {
    await this.open();
    for (const option of await this.optionElements()) {
      if ((await option.text()) === label) {
        return option.click();
      }
    }
    throw new Error(`No dropdown option labelled "${label}"`);
  }
}

go deeper

for a junior

Recall the skeleton: extend ComponentHarness, set static hostSelector, create locators with locatorFor, and write async methods that use them.

for a middle

Explain why locators are functions, which locator variant fits a required, optional or repeated element, and how with() builds a HarnessPredicate.

for a senior

Design the API around user intent, keep markup protected, return plain values, and make operations like selectOption self-sufficient with clear failures.

for a principal

Treat harnesses as part of a component library's public contract: versioned with the component, tested against it, and reviewed whenever the template changes.

## The goal A shared **custom dropdown** (`<app-dropdown>`) appears in dozens of forms. Its authors want every consuming test to pick an option without knowing that the trigger is a `button.dropdown-trigger`, that options are `li` elements with `role="option"`, or that the list only exists while the dropdown is open. A **component harness** is the place to put that knowledge once. ## Building blocks from `@angular/cdk/testing` - **`ComponentHarness`**: the base class. A subclass must define the static **`hostSelector`**, normally the component's selector. - **`host()`**: resolves to the `TestElement` of the component's host element. - **Locator factories** on the base class create *functions* that find things under the host at call time: - `locatorFor(...)` resolves to the first match and **rejects** if there is none; - `locatorForOptional(...)` resolves to the first match or **`null`**; - `locatorForAll(...)` resolves to an **array**, possibly empty. Each accepts CSS selectors, other harness classes, or `HarnessPredicate`s. - **`TestElement`**: the environment-neutral element wrapper (`click()`, `text()`, `sendKeys()`, `getAttribute()`, `hasClass()`, `isFocused()` and more). - **`HarnessPredicate`** and **`BaseHarnessFilters`**: the filtering machinery behind a static `with()` method. ## Design rules for the dropdown harness 1. **Keep locators `protected`.** They encode the markup; making them public invites tests to depend on it. 2. **Expose user-level operations**: `open()`, `isOpen()`, `getSelectedLabel()`, `getOptionLabels()`, `selectOption(label)`. Name them after what a user does or sees. 3. **Return plain values**, such as strings, booleans and arrays, rather than `TestElement`s. The guide's rule: do not expose `TestElement` for internal elements, only for elements the consumer defines, such as the host. 4. **Make operations idempotent and self-sufficient.** `selectOption` opens the dropdown if needed, so callers do not have to know that options only exist while it is open. 5. **Fail with a clear message** when the requested option does not exist, instead of a generic "not found". 6. **Add a static `with()`** that returns a `HarnessPredicate<DropdownHarness>`, extending `BaseHarnessFilters` (which already provides `selector` and `ancestor`) with dropdown-specific filters such as `label`. `addOption(name, value, predicate)` skips the predicate when the option is `undefined`, and `HarnessPredicate.stringMatches` treats a string as an exact match and a `RegExp` as a partial one. 7. **Compose sub-harnesses** when the dropdown is built from other harnessed components: `this.locatorFor(OptionHarness)` returns a harness instead of an element. ## Choosing each locator | Harness method | Locator | Why | |---|---|---| | `getSelectedLabel()` | `locatorFor('button.dropdown-trigger')` | the trigger must always exist; a missing one is a real failure | | `isOpen()` | `locatorForOptional('[role="listbox"]')` | the panel is legitimately absent when closed; `null` is an answer | | `getOptionLabels()` / `selectOption()` | `locatorForAll('[role="option"]')` | zero or many options are both valid states | Because locators are functions, every call re-queries the DOM. When an `@if` block removes and recreates the options list, the harness still finds the **current** elements instead of detached ones. ## Where the component's authors keep it - Ship the harness **next to the component**, in the same library, and review it together with template changes. - Test the harness itself against the real component, so a template change that breaks it fails in the library, not in consuming apps. - If the option panel moves into a CDK overlay attached to `document.body`, only the harness changes: its locators switch to `this.documentRootLocatorFactory()`, and consumer tests are untouched. ## Testing the harness itself A harness is code that other teams rely on, so it deserves its own tests in the component's library: - Render the real `Dropdown` in a small host component and drive it **only** through `DropdownHarness`, asserting both the harness's return values and the host's bound state. - Cover each public method in the states it must handle: closed, open, no options, an unknown label. - Cover `with()` filters, including an unset option (matches everything) and a `RegExp` label (partial match). - Run these tests whenever the dropdown's template changes, so a broken harness fails in the library's CI, before any consuming app upgrades. ## Anti-patterns - A harness that is a thin bag of getters returning `TestElement`s: it moves the coupling instead of removing it. - Methods that mirror implementation (`clickLiAtIndex(2)`) instead of intent (`selectOption('Chile')`). - Caching an element in a field in the constructor instead of using locator functions. - Reaching for `document.querySelector` inside the harness, which does not work in every harness environment.

  • The dropdown's option panel moves into a CDK overlay attached to document.body. What changes?
    Only the harness. Locators created from the harness itself search under its host element, so they no longer see the panel. The harness switches those locators to `this.documentRootLocatorFactory().locatorForAll('[role="option"]')` and similar, which search from the document root. Consumer tests keep calling `selectOption()` unchanged, which is the point of having the harness.
  • Why should getOptionLabels() return string[] rather than the option TestElements?
    Returning `TestElement`s lets consumers call `click()`, `hasClass()` or `getAttribute()` on internal elements, which ties their tests to the dropdown's markup again. Plain values describe what a user perceives, and operations like `selectOption` cover what a user does. The CDK guide reserves exposing `TestElement`s for elements the consumer defines, such as the host.

A harness is like a vending machine's front panel: customers press 'B4' and get a snack, while only the technician knows which spiral turns behind the glass. Rewire the spirals and customers notice nothing, because they never touched them.

saying these in an interview costs you the question

  • Public locators are fine because tests can then click anything they need
  • A harness should cache elements in fields when it is constructed
  • locatorFor returns null when the element is missing
  • hostSelector should point at the dropdown's internal trigger button
  • Callers must open the dropdown before calling selectOption