skip to content

An Angular test using a CDK dropdown harness cannot find the options shown in an overlay and never sees the loading state; why, and how do you fix both?

level: seniorimportance: should knowfreq 27%

answer

  1. the loader's root is the fixture
  2. overlays live under the body
  3. a locator factory at the document root
  4. automatic change detection hides transients

basics

~10 s

The fixture loader and harness locators search below their root, but overlays attach to document.body, so use documentRootLocatorFactory() or documentRootLoader(fixture); harnesses auto-run change detection and wait for stability, so wrap transient-state checks in manualChangeDetection.

solid answer

~30 s

`TestbedHarnessEnvironment.loader(fixture)` searches under the fixture's root element, and a harness's own locators search under its host, but overlay panels are usually appended to `document.body`, outside both. The clean fix is inside the harness: find the options with `this.documentRootLocatorFactory().locatorForAll(...)`, so consumer tests keep calling `selectOption()`; from a test, `TestbedHarnessEnvironment.documentRootLoader(fixture)` does the same. The loading state is invisible because harnesses run change detection after every action and wait for stability, so `await dropdown.open()` resolves after loading has finished. To assert that intermediate state, wrap the steps in `manualChangeDetection`, call `fixture.detectChanges()` yourself, assert, then await `fixture.whenStable()` and assert the final state.

code

ts · 42 lines
ts
import { ComponentHarness, manualChangeDetection } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { TestBed } from '@angular/core/testing';
import { ProfileForm } from './profile-form';

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

  protected trigger = this.locatorFor('button.dropdown-trigger');
  // The panel is attached to document.body, so search from the document root.
  protected optionElements = this.documentRootLocatorFactory().locatorForAll('.dropdown-panel [role="option"]');
  protected loadingRow = this.documentRootLocatorFactory().locatorForOptional('.dropdown-panel .loading');

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

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

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

it('shows a loading row until options arrive', async () => {
  const fixture = TestBed.createComponent(ProfileForm);
  const dropdown = await TestbedHarnessEnvironment.loader(fixture).getHarness(OverlayDropdownHarness);

  await manualChangeDetection(async () => {
    await dropdown.open();
    fixture.detectChanges();
    expect(await dropdown.isLoading()).toBe(true);

    await fixture.whenStable();
    fixture.detectChanges();
    expect(await dropdown.isLoading()).toBe(false);
  });

  expect(await dropdown.getOptionLabels()).toContain('Chile');
});

go deeper

for a junior

Recall that the default loader only searches inside the fixture, and that documentRootLoader exists for content attached to document.body.

for a middle

Explain documentRootLocatorFactory inside a harness, and how automatic change detection before reads and after actions hides intermediate states.

for a senior

Diagnose which of the two mechanics is failing, fix overlay lookups in the harness rather than in tests, and use manualChangeDetection sparingly for transient states.

for a principal

Set design-system rules so overlay-based widgets ship harnesses that already handle document-root lookups, keeping consumer suites independent of rendering strategy.

