Skip to content

refactor(testing): add methods for accessing projected content in component container harnesses #20556

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 17 additions & 20 deletions src/cdk/testing/test-harnesses.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ testing.

`@angular/cdk/testing` contains infrastructure for creating and using component test harnesses. You
can create test harnesses for any component, ranging from small reusable widgets to full application
pages.
pages.

The component harness system supports multiple testing environments. You can use the same harness
implementation in both unit and end-to-end tests. This means that users only need to learn one API,
Expand All @@ -26,7 +26,7 @@ This document provides guidance for three types of developers:
1. [Test authors](#api-for-test-authors)
2. [Component harness authors](#api-for-component-harness-authors)
3. [Harness environment authors](#api-for-harness-environment-authors)

Since many developers fall into only one of these categories, the relevant APIs are broken out by
developer type in the sections below.

Expand Down Expand Up @@ -67,7 +67,7 @@ create `ComponentHarness` instances directly.
In most cases, you can create a `HarnessLoader` in the `beforeEach` block using
`TestbedHarnessEnvironment.loader(fixture)` and then use that `HarnessLoader` to create any
necessary `ComponentHarness` instances. The other methods cover special cases as shown in this
example:
example:

Consider a reusable dialog-button component that opens a dialog on click, containing the following
components, each with a corresponding harness:
Expand Down Expand Up @@ -136,7 +136,7 @@ are used to create `ComponentHarness` instances for elements under this root ele
| `getHarness<T extends ComponentHarness>(harnessType: ComponentHarnessConstructor<T> \| HarnessPredicate<T>): Promise<T>` | Searches for an instance of the given `ComponentHarness` class or `HarnessPredicate` below the root element of this `HarnessLoader` and returns an instance of the harness corresponding to the first matching element |
| `getAllHarnesses<T extends ComponentHarness>(harnessType: ComponentHarnessConstructor<T> \| HarnessPredicate<T>): Promise<T[]>` | Acts like `getHarness`, but returns an array of harness instances, one for each matching element, rather than just the first matching element |

Calls to `getHarness` and `getAllHarnesses` can either take `ComponentHarness` subclass or a
Calls to `getHarness` and `getAllHarnesses` can either take `ComponentHarness` subclass or a
`HarnessPredicate`. `HarnessPredicate` applies additional restrictions to the search (e.g. searching
for a button that has some particular text, etc). The
[details of `HarnessPredicate`](#filtering-harness-instances-with-harnesspredicate) are discussed in
Expand All @@ -149,7 +149,7 @@ created manually.

To support both unit and end-to-end tests, and to insulate tests against changes in
asynchronous behavior, almost all harness methods are asynchronous and return a `Promise`;
therefore, the Angular team recommends using
therefore, the Angular team recommends using
[ES2017 `async`/`await` syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)
to improve the test readability.

Expand Down Expand Up @@ -226,7 +226,7 @@ corresponding component. You can access the component's host element via the `ho
the `ComponentHarness` base class.

`ComponentHarness` additionally offers several methods for locating elements within the component's
DOM. These methods are `locatorFor`, `locatorForOptional`, and `locatorForAll`.
DOM. These methods are `locatorFor`, `locatorForOptional`, and `locatorForAll`.
Note, though, that these methods do not directly find elements. Instead, they _create functions_
that find elements. This approach safeguards against caching references to out-of-date elements. For
example, when an `ngIf` hides and then shows an element, the result is a new DOM element; using
Expand Down Expand Up @@ -395,7 +395,7 @@ for adding options.
| `add(description: string, predicate: (harness: T) => Promise<boolean>): HarnessPredicate<T>` | Creates a new `HarnessPredicate` that enforces all of the conditions of the current one, plus the new constraint specified by the `predicate` parameter. |

For example, when working with a menu it would likely be useful to add a way to filter based on
trigger text and to filter menu items based on their text:
trigger text and to filter menu items based on their text:

```ts
interface MyMenuHarnessFilters extends BaseHarnessFilters {
Expand Down Expand Up @@ -482,17 +482,14 @@ several APIs that can be used to create `HarnessLoader` instances for cases like
| `harnessLoaderForOptional(selector: string): Promise<HarnessLoader \| null>` | Gets a `Promise` for a `HarnessLoader` rooted at the first element matching the given selector, if no element is found the `Promise` resolves to `null`. |
| `harnessLoaderForAll(selector: string): Promise<HarnessLoader[]>` | Gets a `Promise` for a list of `HarnessLoader`, one rooted at each element matching the given selector. |


The `MyPopup` component discussed earlier is a good example of a component with arbitrary content
that users may want to load harnesses for. `MyPopupHarness` could add support for this:
that users may want to load harnesses for. `MyPopupHarness` could add support for this by
extending `ContentContainerComponentHarness`.

```ts
class MyPopupHarness extends ComponentHarness {
class MyPopupHarness extends ContentContainerComponentHarness<string> {
static hostSelector = 'my-popup';

/** Gets a `HarnessLoader` whose root element is the popup's content element. */
async getHarnessLoaderForContent(): Promise<HarnessLoader> {
return this.harnessLoaderFor('my-popup-content');
}
}
```

Expand Down Expand Up @@ -550,7 +547,7 @@ may need to explicitly wait for tasks outside `NgZone`, as this does not happen
Harness environment authors are developers who want to add support for using component harnesses in
additional testing environments. Out-of-the-box, Angular CDK's component harnesses can be used in
Protractor E2E tests and Karma unit tests. Developers can support additional environments by
creating custom implementations of `TestElement` and `HarnessEnvironment`.
creating custom implementations of `TestElement` and `HarnessEnvironment`.

#### Creating a `TestElement` implementation for the environment

Expand Down Expand Up @@ -585,15 +582,15 @@ implementing the `sendKeys` method, is that the key codes in the `TestKey`
enum likely differ from the key codes used in the test environment. Environment authors should
maintain a mapping from `TestKey` codes to the codes used in the particular testing environment.

The
The
[`UnitTestElement`](https://github.com/angular/components/blob/master/src/cdk/testing/testbed/unit-test-element.ts#L57)
and
and
[`ProtractorElement`](https://github.com/angular/components/blob/master/src/cdk/testing/protractor/protractor-element.ts#L67)
implementations in Angular CDK serve as good examples of implementations of this interface.

#### Creating a `HarnessEnvironemnt` implementation for the environment

Test authors use `HarnessEnvironemnt` to create component harness instances for use in tests.
Test authors use `HarnessEnvironemnt` to create component harness instances for use in tests.

`HarnessEnvironment` is an abstract class that must be extended to create a concrete subclass for
the new environment. When supporting a new test environment, you must create a `HarnessEnvironment`
Expand Down Expand Up @@ -623,8 +620,8 @@ require arguments to be passed. (e.g. the `loader` method on `TestbedHarnessEnvi
`ComponentFixture`, and the class provides additional static methods called `documentRootLoader` and
`harnessForFixture`).

The
The
[`TestbedHarnessEnvironment`](https://github.com/angular/components/blob/master/src/cdk/testing/testbed/testbed-harness-environment.ts#L20)
and
and
[`ProtractorHarnessEnvironment`](https://github.com/angular/components/blob/master/src/cdk/testing/protractor/protractor-harness-environment.ts#L16)
implementations in Angular CDK serve as good examples of implementations of this interface.
1 change: 0 additions & 1 deletion src/cdk/testing/tests/harnesses/sub-component-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export class SubComponentHarness extends ComponentHarness {
}
}

/** @dynamic */
export class SubComponentSpecialHarness extends SubComponentHarness {
static readonly hostSelector = 'test-sub.test-special';
}
34 changes: 32 additions & 2 deletions src/material-experimental/mdc-menu/testing/menu-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,20 @@
* found in the LICENSE file at https://angular.io/license
*/

import {ComponentHarness, HarnessPredicate, TestElement, TestKey} from '@angular/cdk/testing';
import {
ComponentHarness,
ContentContainerComponentHarness,
HarnessLoader,
HarnessPredicate,
HarnessQuery,
TestElement,
TestKey,
} from '@angular/cdk/testing';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {MenuHarnessFilters, MenuItemHarnessFilters} from '@angular/material/menu/testing';

/** Harness for interacting with an MDC-based mat-menu in tests. */
export class MatMenuHarness extends ComponentHarness {
export class MatMenuHarness extends ContentContainerComponentHarness<string> {
Copy link
Member

@devversion devversion Sep 14, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we implement ContentContainerComponentHarness rather? (for all similar harnesses too). That would help us enforce the methods being properly overridden and a consistent signature/API. Also looks like the base class isn't used at runtime anyway.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do a follow-up to try and make it so we don't have to override these methods, but we pass in something through the constructor instead.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds great 👍

/** The selector for the host element of a `MatMenu` instance. */
static hostSelector = '.mat-menu-trigger';

Expand Down Expand Up @@ -119,6 +127,28 @@ export class MatMenuHarness extends ComponentHarness {
return menu.clickItem(...subItemFilters as [Omit<MenuItemHarnessFilters, 'ancestor'>]);
}

async getChildLoader(selector: string): Promise<HarnessLoader> {
return (await this._getPanelLoader()).getChildLoader(selector);
}

async getAllChildLoaders(selector: string): Promise<HarnessLoader[]> {
return (await this._getPanelLoader()).getAllChildLoaders(selector);
}

async getHarness<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T> {
return (await this._getPanelLoader()).getHarness(query);
}

async getAllHarnesses<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T[]> {
return (await this._getPanelLoader()).getAllHarnesses(query);
}

/** Gets the element id for the content of the current step. */
private async _getPanelLoader(): Promise<HarnessLoader> {
const panelId = await this._getPanelId();
return this.documentRootLocatorFactory().harnessLoaderFor(`#${panelId}`);
}

/** Gets the menu panel associated with this menu. */
private async _getMenuPanel(): Promise<TestElement | null> {
const panelId = await this._getPanelId();
Expand Down
4 changes: 2 additions & 2 deletions src/material-experimental/mdc-table/testing/table-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing';
import {ContentContainerComponentHarness, HarnessPredicate} from '@angular/cdk/testing';
import {TableHarnessFilters, RowHarnessFilters} from './table-harness-filters';
import {
MatRowHarness,
Expand All @@ -25,7 +25,7 @@ export interface MatTableHarnessColumnsText {
}

/** Harness for interacting with an MDC-based mat-table in tests. */
export class MatTableHarness extends ComponentHarness {
export class MatTableHarness extends ContentContainerComponentHarness<string> {
/** The selector for the host element of a `MatTableHarness` instance. */
static hostSelector = '.mat-mdc-table';

Expand Down
28 changes: 26 additions & 2 deletions src/material-experimental/mdc-tabs/testing/tab-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@
* found in the LICENSE file at https://angular.io/license
*/

import {ComponentHarness, HarnessLoader, HarnessPredicate} from '@angular/cdk/testing';
import {
ComponentHarness,
ContentContainerComponentHarness,
HarnessLoader,
HarnessPredicate,
HarnessQuery,
} from '@angular/cdk/testing';
import {TabHarnessFilters} from './tab-harness-filters';

/** Harness for interacting with an MDC_based Angular Material tab in tests. */
export class MatTabHarness extends ComponentHarness {
export class MatTabHarness extends ContentContainerComponentHarness<string> {
/** The selector for the host element of a `MatTab` instance. */
static hostSelector = '.mat-mdc-tab';

Expand Down Expand Up @@ -68,12 +74,30 @@ export class MatTabHarness extends ComponentHarness {
/**
* Gets a `HarnessLoader` that can be used to load harnesses for components within the tab's
* content area.
* @deprecated Use `getHarness` or `getChildLoader` instead.
* @breaking-change 12.0.0
*/
async getHarnessLoaderForContent(): Promise<HarnessLoader> {
const contentId = await this._getContentId();
return this.documentRootLocatorFactory().harnessLoaderFor(`#${contentId}`);
}

async getChildLoader(selector: string): Promise<HarnessLoader> {
return (await this.getHarnessLoaderForContent()).getChildLoader(selector);
}

async getAllChildLoaders(selector: string): Promise<HarnessLoader[]> {
return (await this.getHarnessLoaderForContent()).getAllChildLoaders(selector);
}

async getHarness<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T> {
return (await this.getHarnessLoaderForContent()).getHarness(query);
}

async getAllHarnesses<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T[]> {
return (await this.getHarnessLoaderForContent()).getAllHarnesses(query);
}

/** Gets the element id for the content of the current tab. */
private async _getContentId(): Promise<string> {
const hostEl = await this.host();
Expand Down
5 changes: 1 addition & 4 deletions src/material/badge/testing/badge-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@ import {MatBadgePosition, MatBadgeSize} from '@angular/material/badge';
import {BadgeHarnessFilters} from './badge-harness-filters';


/**
* Harness for interacting with a standard Material badge in tests.
* @dynamic
*/
/** Harness for interacting with a standard Material badge in tests. */
export class MatBadgeHarness extends ComponentHarness {
static hostSelector = '.mat-badge';

Expand Down
9 changes: 3 additions & 6 deletions src/material/bottom-sheet/testing/bottom-sheet-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,11 @@
* found in the LICENSE file at https://angular.io/license
*/

import {ComponentHarness, HarnessPredicate, TestKey} from '@angular/cdk/testing';
import {ContentContainerComponentHarness, HarnessPredicate, TestKey} from '@angular/cdk/testing';
import {BottomSheetHarnessFilters} from './bottom-sheet-harness-filters';

/**
* Harness for interacting with a standard MatBottomSheet in tests.
* @dynamic
*/
export class MatBottomSheetHarness extends ComponentHarness {
/** Harness for interacting with a standard MatBottomSheet in tests. */
export class MatBottomSheetHarness extends ContentContainerComponentHarness<string> {
// Developers can provide a custom component or template for the
// bottom sheet. The canonical parent is the ".mat-bottom-sheet-container".
static hostSelector = '.mat-bottom-sheet-container';
Expand Down
4 changes: 2 additions & 2 deletions src/material/dialog/testing/dialog-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
* found in the LICENSE file at https://angular.io/license
*/

import {ComponentHarness, HarnessPredicate, TestKey} from '@angular/cdk/testing';
import {ContentContainerComponentHarness, HarnessPredicate, TestKey} from '@angular/cdk/testing';
import {DialogRole} from '@angular/material/dialog';
import {DialogHarnessFilters} from './dialog-harness-filters';

/** Harness for interacting with a standard `MatDialog` in tests. */
export class MatDialogHarness extends ComponentHarness {
export class MatDialogHarness extends ContentContainerComponentHarness<string> {
// Developers can provide a custom component or template for the
// dialog. The canonical dialog parent is the "MatDialogContainer".
/** The selector for the host element of a `MatDialog` instance. */
Expand Down
5 changes: 1 addition & 4 deletions src/material/divider/testing/divider-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@
import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing';
import {DividerHarnessFilters} from './divider-harness-filters';

/**
* Harness for interacting with a `mat-divider`.
* @dynamic
*/
/** Harness for interacting with a `mat-divider`. */
export class MatDividerHarness extends ComponentHarness {
static hostSelector = '.mat-divider';

Expand Down
30 changes: 22 additions & 8 deletions src/material/expansion/testing/expansion-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,31 @@
* found in the LICENSE file at https://angular.io/license
*/

import {ComponentHarness, HarnessLoader, HarnessPredicate} from '@angular/cdk/testing';
import {
ContentContainerComponentHarness,
HarnessLoader,
HarnessPredicate,
} from '@angular/cdk/testing';
import {ExpansionPanelHarnessFilters} from './expansion-harness-filters';

const EXPANSION_PANEL_CONTENT_SELECTOR = '.mat-expansion-panel-content';
/** Selectors for the various `mat-expansion-panel` sections that may contain user content. */
export const enum MatExpansionPanelSection {
HEADER = '.mat-expansion-panel-header',
TITLE = '.mat-expansion-panel-header-title',
DESCRIPTION = '.mat-expansion-panel-header-description',
CONTENT = '.mat-expansion-panel-content'
}

/** Harness for interacting with a standard mat-expansion-panel in tests. */
export class MatExpansionPanelHarness extends ComponentHarness {
export class MatExpansionPanelHarness extends
ContentContainerComponentHarness<MatExpansionPanelSection> {
static hostSelector = '.mat-expansion-panel';

private _header = this.locatorFor('.mat-expansion-panel-header');
private _title = this.locatorForOptional('.mat-expansion-panel-header-title');
private _description = this.locatorForOptional('.mat-expansion-panel-header-description');
private _header = this.locatorFor(MatExpansionPanelSection.HEADER);
private _title = this.locatorForOptional(MatExpansionPanelSection.TITLE);
private _description = this.locatorForOptional(MatExpansionPanelSection.DESCRIPTION);
private _expansionIndicator = this.locatorForOptional('.mat-expansion-indicator');
private _content = this.locatorFor(EXPANSION_PANEL_CONTENT_SELECTOR);
private _content = this.locatorFor(MatExpansionPanelSection.CONTENT);

/**
* Gets a `HarnessPredicate` that can be used to search for an expansion-panel
Expand Down Expand Up @@ -110,9 +121,12 @@ export class MatExpansionPanelHarness extends ComponentHarness {
/**
* Gets a `HarnessLoader` that can be used to load harnesses for
* components within the panel's content area.
* @deprecated Use either `getChildLoader(MatExpansionPanelSection.CONTENT)`, `getHarness` or
* `getAllHarnesses` instead.
* @breaking-change 12.0.0
*/
async getHarnessLoaderForContent(): Promise<HarnessLoader> {
return this.locatorFactory.harnessLoaderFor(EXPANSION_PANEL_CONTENT_SELECTOR);
return this.getChildLoader(MatExpansionPanelSection.CONTENT);
}

/** Focuses the panel. */
Expand Down
3 changes: 1 addition & 2 deletions src/material/expansion/testing/shared.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,7 @@ export function runHarnessTests(
it('should be able to get harness loader for content of panel', async () => {
const panel =
await loader.getHarness(expansionPanelHarness.with({selector: '#standalonePanel'}));
const contentHarnessLoader = await panel.getHarnessLoaderForContent();
const matchedHarnesses = await contentHarnessLoader.getAllHarnesses(TestContentHarness);
const matchedHarnesses = await panel.getAllHarnesses(TestContentHarness);
expect(matchedHarnesses.length).toBe(1);
expect(await matchedHarnesses[0].getText()).toBe('Part of expansion panel');
});
Expand Down
Loading