Skip to content

Commit 4ea9353

Browse files
committed
New internal testing helpers: waitFor, waitForAll
Over the years, we've gradually aligned on a set of best practices for for testing concurrent React features in this repo. The default in most cases is to use `act`, the same as you would do when testing a real React app. However, because we're testing React itself, as opposed to an app that uses React, our internal tests sometimes need to make assertions on intermediate states that `act` intentionally disallows. For those cases, we built a custom set of Jest assertion matchers that provide greater control over the concurrent work queue. It works by mocking the Scheduler package. (When we eventually migrate to using native postTask, it would probably work by stubbing that instead.) A problem with these helpers that we recently discovered is, because they are synchronous function calls, they aren't sufficient if the work you need to flush is scheduled in a microtask — we don't control the microtask queue, and can't mock it. `act` addresses this problem by encouraging you to await the result of the `act` call. (It's not currently required to await, but in future versions of React it likely will be.) It will then continue flushing work until both the microtask queue and the Scheduler queue is exhausted. We can follow a similar strategy for our custom test helpers, by replacing the current set of synchronous helpers with a corresponding set of async ones: - `expect(Scheduler).toFlushAndYield(log)` -> `await waitForAll(log)` - `expect(Scheduler).toFlushAndYieldThrough(log)` -> `await waitFor(log)` - `expect(Scheduler).toFlushUntilNextPaint(log)` -> `await waitForPaint(log)` These APIs are inspired by the existing best practice for writing e2e React tests. Rather than mock all task queues, in an e2e test you set up a timer loop and wait for the UI to match an expecte condition. Although we are mocking _some_ of the task queues in our tests, the general principle still holds: it makes it less likely that our tests will diverge from real world behavior in an actual browser. In this commit, I've implemented the new testing helpers and converted one of the Suspense tests to use them. In subsequent steps, I'll codemod the rest of our test suite.
1 parent acb8879 commit 4ea9353

File tree

4 files changed

+141
-12
lines changed

4 files changed

+141
-12
lines changed

packages/jest-react/src/JestReact.js

+127-4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import {REACT_ELEMENT_TYPE, REACT_FRAGMENT_TYPE} from 'shared/ReactSymbols';
99

1010
import isArray from 'shared/isArray';
11+
import * as SchedulerMock from 'scheduler/unstable_mock';
12+
import {diff} from 'jest-diff';
1113

1214
export {act} from './internalAct';
1315

@@ -27,19 +29,20 @@ function captureAssertion(fn) {
2729
return {pass: true};
2830
}
2931