## Two separate problems behind one symptom A dropdown harness test fails with "not found" or asserts on a state the user would never see. On this leaf the cause is almost always one of two harness mechanics: **where the loader searches** and **when change detection runs**. ## Problem 1: the options live outside the fixture `TestbedHarnessEnvironment.loader(fixture)` is rooted at the **fixture's root element** and only searches below it. Components that float above the page, such as dropdown panels, menus and dialogs, are often rendered into a container appended to **`document.body`**, for example by the CDK `Overlay` service. That DOM is not inside the fixture, so: - `loader.getHarness(OptionHarness)` from the test cannot find the options; - `this.locatorForAll('[role="option"]')` inside `DropdownHarness` cannot find them either, because harness locators search under the harness's **host element**. The fixes, in order of preference: 1. **Inside the harness**, locate overlay content with **`this.documentRootLocatorFactory()`**, which returns a `LocatorFactory` rooted at the document root with the same `locatorFor` / `locatorForOptional` / `locatorForAll` methods. Consumer tests keep calling `selectOption('Chile')` and never learn that an overlay exists. 2. **In the test**, when there is no harness method for it, use **`TestbedHarnessEnvironment.documentRootLoader(fixture)`** to get a loader rooted at the document. If several dropdowns can be open at once, scope the document-root search, for example by an id the dropdown puts on its panel and references from its trigger, so one harness does not pick up another dropdown's options. ## Problem 2: change detection runs for you By default the harness system handles **change detection automatically**: it runs change detection **before reading** state and **after every interaction** such as a click, and waits for the fixture to become **stable** before resolving. That is why harness tests rarely call `fixture.detectChanges()`, and it is usually what you want. It becomes a problem when the test needs an **intermediate** state. Suppose the dropdown loads its options asynchronously and shows a "Loading..." row meanwhile. `await dropdown.open()` waits for stability, so by the time it resolves the options have loaded and the loading row is gone: the test cannot observe it. The CDK's tools for this, all from `@angular/cdk/testing`: | Tool | What it does | Use it when | |---|---|---| | `manualChangeDetection(async () => { ... })` | disables automatic change detection for the block | asserting a state that exists only while work is pending | | `parallel(() => [...])` | resolves several harness calls like `Promise.all`, running change detection once before and once after | reading several properties at once | | `this.forceStabilize()`, inside a harness | flushes change detection and pending tasks again | edge cases such as animation events that need a second round | Inside `manualChangeDetection`, the test is responsible again: it calls `fixture.detectChanges()` to render the pending state, asserts it, then awaits `fixture.whenStable()` and asserts the final state. ## A diagnosis checklist - **Is the missing element inside the fixture's DOM?** If it is under `document.body`, switch to a document-root locator or loader. - **Is the harness method looking under its host** for something the component renders elsewhere? Fix the harness, not every test. - **Is the test asserting a transient state?** Wrap that part in `manualChangeDetection`. - **Is a read slow because of many sequential awaits?** Batch them with `parallel`. - **Are timers or async tasks scheduled outside Angular's tracking?** The harness's automatic waiting may not cover them; `waitForTasksOutsideAngular()` exists for tasks scheduled outside the Angular zone and only works when Zone.js and its test patches are present. ## Zoneless applications Since v21 new applications are zoneless by default. The harness system still runs change detection and waits for the fixture to be stable, so the automatic behaviour above holds. What changes is the escape hatch for work outside Angular's tracking: `waitForTasksOutsideAngular()` depends on Zone.js, so in a zoneless test the component should register its asynchronous work in ways Angular tracks, and the test awaits `fixture.whenStable()` instead. ## Why this is a senior topic Both problems are invisible in simple component tests and appear when a design system adds overlays or asynchronous loading. The fix that scales is to put the knowledge **in the harness**, where one change repairs every consuming test, and to reserve `manualChangeDetection` for the few tests that really are about intermediate states.

  • Why fix the overlay lookup inside the harness rather than switching every test to documentRootLoader?
    Where the panel renders is an implementation detail of the dropdown. If each test reaches the document root itself, every consumer learns that detail and breaks when it changes. Putting `documentRootLocatorFactory()` inside the harness keeps the knowledge in one file owned by the dropdown's authors, and consumer tests keep calling `selectOption()`.
  • What does parallel() change compared with awaiting three harness reads in a row?
    Awaiting reads one by one runs the automatic change detection around each call. `parallel(() => [a(), b(), c()])` resolves the calls like `Promise.all` but batches change detection so it runs once before and once after the whole group, which is faster and reads a consistent snapshot of the component.

saying these in an interview costs you the question

  • TestbedHarnessEnvironment.loader(fixture) searches the whole document
  • Harness locators can see anything appended to document.body
  • The fix is to call fixture.detectChanges() before every harness call
  • manualChangeDetection makes harness calls wait longer for stability
  • Every consumer test should switch to documentRootLoader for overlays