Skip to content

feat(material/testing): Extend Angular harness testing functionality #30960

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
30 changes: 30 additions & 0 deletions src/cdk/testing/component-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,17 @@ export interface HarnessLoader {
*/
getHarnessOrNull<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T | null>;

/**
* Searches for an instance of the component corresponding to the given harness type under the
* `HarnessLoader`'s root element, and returns a `ComponentHarness` for the instance on the page
* at the given index. If no matching component exists at that index, an error is thrown.
* @param query A query for a harness to create
* @param index The zero-indexed offset of the matching component instance to return
* @return An instance of the given harness type.
* @throws If a matching component instance can't be found at the given index.
*/
getHarnessAtIndex<T extends ComponentHarness>(query: HarnessQuery<T>, index: number): Promise<T>;

/**
* Searches for all instances of the component corresponding to the given harness type under the
* `HarnessLoader`'s root element, and returns a list `ComponentHarness` for each instance.
Expand All @@ -114,6 +125,14 @@ export interface HarnessLoader {
*/
getAllHarnesses<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T[]>;

/**
* Searches for all instances of the component corresponding to the given harness type under the
* `HarnessLoader`'s root element, and returns the total count of all matching components.
* @param query A query for a harness to create
* @return An integer indicating the number of instances that were found.
*/
countHarnesses<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<number>;

/**
* Searches for an instance of the component corresponding to the given harness type under the
* `HarnessLoader`'s root element, and returns a boolean indicating if any were found.
Expand Down Expand Up @@ -425,10 +444,21 @@ export abstract class ContentContainerComponentHarness<S extends string = string
return (await this.getRootHarnessLoader()).getHarnessOrNull(query);
}

async getHarnessAtIndex<T extends ComponentHarness>(
query: HarnessQuery<T>,
index: number,
): Promise<T> {
return (await this.getRootHarnessLoader()).getHarnessAtIndex(query, index);
}

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

async countHarnesses<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<number> {
return (await this.getRootHarnessLoader()).countHarnesses(query);
}

async hasHarness<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<boolean> {
return (await this.getRootHarnessLoader()).hasHarness(query);
}
Expand Down
20 changes: 20 additions & 0 deletions src/cdk/testing/harness-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,31 @@ export abstract class HarnessEnvironment<E> implements HarnessLoader, LocatorFac
return this.locatorForOptional(query)();
}

// Implemented as part of the `HarnessLoader` interface.
async getHarnessAtIndex<T extends ComponentHarness>(
query: HarnessQuery<T>,
offset: number,
): Promise<T> {
if (offset < 0) {
throw Error('Index must not be negative');
}
const harnesses = await this.locatorForAll(query)();
if (offset >= harnesses.length) {
throw Error(`No harness was located at index ${offset}`);
}
return harnesses[offset];
}

// Implemented as part of the `HarnessLoader` interface.
getAllHarnesses<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<T[]> {
return this.locatorForAll(query)();
}

// Implemented as part of the `HarnessLoader` interface.
async countHarnesses<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<number> {
return (await this.locatorForAll(query)()).length;
}

// Implemented as part of the `HarnessLoader` interface.
async hasHarness<T extends ComponentHarness>(query: HarnessQuery<T>): Promise<boolean> {
return (await this.locatorForOptional(query)()) !== null;
Expand Down
5 changes: 4 additions & 1 deletion src/cdk/testing/test-harnesses.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,12 @@ are used to create `ComponentHarness` instances for elements under this root ele
| `getChildLoader(selector: string): Promise<HarnessLoader>` | Searches for an element matching the given selector below the root element of this `HarnessLoader`, and returns a new `HarnessLoader` rooted at the first matching element |
| `getAllChildLoaders(selector: string): Promise<HarnessLoader[]>` | Acts like `getChildLoader`, but returns an array of `HarnessLoader` instances, one for each matching element, rather than just the first matching element |
| `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 |
| `getHarnessAtIndex<T extends ComponentHarness>(harnessType: ComponentHarnessConstructor<T> \| HarnessPredicate<T>, index: number): Promise<T>` | Acts like `getHarness`, but returns an instance of the harness corresponding to the matching element with the given index (zero-indexed) |
| `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 |
| `countHarnesses<T extends ComponentHarness>(harnessType: ComponentHarnessConstructor<T> \| HarnessPredicate<T>): Promise<number>` | Counts the number of instances of the given `ComponentHarness` class or `HarnessPredicate` below the root element of this `HarnessLoader`, and returns the result. |
| `hasHarness<T extends ComponentHarness>(harnessType: ComponentHarnessConstructor<T> \| HarnessPredicate<T>): Promise<boolean>` | Returns true if an instance of the given `ComponentHarness` class or `HarnessPredicate` exists below the root element of this `HarnessLoader` |

Calls to `getHarness` and `getAllHarnesses` can either take `ComponentHarness` subclass or a
Calls to the harness functions 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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
MatEndDate,
MatStartDate,
} from '../../datepicker';
import {MatFormFieldModule} from '../../form-field';
import {MatCalendarHarness} from './calendar-harness';
import {
MatDateRangeInputHarness,
Expand All @@ -34,7 +35,14 @@ describe('matDateRangeInputHarness', () => {

it('should load all date range input harnesses', async () => {
const inputs = await loader.getAllHarnesses(MatDateRangeInputHarness);
expect(inputs.length).toBe(2);
expect(inputs.length).toBe(3);
});

it('should load date range input with a specific label', async () => {
const inputs = await loader.getAllHarnesses(
MatDateRangeInputHarness.with({label: 'Date range'}),
);
expect(inputs.length).toBe(1);
});

it('should get whether the input is disabled', async () => {
Expand Down Expand Up @@ -261,13 +269,22 @@ describe('matDateRangeInputHarness', () => {
<input matStartDate>
<input matEndDate>
</mat-date-range-input>

<mat-form-field>
<mat-label>Date range</mat-label>
<mat-date-range-input basic>
<input matStartDate>
<input matEndDate>
</mat-date-range-input>
</mat-form-field>
`,
imports: [
MatNativeDateModule,
MatDateRangeInput,
MatStartDate,
MatEndDate,
MatDateRangePicker,
MatFormFieldModule,
FormsModule,
],
})
Expand Down
39 changes: 34 additions & 5 deletions src/material/datepicker/testing/date-range-input-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export class MatEndDateHarness extends MatDatepickerInputHarnessBase {
export class MatDateRangeInputHarness extends DatepickerTriggerHarnessBase {
static hostSelector = '.mat-date-range-input';

private readonly floatingLabelSelector = '.mdc-floating-label';

/**
* Gets a `HarnessPredicate` that can be used to search for a `MatDateRangeInputHarness`
* that meets certain criteria.
Expand All @@ -57,11 +59,13 @@ export class MatDateRangeInputHarness extends DatepickerTriggerHarnessBase {
static with(
options: DateRangeInputHarnessFilters = {},
): HarnessPredicate<MatDateRangeInputHarness> {
return new HarnessPredicate(MatDateRangeInputHarness, options).addOption(
'value',
options.value,
(harness, value) => HarnessPredicate.stringMatches(harness.getValue(), value),
);
return new HarnessPredicate(MatDateRangeInputHarness, options)
.addOption('value', options.value, (harness, value) =>
HarnessPredicate.stringMatches(harness.getValue(), value),
)
.addOption('label', options.label, (harness, label) => {
return HarnessPredicate.stringMatches(harness.getLabel(), label);
});
}

/** Gets the combined value of the start and end inputs, including the separator. */
Expand All @@ -87,6 +91,31 @@ export class MatDateRangeInputHarness extends DatepickerTriggerHarnessBase {
return this.locatorFor(MatEndDateHarness)();
}

/** Gets the floating label text for the range input, if it exists. */
async getLabel(): Promise<string | null> {
// Copied from MatFormFieldControlHarness since this class cannot extend two classes
const documentRootLocator = await this.documentRootLocatorFactory();
const labelId = await (await this.host()).getAttribute('aria-labelledby');
const hostId = await (await this.host()).getAttribute('id');

if (labelId) {
// First option, try to fetch the label using the `aria-labelledby`
// attribute.
const labelEl = await await documentRootLocator.locatorForOptional(
`${this.floatingLabelSelector}[id="${labelId}"]`,
)();
return labelEl ? labelEl.text() : null;
} else if (hostId) {
// Fallback option, try to match the id of the input with the `for`
// attribute of the label.
const labelEl = await await documentRootLocator.locatorForOptional(
`${this.floatingLabelSelector}[for="${hostId}"]`,
)();
return labelEl ? labelEl.text() : null;
}
return null;
}

/** Gets the separator text between the values of the two inputs. */
async getSeparator(): Promise<string> {
return (await this.locatorFor('.mat-date-range-input-separator')()).text();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {MatFormFieldControlHarnessFilters} from '@angular/material/form-field/testing/control';
import {BaseHarnessFilters} from '@angular/cdk/testing';

/** A set of criteria that can be used to filter a list of datepicker input instances. */
export interface DatepickerInputHarnessFilters extends BaseHarnessFilters {
export interface DatepickerInputHarnessFilters extends MatFormFieldControlHarnessFilters {
/** Filters based on the value of the input. */
value?: string | RegExp;
/** Filters based on the placeholder text of the input. */
Expand Down Expand Up @@ -43,7 +44,7 @@ export interface CalendarCellHarnessFilters extends BaseHarnessFilters {
}

/** A set of criteria that can be used to filter a list of date range input instances. */
export interface DateRangeInputHarnessFilters extends BaseHarnessFilters {
export interface DateRangeInputHarnessFilters extends MatFormFieldControlHarnessFilters {
/** Filters based on the value of the input. */
value?: string | RegExp;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {ComponentHarnessConstructor, HarnessPredicate} from '@angular/cdk/testing';
import {MatFormFieldControlHarness} from '../../form-field/testing/control';
import {MatFormFieldControlHarness} from '@angular/material/form-field/testing/control';
import {DatepickerInputHarnessFilters} from './datepicker-harness-filters';

/** Sets up the filter predicates for a datepicker input harness. */
Expand All @@ -21,6 +21,9 @@ export function getInputPredicate<T extends MatDatepickerInputHarnessBase>(
})
.addOption('placeholder', options.placeholder, (harness, placeholder) => {
return HarnessPredicate.stringMatches(harness.getPlaceholder(), placeholder);
})
.addOption('label', options.label, (harness, label) => {
return HarnessPredicate.stringMatches(harness.getLabel(), label);
});
}

Expand All @@ -36,6 +39,11 @@ export abstract class MatDatepickerInputHarnessBase extends MatFormFieldControlH
return (await this.host()).getProperty<boolean>('required');
}

/** Gets the floating label text for the input, if it exists. */
async getLabel(): Promise<string | null> {
return await this._getFloatingLabelText();
}

/** Gets the value of the input. */
async getValue(): Promise<string> {
// The "value" property of the native input is always defined.
Expand Down
45 changes: 32 additions & 13 deletions src/material/datepicker/testing/datepicker-input-harness.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {ComponentFixture, TestBed} from '@angular/core/testing';
import {FormsModule} from '@angular/forms';
import {DateAdapter, MATERIAL_ANIMATIONS, MatNativeDateModule} from '../../core';
import {MatDatepickerModule} from '../../datepicker';
import {MatFormFieldModule} from '../../form-field';
import {MatInputModule} from '../../input';
import {MatCalendarHarness} from './calendar-harness';
import {MatDatepickerInputHarness} from './datepicker-input-harness';

Expand All @@ -27,6 +29,13 @@ describe('MatDatepickerInputHarness', () => {
expect(inputs.length).toBe(2);
});

it('should load datepicker input with a specific label', async () => {
const selects = await loader.getAllHarnesses(
MatDatepickerInputHarness.with({label: 'Pick a date'}),
);
expect(selects.length).toBe(1);
});

it('should filter inputs based on their value', async () => {
fixture.componentInstance.date = new Date(2020, 0, 1, 12, 0, 0);
fixture.changeDetectorRef.markForCheck();
Expand Down Expand Up @@ -187,21 +196,31 @@ describe('MatDatepickerInputHarness', () => {

@Component({
template: `
<input
id="basic"
matInput
[matDatepicker]="picker"
(dateChange)="dateChangeCount = dateChangeCount + 1"
[(ngModel)]="date"
[min]="minDate"
[max]="maxDate"
[disabled]="disabled"
[required]="required"
placeholder="Type a date">
<mat-datepicker #picker [touchUi]="touchUi"></mat-datepicker>
<mat-form-field>
<mat-label>Pick a date</mat-label>
<input
id="basic"
matInput
[matDatepicker]="picker"
(dateChange)="dateChangeCount = dateChangeCount + 1"
[(ngModel)]="date"
[min]="minDate"
[max]="maxDate"
[disabled]="disabled"
[required]="required"
placeholder="Type a date">
<mat-datepicker #picker [touchUi]="touchUi"></mat-datepicker>
</mat-form-field>

<input id="no-datepicker" matDatepicker>
`,
imports: [MatNativeDateModule, MatDatepickerModule, FormsModule],
imports: [
MatNativeDateModule,
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
FormsModule,
],
})
class DatepickerInputHarnessTest {
date: Date | null = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {BaseHarnessFilters} from '@angular/cdk/testing';

/**
* A set of criteria shared by any class derived from `MatFormFieldControlHarness`, that can be
* used to filter a list of those components.
*/
export interface MatFormFieldControlHarnessFilters extends BaseHarnessFilters {
/** Filters based on the text of the form field's floating label. */
label?: string | RegExp;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,30 @@ import {ComponentHarness} from '@angular/cdk/testing';
* Base class for custom form-field control harnesses. Harnesses for
* custom controls with form-fields need to implement this interface.
*/
export abstract class MatFormFieldControlHarness extends ComponentHarness {}
export abstract class MatFormFieldControlHarness extends ComponentHarness {
private readonly floatingLabelSelector = '.mdc-floating-label';

/** Gets the text content of the floating label, if it exists. */
protected async _getFloatingLabelText(): Promise<string | null> {
const documentRootLocator = await this.documentRootLocatorFactory();
const labelId = await (await this.host()).getAttribute('aria-labelledby');
const hostId = await (await this.host()).getAttribute('id');

if (labelId) {
// First option, try to fetch the label using the `aria-labelledby`
// attribute.
const labelEl = await await documentRootLocator.locatorForOptional(
`${this.floatingLabelSelector}[id="${labelId}"]`,
)();
return labelEl ? labelEl.text() : null;
} else if (hostId) {
// Fallback option, try to match the id of the input with the `for`
// attribute of the label.
const labelEl = await await documentRootLocator.locatorForOptional(
`${this.floatingLabelSelector}[for="${hostId}"]`,
)();
return labelEl ? labelEl.text() : null;
}
return null;
}
}
1 change: 1 addition & 0 deletions src/material/form-field/testing/control/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
*/

export * from './form-field-control-harness';
export * from './form-field-control-harness-filters';
4 changes: 2 additions & 2 deletions src/material/input/testing/input-harness-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {BaseHarnessFilters} from '@angular/cdk/testing';
import {MatFormFieldControlHarnessFilters} from '@angular/material/form-field/testing/control';

/** A set of criteria that can be used to filter a list of `MatInputHarness` instances. */
export interface InputHarnessFilters extends BaseHarnessFilters {
export interface InputHarnessFilters extends MatFormFieldControlHarnessFilters {
/** Filters based on the value of the input. */
value?: string | RegExp;
/** Filters based on the placeholder text of the input. */
Expand Down
Loading
Loading