30-
function assertYieldsWereCleared(root) {
31-
const Scheduler = root._Scheduler;
32+
function assertYieldsWereCleared(Scheduler) {
3233
const actualYields = Scheduler.unstable_clearYields();
3334
if (actualYields.length !== 0) {
34-
throw new Error(
35+
const error = Error(
3536
'Log of yielded values is not empty. ' +
3637
'Call expect(ReactTestRenderer).unstable_toHaveYielded(...) first.',
3738
);
39+
Error.captureStackTrace(error, assertYieldsWereCleared);
40+
throw error;
3841
}
3942
}
4043

4144
export function unstable_toMatchRenderedOutput(root, expectedJSX) {
42-
assertYieldsWereCleared(root);
45+
assertYieldsWereCleared(root._SchedulerMock);
4346
const actualJSON = root.toJSON();
4447

4548
let actualJSX;
@@ -121,3 +124,123 @@ function jsonChildrenToJSXChildren(jsonChildren) {
121124
}
122125
return null;
123126
}
127+
128+
export async function waitFor(expectedLog) {
129+
assertYieldsWereCleared(SchedulerMock);
130+
131+
// Create the error object before doing any async work, to get a better
132+
// stack trace.
133+
const error = new Error();
134+
Error.captureStackTrace(error, waitFor);
135+
136+
const actualLog = [];
137+
do {
138+
// Wait until end of current task/microtask.
139+
await null;
140+
if (SchedulerMock.unstable_hasPendingWork()) {
141+
SchedulerMock.unstable_flushNumberOfYields(
142+
expectedLog.length - actualLog.length,
143+
);
144+
actualLog.push(...SchedulerMock.unstable_clearYields());
145+
if (expectedLog.length > actualLog.length) {
146+
// Continue flushing until we've logged the expected number of items.
147+
} else {
148+
// Once we've reached the expected sequence, wait one more microtask to
149+
// flush any remaining synchronous work.
150+
await null;
151+
break;
152+
}
153+
} else {
154+
// There's no pending work, even after a microtask.
155+
break;
156+
}
157+
} while (true);
158+
159+
if (areArraysEqual(actualLog, expectedLog)) {
160+
return;
161+
}
162+
163+
error.message = `
164+
Expected sequence of events did not occur.
165+
166+
${diff(expectedLog, actualLog)}
167+
`;
168+
throw error;
169+
}
170+
171+
export async function waitForAll(expectedLog) {
172+
assertYieldsWereCleared(SchedulerMock);
173+
174+
// Create the error object before doing any async work, to get a better
175+
// stack trace.
176+
const error = new Error();
177+
Error.captureStackTrace(error, waitFor);
178+
179+
do {
180+
// Wait until end of current task/microtask.
181+
await null;
182+
if (!SchedulerMock.unstable_hasPendingWork()) {
183+
// There's no pending work, even after a microtask. Stop flushing.
184+
break;
185+
}
186+
SchedulerMock.unstable_flushAllWithoutAsserting();
187+
} while (true);
188+
189+
const actualLog = SchedulerMock.unstable_clearYields();
190+
if (areArraysEqual(actualLog, expectedLog)) {
191+
return;
192+
}
193+
194+
error.message = `
195+
Expected sequence of events did not occur.
196+
197+
${diff(expectedLog, actualLog)}
198+
`;
199+
throw error;
200+
}
201+
202+
// TODO: This name is a bit misleading currently because it will stop as soon as
203+
// React yields for any reason, not just for a paint. I've left it this way for
204+
// now because that's how untable_flushUntilNextPaint already worked, but maybe
205+
// we should split these use cases into separate APIs.
206+
export async function waitForPaint(expectedLog) {
207+
assertYieldsWereCleared(SchedulerMock);
208+
209+
// Create the error object before doing any async work, to get a better
210+
// stack trace.
211+
const error = new Error();
212+
Error.captureStackTrace(error, waitFor);
213+
214+
// Wait until end of current task/microtask.
215+
await null;
216+
if (SchedulerMock.unstable_hasPendingWork()) {
217+
// Flush until React yields.
218+
SchedulerMock.unstable_flushUntilNextPaint();
219+
// Wait one more microtask to flush any remaining synchronous work.
220+
await null;
221+
}
222+
223+
const actualLog = SchedulerMock.unstable_clearYields();
224+
if (areArraysEqual(actualLog, expectedLog)) {
225+
return;
226+
}
227+
228+
error.message = `
229+
Expected sequence of events did not occur.
230+
231+
${diff(expectedLog, actualLog)}
232+
`;
233+
throw error;
234+
}
235+
236+
function areArraysEqual(a, b) {
237+
if (a.length !== b.length) {
238+
return false;
239+
}
240+
for (let i = 0; i < a.length; i++) {
241+
if (a[i] !== b[i]) {
242+
return false;
243+
}
244+
}
245+
return true;
246+
}

packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js

+10-6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ let Fragment;
33
let ReactNoop;
44
let Scheduler;
55
let act;
6+
let waitFor;
7+
let waitForAll;
8+
let waitForPaint;
69
let Suspense;
710
let getCacheForType;
811

@@ -19,6 +22,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1922
Scheduler = require('scheduler');
2023
act = require('jest-react').act;
2124
Suspense = React.Suspense;
25+
const JestReact = require('jest-react');
26+
waitFor = JestReact.waitFor;
27+
waitForAll = JestReact.waitForAll;
28+
waitForPaint = JestReact.waitForPaint;
2229

2330
getCacheForType = React.unstable_getCacheForType;
2431

@@ -208,7 +215,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
208215
React.startTransition(() => {
209216
ReactNoop.render(<Foo />);
210217
});
211-
expect(Scheduler).toFlushAndYieldThrough([
218+
await waitFor([
212219
'Foo',
213220
'Bar',
214221
// A suspends
@@ -226,7 +233,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
226233

227234
// Even though the promise has resolved, we should now flush
228235
// and commit the in progress render instead of restarting.
229-
expect(Scheduler).toFlushAndYield(['D']);
236+
await waitForPaint(['D']);
230237
expect(ReactNoop).toMatchRenderedOutput(
231238
<>
232239
<span prop="Loading..." />
@@ -235,11 +242,8 @@ describe('ReactSuspenseWithNoopRenderer', () => {
235242
</>,
236243
);
237244

238-
// Await one micro task to attach the retry listeners.
239-
await null;
240-
241245
// Next, we'll flush the complete content.
242-
expect(Scheduler).toFlushAndYield(['Bar', 'A', 'B']);
246+
await waitForAll(['Bar', 'A', 'B']);
243247

244248
expect(ReactNoop).toMatchRenderedOutput(
245249
<>

scripts/jest/matchers/schedulerTestMatchers.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ function captureAssertion(fn) {
1818

1919
function assertYieldsWereCleared(Scheduler) {
2020
const actualYields = Scheduler.unstable_clearYields();
21+
2122
if (actualYields.length !== 0) {
22-
throw new Error(
23+
const error = Error(
2324
'Log of yielded values is not empty. ' +
2425
'Call expect(Scheduler).toHaveYielded(...) first.'
2526
);
27+
Error.captureStackTrace(error, assertYieldsWereCleared);
2628
}
2729
}
2830

scripts/rollup/bundles.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -960,7 +960,7 @@ const bundles = [
960960
global: 'JestReact',
961961
minifyWithProdErrorCodes: false,
962962
wrapWithModuleBoundaries: false,
963-
externals: ['react', 'scheduler', 'scheduler/unstable_mock'],
963+
externals: ['react', 'scheduler', 'scheduler/unstable_mock', 'jest-diff'],
964964
},
965965

966966
/******* ESLint Plugin for Hooks *******/

0 commit comments

Comments
 (0)