Skip to content

feat(cdk/testing): allow disabling & batching of change detection #20464

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 12 commits into from
Sep 23, 2020
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
3 changes: 3 additions & 0 deletions src/cdk/testing/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ ts_library(
],
),
module_name = "@angular/cdk/testing",
deps = [
"@npm//rxjs",
],
)

markdown_to_html(
Expand Down
122 changes: 122 additions & 0 deletions src/cdk/testing/change-detection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* @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.io/license
*/

import {BehaviorSubject, Subscription} from 'rxjs';

/** Represents the status of auto change detection. */
export interface AutoChangeDetectionStatus {
/** Whether auto change detection is disabled. */
isDisabled: boolean;
/**
* An optional callback, if present it indicates that change detection should be run immediately,
* while handling the status change. The callback should then be called as soon as change
* detection is done.
*/
onDetectChangesNow?: () => void;
}

/** Subject used to dispatch and listen for changes to the auto change detection status . */
const autoChangeDetectionSubject = new BehaviorSubject<AutoChangeDetectionStatus>({
isDisabled: false
});

/** The current subscription to `autoChangeDetectionSubject`. */
let autoChangeDetectionSubscription: Subscription | null;

/**
* The default handler for auto change detection status changes. This handler will be used if the
* specific environment does not install its own.
* @param status The new auto change detection status.
*/
function defaultAutoChangeDetectionHandler(status: AutoChangeDetectionStatus) {
status.onDetectChangesNow?.();
}

/**
* Allows a test `HarnessEnvironment` to install its own handler for auto change detection status
* changes.
* @param handler The handler for the auto change detection status.
*/
export function handleAutoChangeDetectionStatus(
handler: (status: AutoChangeDetectionStatus) => void) {
stopHandlingAutoChangeDetectionStatus();
autoChangeDetectionSubscription = autoChangeDetectionSubject.subscribe(handler);
}

/** Allows a `HarnessEnvironment` to stop handling auto change detection status changes. */
export function stopHandlingAutoChangeDetectionStatus() {
autoChangeDetectionSubscription?.unsubscribe();
autoChangeDetectionSubscription = null;
}

/**
* Batches together triggering of change detection over the duration of the given function.
* @param fn The function to call with batched change detection.
* @param triggerBeforeAndAfter Optionally trigger change detection once before and after the batch
* operation. If false, change detection will not be triggered.
* @return The result of the given function.
*/
async function batchChangeDetection<T>(fn: () => Promise<T>, triggerBeforeAndAfter: boolean) {
// If change detection batching is already in progress, just run the function.
if (autoChangeDetectionSubject.getValue().isDisabled) {
return await fn();
}

// If nothing is handling change detection batching, install the default handler.
if (!autoChangeDetectionSubscription) {
autoChangeDetectionSubject.subscribe(defaultAutoChangeDetectionHandler);
}

if (triggerBeforeAndAfter) {
await new Promise(resolve => autoChangeDetectionSubject.next({
isDisabled: true,
onDetectChangesNow: resolve,
}));
// The function passed in may throw (e.g. if the user wants to make an expectation of an error
// being thrown. If this happens, we need to make sure we still re-enable change detection, so
// we wrap it in a `finally` block.
try {
return await fn();
} finally {
await new Promise(resolve => autoChangeDetectionSubject.next({
isDisabled: false,
onDetectChangesNow: resolve,
}));
}
} else {
autoChangeDetectionSubject.next({isDisabled: true});
// The function passed in may throw (e.g. if the user wants to make an expectation of an error
// being thrown. If this happens, we need to make sure we still re-enable change detection, so
// we wrap it in a `finally` block.
try {
return await fn();
} finally {
autoChangeDetectionSubject.next({isDisabled: false});
}
}
}

/**
* Disables the harness system's auto change detection for the duration of the given function.
* @param fn The function to disable auto change detection for.
* @return The result of the given function.
*/
export async function manualChangeDetection<T>(fn: () => Promise<T>) {
return batchChangeDetection(fn, false);
}

/**
* Resolves the given list of async values in parallel (i.e. via Promise.all) while batching change
* detection over the entire operation such that change detection occurs exactly once before
* resolving the values and once after.
* @param values A getter for the async values to resolve in parallel with batched change detection.
* @return The resolved values.
*/
export async function parallel<T>(values: () => Iterable<T | PromiseLike<T>>) {
return batchChangeDetection(() => Promise.all(values()), true);
}
8 changes: 6 additions & 2 deletions src/cdk/testing/component-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {parallel} from './change-detection';
import {TestElement} from './test-element';

/** An async function that returns a promise when called. */
Expand Down Expand Up @@ -486,7 +487,10 @@ export class HarnessPredicate<T extends ComponentHarness> {
* @return A list of harnesses that satisfy this predicate.
*/
async filter(harnesses: T[]): Promise<T[]> {
const results = await Promise.all(harnesses.map(h => this.evaluate(h)));
if (harnesses.length === 0) {
return [];
}
const results = await parallel(() => harnesses.map(h => this.evaluate(h)));
return harnesses.filter((_, i) => results[i]);
}

Expand All @@ -497,7 +501,7 @@ export class HarnessPredicate<T extends ComponentHarness> {
* and resolves to false otherwise.
*/
async evaluate(harness: T): Promise<boolean> {
const results = await Promise.all(this._predicates.map(p => p(harness)));
const results = await parallel(() => this._predicates.map(p => p(harness)));
return results.reduce((combined, current) => combined && current, true);
}

Expand Down
11 changes: 6 additions & 5 deletions src/cdk/testing/harness-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {parallel} from './change-detection';
import {
AsyncFactoryFn,
ComponentHarness,
Expand Down Expand Up @@ -165,15 +166,15 @@ export abstract class HarnessEnvironment<E> implements HarnessLoader, LocatorFac
const skipSelectorCheck = (elementQueries.length === 0 && harnessTypes.size === 1) ||
harnessQueries.length === 0;

const perElementMatches = await Promise.all(rawElements.map(async rawElement => {
const perElementMatches = await parallel(() => rawElements.map(async rawElement => {
const testElement = this.createTestElement(rawElement);
const allResultsForElement = await Promise.all(
// For each query, get `null` if it doesn't match, or a `TestElement` or
// `ComponentHarness` as appropriate if it does match. This gives us everything that
// matches the current raw element, but it may contain duplicate entries (e.g. multiple
// `TestElement` or multiple `ComponentHarness` of the same type.
allQueries.map(query =>
this._getQueryResultForElement(query, rawElement, testElement, skipSelectorCheck)));
// matches the current raw element, but it may contain duplicate entries (e.g.
// multiple `TestElement` or multiple `ComponentHarness` of the same type).
allQueries.map(query => this._getQueryResultForElement(
query, rawElement, testElement, skipSelectorCheck)));
return _removeDuplicateQueryResults(allResultsForElement);
}));
return ([] as any).concat(...perElementMatches);
Expand Down
1 change: 1 addition & 0 deletions src/cdk/testing/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export * from './harness-environment';
export * from './test-element';
export * from './element-dimensions';
export * from './text-filtering';
export * from './change-detection';
60 changes: 51 additions & 9 deletions src/cdk/testing/test-harnesses.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,29 @@ provide convenience methods on their `ComponentHarness` subclass to facilitate t
`HarnessPredicate` instances. However, if the harness author's API is not sufficient, they can be
created manually.

#### Change detection
By default, test harnesses will run Angular's change detection before reading the state of a DOM
element and after interacting with a DOM element. While convenient in most cases, there may be times
that you need finer-grained control over change detection. For example, you may want to check the
state of a component while an async operation is pending. In these cases you can use the
`manualChangeDetection` function to disable automatic handling of change detection for a block of
code. For example:

```ts
it('checks state while async action is in progress', async () => {
const buttonHarness = loader.getHarness(MyButtonHarness);
await manualChangeDetection(async () => {
await buttonHarness.click();
fixture.detectChanges();
// Check expectations while async click operation is in progress.
expect(isProgressSpinnerVisible()).toBe(true);
await fixture.whenStable();
// Check expectations after async click operation complete.
expect(isProgressSpinnerVisible()).toBe(false);
});
});
```

#### Working with asynchronous component harness methods

To support both unit and end-to-end tests, and to insulate tests against changes in
Expand All @@ -154,21 +177,23 @@ therefore, the Angular team recommends using
to improve the test readability.

Note that `await` statements block the execution of your test until the associated `Promise`
resolves. When reading multiple properties of a harness it may not be necessary to block on the
first before asking for the next, in these cases use `Promise.all` to parallelize.

For example, consider the following example of reading both the `checked` and `indeterminate` state
off of a checkbox harness:
resolves. Occasionally, you may want to perform multiple actions simultaneously and wait until
they're all done rather than performing each action sequentially. For example, reading multiple
properties off a single component. In these situations use the `parallel` function to parallelize
the operations. The parallel function works similarly to `Promise.all`, while also optimizing change
detection, so it is not run an excessive number of times. The following code demonstrates how you
can read multiple properties from a harness with `parallel`:

```ts
it('reads properties in parallel', async () => {
const checkboxHarness = loader.getHarness(MyCheckboxHarness);
const [checked, indeterminate] = await Promise.all([
// Read the checked and intermediate properties simultaneously.
const [checked, indeterminate] = await parallel(() => [
checkboxHarness.isChecked(),
checkboxHarness.isIndeterminate()
]);

// ... make some assertions
expect(checked).toBe(false);
expect(indeterminate).toBe(true);
});
```

Expand Down Expand Up @@ -421,7 +446,7 @@ class MyMenuHarness extends ComponentHarness {
protected getPopupHarness = this.locatorFor(MyPopupHarness);

/** Gets the text of the menu trigger. */
getTriggerText(): Promise<string> {
async getTriggerText(): Promise<string> {
const popupHarness = await this.getPopupHarness();
return popupHarness.getTriggerText();
}
Expand Down Expand Up @@ -625,3 +650,20 @@ The
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.

#### Handling auto change detection status
In order to support the `manualChangeDetection` and `parallel` APIs, your environment should install
a handler for the auto change detection status.

When your environment wants to start handling the auto change detection status it can call
`handleAutoChangeDetectionStatus(handler)`. The handler function will receive a
`AutoChangeDetectionStatus` which has two properties:

* `isDisabled: boolean` - Indicates whether auto change detection is currently disabled. When true,
your environment's `forceStabilize` method should act as a no-op. This allows users to trigger
change detection manually instead.
* `onDetectChangesNow?: () => void` - If this optional callback is specified, your environment
should trigger change detection immediately and call the callback when change detection finishes.

If your environment wants to stop handling auto change detection status it can call
`stopHandlingAutoChangeDetectionStatus()`.
63 changes: 57 additions & 6 deletions src/cdk/testing/testbed/testbed-harness-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
import {
ComponentHarness,
ComponentHarnessConstructor,
handleAutoChangeDetectionStatus,
HarnessEnvironment,
HarnessLoader,
stopHandlingAutoChangeDetectionStatus,
TestElement
} from '@angular/cdk/testing';
import {ComponentFixture, flush} from '@angular/core/testing';
Expand All @@ -30,6 +32,50 @@ const defaultEnvironmentOptions: TestbedHarnessEnvironmentOptions = {
queryFn: (selector: string, root: Element) => root.querySelectorAll(selector)
};

/** Whether auto change detection is currently disabled. */
let disableAutoChangeDetection = false;

/**
* The set of non-destroyed fixtures currently being used by `TestbedHarnessEnvironment` instances.
*/
const activeFixtures = new Set<ComponentFixture<unknown>>();

/**
* Installs a handler for change detection batching status changes for a specific fixture.
* @param fixture The fixture to handle change detection batching for.
*/
function installAutoChangeDetectionStatusHandler(fixture: ComponentFixture<unknown>) {
if (!activeFixtures.size) {
handleAutoChangeDetectionStatus(({isDisabled, onDetectChangesNow}) => {
disableAutoChangeDetection = isDisabled;
if (onDetectChangesNow) {
Promise.all(Array.from(activeFixtures).map(detectChanges)).then(onDetectChangesNow);
}
});
}
activeFixtures.add(fixture);
}

/**
* Uninstalls a handler for change detection batching status changes for a specific fixture.
* @param fixture The fixture to stop handling change detection batching for.
*/
function uninstallAutoChangeDetectionStatusHandler(fixture: ComponentFixture<unknown>) {
activeFixtures.delete(fixture);
if (!activeFixtures.size) {
stopHandlingAutoChangeDetectionStatus();
}
}

/**
* Triggers change detection for a specific fixture.
* @param fixture The fixture to trigger change detection for.
*/
async function detectChanges(fixture: ComponentFixture<unknown>) {
fixture.detectChanges();
await fixture.whenStable();
}

/** A `HarnessEnvironment` implementation for Angular's Testbed. */
export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
/** Whether the environment has been destroyed. */
Expand All @@ -46,7 +92,11 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
super(rawRootElement);
this._options = {...defaultEnvironmentOptions, ...options};
this._taskState = TaskStateZoneInterceptor.setup();
_fixture.componentRef.onDestroy(() => this._destroyed = true);
installAutoChangeDetectionStatusHandler(_fixture);
_fixture.componentRef.onDestroy(() => {
uninstallAutoChangeDetectionStatusHandler(_fixture);
this._destroyed = true;
});
}

/** Creates a `HarnessLoader` rooted at the given fixture's root element. */
Expand Down Expand Up @@ -87,12 +137,13 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
}

async forceStabilize(): Promise<void> {
if (this._destroyed) {
throw Error('Harness is attempting to use a fixture that has already been destroyed.');
}
if (!disableAutoChangeDetection) {
if (this._destroyed) {
throw Error('Harness is attempting to use a fixture that has already been destroyed.');
}

this._fixture.detectChanges();
await this._fixture.whenStable();
await detectChanges(this._fixture);
}
}

async waitForTasksOutsideAngular(): Promise<void> {
Expand Down
Loading