From e5244673388a58eaf8f02a9e44749bef3ffee615 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 2 Mar 2023 21:58:11 -0500 Subject: [PATCH] New internal testing helpers: waitFor, waitForAll, waitForPaint (#26285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .eslintrc.js | 1 + .../ReactInternalTestUtils.js | 182 ++++++++++++++++++ packages/internal-test-utils/index.js | 1 + packages/internal-test-utils/package.json | 5 + packages/jest-react/src/JestReact.js | 4 +- .../ReactSuspenseWithNoopRenderer-test.js | 20 +- scripts/jest/config.build.js | 6 + .../jest/matchers/schedulerTestMatchers.js | 5 +- 8 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 packages/internal-test-utils/ReactInternalTestUtils.js create mode 100644 packages/internal-test-utils/index.js create mode 100644 packages/internal-test-utils/package.json diff --git a/.eslintrc.js b/.eslintrc.js index 13746fb3c672d..aff4fa6ce48bc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -325,6 +325,7 @@ module.exports = { 'packages/react-native-renderer/**/*.js', 'packages/eslint-plugin-react-hooks/**/*.js', 'packages/jest-react/**/*.js', + 'packages/internal-test-utils/**/*.js', 'packages/**/__tests__/*.js', 'packages/**/npm/*.js', ], diff --git a/packages/internal-test-utils/ReactInternalTestUtils.js b/packages/internal-test-utils/ReactInternalTestUtils.js new file mode 100644 index 0000000000000..db47ff910ca86 --- /dev/null +++ b/packages/internal-test-utils/ReactInternalTestUtils.js @@ -0,0 +1,182 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// TODO: Move `internalAct` and other test helpers to this package, too + +import * as SchedulerMock from 'scheduler/unstable_mock'; +import {diff} from 'jest-diff'; +import {equals} from '@jest/expect-utils'; + +function assertYieldsWereCleared(Scheduler) { + const actualYields = Scheduler.unstable_clearYields(); + if (actualYields.length !== 0) { + const error = Error( + 'The event log is not empty. Call assertLog(...) first.', + ); + Error.captureStackTrace(error, assertYieldsWereCleared); + throw error; + } +} + +export async function waitFor(expectedLog) { + assertYieldsWereCleared(SchedulerMock); + + // Create the error object before doing any async work, to get a better + // stack trace. + const error = new Error(); + Error.captureStackTrace(error, waitFor); + + const actualLog = []; + do { + // Wait until end of current task/microtask. + await null; + if (SchedulerMock.unstable_hasPendingWork()) { + SchedulerMock.unstable_flushNumberOfYields( + expectedLog.length - actualLog.length, + ); + actualLog.push(...SchedulerMock.unstable_clearYields()); + if (expectedLog.length > actualLog.length) { + // Continue flushing until we've logged the expected number of items. + } else { + // Once we've reached the expected sequence, wait one more microtask to + // flush any remaining synchronous work. + await null; + actualLog.push(...SchedulerMock.unstable_clearYields()); + break; + } + } else { + // There's no pending work, even after a microtask. + break; + } + } while (true); + + if (equals(actualLog, expectedLog)) { + return; + } + + error.message = ` +Expected sequence of events did not occur. + +${diff(expectedLog, actualLog)} +`; + throw error; +} + +export async function waitForAll(expectedLog) { + assertYieldsWereCleared(SchedulerMock); + + // Create the error object before doing any async work, to get a better + // stack trace. + const error = new Error(); + Error.captureStackTrace(error, waitFor); + + do { + // Wait until end of current task/microtask. + await null; + if (!SchedulerMock.unstable_hasPendingWork()) { + // There's no pending work, even after a microtask. Stop flushing. + break; + } + SchedulerMock.unstable_flushAllWithoutAsserting(); + } while (true); + + const actualLog = SchedulerMock.unstable_clearYields(); + if (equals(actualLog, expectedLog)) { + return; + } + + error.message = ` +Expected sequence of events did not occur. + +${diff(expectedLog, actualLog)} +`; + throw error; +} + +export async function waitForThrow(expectedError: mixed) { + assertYieldsWereCleared(SchedulerMock); + + // Create the error object before doing any async work, to get a better + // stack trace. + const error = new Error(); + Error.captureStackTrace(error, waitFor); + + do { + // Wait until end of current task/microtask. + await null; + if (!SchedulerMock.unstable_hasPendingWork()) { + // There's no pending work, even after a microtask. Stop flushing. + error.message = 'Expected something to throw, but nothing did.'; + throw error; + } + try { + SchedulerMock.unstable_flushAllWithoutAsserting(); + } catch (x) { + if (equals(x, expectedError)) { + return; + } + if (typeof x === 'object' && x !== null && x.message === expectedError) { + return; + } + error.message = ` +Expected error was not thrown. + +${diff(expectedError, x)} +`; + throw error; + } + } while (true); +} + +// TODO: This name is a bit misleading currently because it will stop as soon as +// React yields for any reason, not just for a paint. I've left it this way for +// now because that's how untable_flushUntilNextPaint already worked, but maybe +// we should split these use cases into separate APIs. +export async function waitForPaint(expectedLog) { + assertYieldsWereCleared(SchedulerMock); + + // Create the error object before doing any async work, to get a better + // stack trace. + const error = new Error(); + Error.captureStackTrace(error, waitFor); + + // Wait until end of current task/microtask. + await null; + if (SchedulerMock.unstable_hasPendingWork()) { + // Flush until React yields. + SchedulerMock.unstable_flushUntilNextPaint(); + // Wait one more microtask to flush any remaining synchronous work. + await null; + } + + const actualLog = SchedulerMock.unstable_clearYields(); + if (equals(actualLog, expectedLog)) { + return; + } + + error.message = ` +Expected sequence of events did not occur. + +${diff(expectedLog, actualLog)} +`; + throw error; +} + +export function assertLog(expectedLog) { + const actualLog = SchedulerMock.unstable_clearYields(); + if (equals(actualLog, expectedLog)) { + return; + } + + const error = new Error(` +Expected sequence of events did not occur. + +${diff(expectedLog, actualLog)} +`); + Error.captureStackTrace(error, assertLog); + throw error; +} diff --git a/packages/internal-test-utils/index.js b/packages/internal-test-utils/index.js new file mode 100644 index 0000000000000..7b6e30be3728f --- /dev/null +++ b/packages/internal-test-utils/index.js @@ -0,0 +1 @@ +export * from './ReactInternalTestUtils'; diff --git a/packages/internal-test-utils/package.json b/packages/internal-test-utils/package.json new file mode 100644 index 0000000000000..4748827d8003a --- /dev/null +++ b/packages/internal-test-utils/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "name": "internal-test-utils", + "version": "0.0.0" +} diff --git a/packages/jest-react/src/JestReact.js b/packages/jest-react/src/JestReact.js index f67ddd8636f1a..a46dc8d2ac1bc 100644 --- a/packages/jest-react/src/JestReact.js +++ b/packages/jest-react/src/JestReact.js @@ -31,10 +31,12 @@ function assertYieldsWereCleared(root) { const Scheduler = root._Scheduler; const actualYields = Scheduler.unstable_clearYields(); if (actualYields.length !== 0) { - throw new Error( + const error = Error( 'Log of yielded values is not empty. ' + 'Call expect(ReactTestRenderer).unstable_toHaveYielded(...) first.', ); + Error.captureStackTrace(error, assertYieldsWereCleared); + throw error; } } diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js index c73cd296b8695..31326a338e840 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js @@ -3,6 +3,10 @@ let Fragment; let ReactNoop; let Scheduler; let act; +let waitFor; +let waitForAll; +let assertLog; +let waitForPaint; let Suspense; let getCacheForType; @@ -19,6 +23,11 @@ describe('ReactSuspenseWithNoopRenderer', () => { Scheduler = require('scheduler'); act = require('jest-react').act; Suspense = React.Suspense; + const InternalTestUtils = require('internal-test-utils'); + waitFor = InternalTestUtils.waitFor; + waitForAll = InternalTestUtils.waitForAll; + waitForPaint = InternalTestUtils.waitForPaint; + assertLog = InternalTestUtils.assertLog; getCacheForType = React.unstable_getCacheForType; @@ -208,7 +217,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { React.startTransition(() => { ReactNoop.render(); }); - expect(Scheduler).toFlushAndYieldThrough([ + await waitFor([ 'Foo', 'Bar', // A suspends @@ -226,7 +235,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { // Even though the promise has resolved, we should now flush // and commit the in progress render instead of restarting. - expect(Scheduler).toFlushAndYield(['D']); + await waitForPaint(['D']); expect(ReactNoop).toMatchRenderedOutput( <> @@ -235,11 +244,8 @@ describe('ReactSuspenseWithNoopRenderer', () => { , ); - // Await one micro task to attach the retry listeners. - await null; - // Next, we'll flush the complete content. - expect(Scheduler).toFlushAndYield(['Bar', 'A', 'B']); + await waitForAll(['Bar', 'A', 'B']); expect(ReactNoop).toMatchRenderedOutput( <> @@ -544,7 +550,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { ReactNoop.flushSync(() => { ReactNoop.render(); }); - expect(Scheduler).toHaveYielded(['B', '1']); + assertLog(['B', '1']); expect(ReactNoop).toMatchRenderedOutput( <> diff --git a/scripts/jest/config.build.js b/scripts/jest/config.build.js index 5b04ab05df7cd..9b8d328a509e7 100644 --- a/scripts/jest/config.build.js +++ b/scripts/jest/config.build.js @@ -12,6 +12,12 @@ const NODE_MODULES_DIR = // Find all folders in packages/* with package.json const packagesRoot = join(__dirname, '..', '..', 'packages'); const packages = readdirSync(packagesRoot).filter(dir => { + if (dir === 'internal-test-utils') { + // This is an internal package used only for testing. It's OK to read + // from source. + // TODO: Maybe let's have some convention for this? + return false; + } if (dir.charAt(0) === '.') { return false; } diff --git a/scripts/jest/matchers/schedulerTestMatchers.js b/scripts/jest/matchers/schedulerTestMatchers.js index f18ccfc548093..645d8a58cc59f 100644 --- a/scripts/jest/matchers/schedulerTestMatchers.js +++ b/scripts/jest/matchers/schedulerTestMatchers.js @@ -18,11 +18,14 @@ function captureAssertion(fn) { function assertYieldsWereCleared(Scheduler) { const actualYields = Scheduler.unstable_clearYields(); + if (actualYields.length !== 0) { - throw new Error( + const error = Error( 'Log of yielded values is not empty. ' + 'Call expect(Scheduler).toHaveYielded(...) first.' ); + Error.captureStackTrace(error, assertYieldsWereCleared); + throw error; } }