Skip to content

Commit 88e70ec

Browse files
mmalerbawagnermaciel
authored andcommitted
feat(cdk/testing): allow disabling & batching of change detection (angular#20464)
* feat(cdk/testing): allow disabling & batching of change detection * feat(cdk/testing): address feedback * feat(cdk/testing): split batchCD into 2 functions, `parallel` and `noAutoChangeDetection` * feat(cdk/testing): address feedback * feat(cdk/testing): fix some CI issues * feat(cdk/testing): fix circular dep * feat(cdk/testing): update docs * feat(cdk/testing): address feedback * feat(cdk/testing): address comments * feat(cdk/testing): fix `parallel` to take a function * feat(cdk/testing): ensure change detection completes even if test code throws * feat(cdk/testing): fix misplaced tests
1 parent 587e3df commit 88e70ec

File tree

9 files changed

+308
-23
lines changed

9 files changed

+308
-23
lines changed

src/cdk/testing/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ ts_library(
1212
],
1313
),
1414
module_name = "@angular/cdk/testing",
15+
deps = [
16+
"@npm//rxjs",
17+
],
1518
)
1619

1720
markdown_to_html(

src/cdk/testing/change-detection.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {BehaviorSubject, Subscription} from 'rxjs';
10+
11+
/** Represents the status of auto change detection. */
12+
export interface AutoChangeDetectionStatus {
13+
/** Whether auto change detection is disabled. */
14+
isDisabled: boolean;
15+
/**
16+
* An optional callback, if present it indicates that change detection should be run immediately,
17+
* while handling the status change. The callback should then be called as soon as change
18+
* detection is done.
19+
*/
20+
onDetectChangesNow?: () => void;
21+
}
22+
23+
/** Subject used to dispatch and listen for changes to the auto change detection status . */
24+
const autoChangeDetectionSubject = new BehaviorSubject<AutoChangeDetectionStatus>({
25+
isDisabled: false
26+
});
27+
28+
/** The current subscription to `autoChangeDetectionSubject`. */
29+
let autoChangeDetectionSubscription: Subscription | null;
30+
31+
/**
32+
* The default handler for auto change detection status changes. This handler will be used if the
33+
* specific environment does not install its own.
34+
* @param status The new auto change detection status.
35+
*/
36+
function defaultAutoChangeDetectionHandler(status: AutoChangeDetectionStatus) {
37+
status.onDetectChangesNow?.();
38+
}
39+
40+
/**
41+
* Allows a test `HarnessEnvironment` to install its own handler for auto change detection status
42+
* changes.
43+
* @param handler The handler for the auto change detection status.
44+
*/
45+
export function handleAutoChangeDetectionStatus(
46+
handler: (status: AutoChangeDetectionStatus) => void) {
47+
stopHandlingAutoChangeDetectionStatus();
48+
autoChangeDetectionSubscription = autoChangeDetectionSubject.subscribe(handler);
49+
}
50+
51+
/** Allows a `HarnessEnvironment` to stop handling auto change detection status changes. */
52+
export function stopHandlingAutoChangeDetectionStatus() {
53+
autoChangeDetectionSubscription?.unsubscribe();
54+
autoChangeDetectionSubscription = null;
55+
}
56+
57+
/**
58+
* Batches together triggering of change detection over the duration of the given function.
59+
* @param fn The function to call with batched change detection.
60+
* @param triggerBeforeAndAfter Optionally trigger change detection once before and after the batch
61+
* operation. If false, change detection will not be triggered.
62+
* @return The result of the given function.
63+
*/
64+
async function batchChangeDetection<T>(fn: () => Promise<T>, triggerBeforeAndAfter: boolean) {
65+
// If change detection batching is already in progress, just run the function.
66+
if (autoChangeDetectionSubject.getValue().isDisabled) {
67+
return await fn();
68+
}
69+
70+
// If nothing is handling change detection batching, install the default handler.
71+
if (!autoChangeDetectionSubscription) {
72+
autoChangeDetectionSubject.subscribe(defaultAutoChangeDetectionHandler);
73+
}
74+
75+
if (triggerBeforeAndAfter) {
76+
await new Promise(resolve => autoChangeDetectionSubject.next({
77+
isDisabled: true,
78+
onDetectChangesNow: resolve,
79+
}));
80+
// The function passed in may throw (e.g. if the user wants to make an expectation of an error
81+
// being thrown. If this happens, we need to make sure we still re-enable change detection, so
82+
// we wrap it in a `finally` block.
83+
try {
84+
return await fn();
85+
} finally {
86+
await new Promise(resolve => autoChangeDetectionSubject.next({
87+
isDisabled: false,
88+
onDetectChangesNow: resolve,
89+
}));
90+
}
91+
} else {
92+
autoChangeDetectionSubject.next({isDisabled: true});
93+
// The function passed in may throw (e.g. if the user wants to make an expectation of an error
94+
// being thrown. If this happens, we need to make sure we still re-enable change detection, so
95+
// we wrap it in a `finally` block.
96+
try {
97+
return await fn();
98+
} finally {
99+
autoChangeDetectionSubject.next({isDisabled: false});
100+
}
101+
}
102+
}
103+
104+
/**
105+
* Disables the harness system's auto change detection for the duration of the given function.
106+
* @param fn The function to disable auto change detection for.
107+
* @return The result of the given function.
108+
*/
109+
export async function manualChangeDetection<T>(fn: () => Promise<T>) {
110+
return batchChangeDetection(fn, false);
111+
}
112+
113+
/**
114+
* Resolves the given list of async values in parallel (i.e. via Promise.all) while batching change
115+
* detection over the entire operation such that change detection occurs exactly once before
116+
* resolving the values and once after.
117+
* @param values A getter for the async values to resolve in parallel with batched change detection.
118+
* @return The resolved values.
119+
*/
120+
export async function parallel<T>(values: () => Iterable<T | PromiseLike<T>>) {
121+
return batchChangeDetection(() => Promise.all(values()), true);
122+
}

src/cdk/testing/component-harness.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {parallel} from './change-detection';
910
import {TestElement} from './test-element';
1011

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

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

src/cdk/testing/harness-environment.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

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

168-
const perElementMatches = await Promise.all(rawElements.map(async rawElement => {
169+
const perElementMatches = await parallel(() => rawElements.map(async rawElement => {
169170
const testElement = this.createTestElement(rawElement);
170171
const allResultsForElement = await Promise.all(
171172
// For each query, get `null` if it doesn't match, or a `TestElement` or
172173
// `ComponentHarness` as appropriate if it does match. This gives us everything that
173-
// matches the current raw element, but it may contain duplicate entries (e.g. multiple
174-
// `TestElement` or multiple `ComponentHarness` of the same type.
175-
allQueries.map(query =>
176-
this._getQueryResultForElement(query, rawElement, testElement, skipSelectorCheck)));
174+
// matches the current raw element, but it may contain duplicate entries (e.g.
175+
// multiple `TestElement` or multiple `ComponentHarness` of the same type).
176+
allQueries.map(query => this._getQueryResultForElement(
177+
query, rawElement, testElement, skipSelectorCheck)));
177178
return _removeDuplicateQueryResults(allResultsForElement);
178179
}));
179180
return ([] as any).concat(...perElementMatches);

src/cdk/testing/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export * from './harness-environment';
1111
export * from './test-element';
1212
export * from './element-dimensions';
1313
export * from './text-filtering';
14+
export * from './change-detection';

src/cdk/testing/test-harnesses.md

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,29 @@ provide convenience methods on their `ComponentHarness` subclass to facilitate t
145145
`HarnessPredicate` instances. However, if the harness author's API is not sufficient, they can be
146146
created manually.
147147

148+
#### Change detection
149+
By default, test harnesses will run Angular's change detection before reading the state of a DOM
150+
element and after interacting with a DOM element. While convenient in most cases, there may be times
151+
that you need finer-grained control over change detection. For example, you may want to check the
152+
state of a component while an async operation is pending. In these cases you can use the
153+
`manualChangeDetection` function to disable automatic handling of change detection for a block of
154+
code. For example:
155+
156+
```ts
157+
it('checks state while async action is in progress', async () => {
158+
const buttonHarness = loader.getHarness(MyButtonHarness);
159+
await manualChangeDetection(async () => {
160+
await buttonHarness.click();
161+
fixture.detectChanges();
162+
// Check expectations while async click operation is in progress.
163+
expect(isProgressSpinnerVisible()).toBe(true);
164+
await fixture.whenStable();
165+
// Check expectations after async click operation complete.
166+
expect(isProgressSpinnerVisible()).toBe(false);
167+
});
168+
});
169+
```
170+
148171
#### Working with asynchronous component harness methods
149172

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

156179
Note that `await` statements block the execution of your test until the associated `Promise`
157-
resolves. When reading multiple properties of a harness it may not be necessary to block on the
158-
first before asking for the next, in these cases use `Promise.all` to parallelize.
159-
160-
For example, consider the following example of reading both the `checked` and `indeterminate` state
161-
off of a checkbox harness:
180+
resolves. Occasionally, you may want to perform multiple actions simultaneously and wait until
181+
they're all done rather than performing each action sequentially. For example, reading multiple
182+
properties off a single component. In these situations use the `parallel` function to parallelize
183+
the operations. The parallel function works similarly to `Promise.all`, while also optimizing change
184+
detection, so it is not run an excessive number of times. The following code demonstrates how you
185+
can read multiple properties from a harness with `parallel`:
162186

163187
```ts
164188
it('reads properties in parallel', async () => {
165189
const checkboxHarness = loader.getHarness(MyCheckboxHarness);
166-
const [checked, indeterminate] = await Promise.all([
190+
// Read the checked and intermediate properties simultaneously.
191+
const [checked, indeterminate] = await parallel(() => [
167192
checkboxHarness.isChecked(),
168193
checkboxHarness.isIndeterminate()
169194
]);
170-
171-
// ... make some assertions
195+
expect(checked).toBe(false);
196+
expect(indeterminate).toBe(true);
172197
});
173198
```
174199

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

423448
/** Gets the text of the menu trigger. */
424-
getTriggerText(): Promise<string> {
449+
async getTriggerText(): Promise<string> {
425450
const popupHarness = await this.getPopupHarness();
426451
return popupHarness.getTriggerText();
427452
}
@@ -625,3 +650,20 @@ The
625650
and
626651
[`ProtractorHarnessEnvironment`](https://github.com/angular/components/blob/master/src/cdk/testing/protractor/protractor-harness-environment.ts#L16)
627652
implementations in Angular CDK serve as good examples of implementations of this interface.
653+
654+
#### Handling auto change detection status
655+
In order to support the `manualChangeDetection` and `parallel` APIs, your environment should install
656+
a handler for the auto change detection status.
657+
658+
When your environment wants to start handling the auto change detection status it can call
659+
`handleAutoChangeDetectionStatus(handler)`. The handler function will receive a
660+
`AutoChangeDetectionStatus` which has two properties:
661+
662+
* `isDisabled: boolean` - Indicates whether auto change detection is currently disabled. When true,
663+
your environment's `forceStabilize` method should act as a no-op. This allows users to trigger
664+
change detection manually instead.
665+
* `onDetectChangesNow?: () => void` - If this optional callback is specified, your environment
666+
should trigger change detection immediately and call the callback when change detection finishes.
667+
668+
If your environment wants to stop handling auto change detection status it can call
669+
`stopHandlingAutoChangeDetectionStatus()`.

src/cdk/testing/testbed/testbed-harness-environment.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
import {
1010
ComponentHarness,
1111
ComponentHarnessConstructor,
12+
handleAutoChangeDetectionStatus,
1213
HarnessEnvironment,
1314
HarnessLoader,
15+
stopHandlingAutoChangeDetectionStatus,
1416
TestElement
1517
} from '@angular/cdk/testing';
1618
import {ComponentFixture, flush} from '@angular/core/testing';
@@ -30,6 +32,50 @@ const defaultEnvironmentOptions: TestbedHarnessEnvironmentOptions = {
3032
queryFn: (selector: string, root: Element) => root.querySelectorAll(selector)
3133
};
3234

35+
/** Whether auto change detection is currently disabled. */
36+
let disableAutoChangeDetection = false;
37+
38+
/**
39+
* The set of non-destroyed fixtures currently being used by `TestbedHarnessEnvironment` instances.
40+
*/
41+
const activeFixtures = new Set<ComponentFixture<unknown>>();
42+
43+
/**
44+
* Installs a handler for change detection batching status changes for a specific fixture.
45+
* @param fixture The fixture to handle change detection batching for.
46+
*/
47+
function installAutoChangeDetectionStatusHandler(fixture: ComponentFixture<unknown>) {
48+
if (!activeFixtures.size) {
49+
handleAutoChangeDetectionStatus(({isDisabled, onDetectChangesNow}) => {
50+
disableAutoChangeDetection = isDisabled;
51+
if (onDetectChangesNow) {
52+
Promise.all(Array.from(activeFixtures).map(detectChanges)).then(onDetectChangesNow);
53+
}
54+
});
55+
}
56+
activeFixtures.add(fixture);
57+
}
58+
59+
/**
60+
* Uninstalls a handler for change detection batching status changes for a specific fixture.
61+
* @param fixture The fixture to stop handling change detection batching for.
62+
*/
63+
function uninstallAutoChangeDetectionStatusHandler(fixture: ComponentFixture<unknown>) {
64+
activeFixtures.delete(fixture);
65+
if (!activeFixtures.size) {
66+
stopHandlingAutoChangeDetectionStatus();
67+
}
68+
}
69+
70+
/**
71+
* Triggers change detection for a specific fixture.
72+
* @param fixture The fixture to trigger change detection for.
73+
*/
74+
async function detectChanges(fixture: ComponentFixture<unknown>) {
75+
fixture.detectChanges();
76+
await fixture.whenStable();
77+
}
78+
3379
/** A `HarnessEnvironment` implementation for Angular's Testbed. */
3480
export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
3581
/** Whether the environment has been destroyed. */
@@ -46,7 +92,11 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment<Element> {
4692
super(rawRootElement);
4793
this._options = {...defaultEnvironmentOptions, ...options};
4894
this._taskState = TaskStateZoneInterceptor.setup();
49-
_fixture.componentRef.onDestroy(() => this._destroyed = true);
95+
installAutoChangeDetectionStatusHandler(_fixture);
96+
_fixture.componentRef.onDestroy(() => {
97+
uninstallAutoChangeDetectionStatusHandler(_fixture);
98+
this._destroyed = true;
99+
});
50100
}
51101

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

89139
async forceStabilize(): Promise<void> {
90-
if (this._destroyed) {
91-
throw Error('Harness is attempting to use a fixture that has already been destroyed.');
92-
}
140+
if (!disableAutoChangeDetection) {
141+
if (this._destroyed) {
142+
throw Error('Harness is attempting to use a fixture that has already been destroyed.');
143+
}
93144

94-
this._fixture.detectChanges();
95-
await this._fixture.whenStable();
145+
await detectChanges(this._fixture);
146+
}
96147
}
97148

98149
async waitForTasksOutsideAngular(): Promise<void> {

0 commit comments

Comments
 (0)