From 71b4e99901a98396ca34655e0cc1c03eadcbc49b Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 19 Jul 2018 10:26:24 -0700 Subject: [PATCH] [react-test-renderer] Jest matchers for async tests (#13236) Adds custom Jest matchers that help with writing async tests: - `toFlushThrough` - `toFlushAll` - `toFlushAndThrow` - `toClearYields` Each one accepts an array of expected yielded values, to prevent false negatives. Eventually I imagine we'll want to publish this on npm. --- .../src/ReactTestRenderer.js | 2 +- .../src/ReactTestRendererScheduling.js | 39 +-- .../__tests__/ReactTestRendererAsync-test.js | 222 +++++++++++------- .../__tests__/ReactProfiler-test.internal.js | 48 ++-- scripts/jest/matchers/testRenderer.js | 57 +++++ scripts/jest/setupTests.js | 1 + .../spec-equivalence-reporter/setupTests.js | 1 + 7 files changed, 214 insertions(+), 156 deletions(-) create mode 100644 scripts/jest/matchers/testRenderer.js diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js index 861504436d..9102963b6a 100644 --- a/packages/react-test-renderer/src/ReactTestRenderer.js +++ b/packages/react-test-renderer/src/ReactTestRenderer.js @@ -472,7 +472,7 @@ const ReactTestRendererFiber = { TestRendererScheduling.clearYields(); return TestRenderer.flushSync(fn); }, - unstable_flushThrough: TestRendererScheduling.flushThrough, + unstable_flushNumberOfYields: TestRendererScheduling.flushNumberOfYields, unstable_clearYields: TestRendererScheduling.clearYields, }; diff --git a/packages/react-test-renderer/src/ReactTestRendererScheduling.js b/packages/react-test-renderer/src/ReactTestRendererScheduling.js index 1855db197a..311c112cc7 100644 --- a/packages/react-test-renderer/src/ReactTestRendererScheduling.js +++ b/packages/react-test-renderer/src/ReactTestRendererScheduling.js @@ -31,38 +31,7 @@ export function setNowImplementation(implementation: () => number): void { nowImplementation = implementation; } -function verifyExpectedValues(expectedValues: Array): void { - for (let i = 0; i < expectedValues.length; i++) { - const expectedValue = `"${(expectedValues[i]: any)}"`; - const yieldedValue = - i < yieldedValues.length ? `"${(yieldedValues[i]: any)}"` : 'nothing'; - if (yieldedValue !== expectedValue) { - const error = new Error( - `Flush expected to yield ${(expectedValue: any)}, but ${(yieldedValue: any)} was yielded`, - ); - // Attach expected and yielded arrays, - // So the caller could pretty print the diff (if desired). - (error: any).expectedValues = expectedValues; - (error: any).actualValues = yieldedValues; - throw error; - } - } - - if (expectedValues.length !== yieldedValues.length) { - const error = new Error( - `Flush expected to yield ${expectedValues.length} values, but yielded ${ - yieldedValues.length - }`, - ); - // Attach expected and yielded arrays, - // So the caller could pretty print the diff (if desired). - (error: any).expectedValues = expectedValues; - (error: any).actualValues = yieldedValues; - throw error; - } -} - -export function flushAll(expectedValues: Array): Array { +export function flushAll(): Array { yieldedValues = []; while (scheduledCallback !== null) { const cb = scheduledCallback; @@ -78,11 +47,10 @@ export function flushAll(expectedValues: Array): Array { didTimeout: false, }); } - verifyExpectedValues(expectedValues); return yieldedValues; } -export function flushThrough(expectedValues: Array): Array { +export function flushNumberOfYields(count: number): Array { let didStop = false; yieldedValues = []; while (scheduledCallback !== null && !didStop) { @@ -90,7 +58,7 @@ export function flushThrough(expectedValues: Array): Array { scheduledCallback = null; cb({ timeRemaining() { - if (yieldedValues.length >= expectedValues.length) { + if (yieldedValues.length >= count) { // We at least as many values as expected. Stop rendering. didStop = true; return 0; @@ -104,7 +72,6 @@ export function flushThrough(expectedValues: Array): Array { didTimeout: false, }); } - verifyExpectedValues(expectedValues); return yieldedValues; } diff --git a/packages/react-test-renderer/src/__tests__/ReactTestRendererAsync-test.js b/packages/react-test-renderer/src/__tests__/ReactTestRendererAsync-test.js index f25c0134da..f0f4e48391 100644 --- a/packages/react-test-renderer/src/__tests__/ReactTestRendererAsync-test.js +++ b/packages/react-test-renderer/src/__tests__/ReactTestRendererAsync-test.js @@ -32,7 +32,7 @@ describe('ReactTestRendererAsync', () => { expect(renderer.toJSON()).toEqual(null); // Flush initial mount. - renderer.unstable_flushAll([]); + expect(renderer).toFlushAll([]); expect(renderer.toJSON()).toEqual('Hi'); // Update @@ -40,7 +40,7 @@ describe('ReactTestRendererAsync', () => { // Not yet updated. expect(renderer.toJSON()).toEqual('Hi'); // Flush update. - renderer.unstable_flushAll([]); + expect(renderer).toFlushAll([]); expect(renderer.toJSON()).toEqual('Bye'); }); @@ -62,11 +62,11 @@ describe('ReactTestRendererAsync', () => { unstable_isAsync: true, }); - renderer.unstable_flushAll(['A:1', 'B:1', 'C:1']); + expect(renderer).toFlushAll(['A:1', 'B:1', 'C:1']); expect(renderer.toJSON()).toEqual(['A:1', 'B:1', 'C:1']); renderer.update(); - renderer.unstable_flushAll(['A:2', 'B:2', 'C:2']); + expect(renderer).toFlushAll(['A:2', 'B:2', 'C:2']); expect(renderer.toJSON()).toEqual(['A:2', 'B:2', 'C:2']); }); @@ -89,15 +89,12 @@ describe('ReactTestRendererAsync', () => { }); // Flush the first two siblings - expect(renderer.unstable_flushThrough(['A:1', 'B:1'])).toEqual([ - 'A:1', - 'B:1', - ]); + expect(renderer).toFlushThrough(['A:1', 'B:1']); // Did not commit yet. expect(renderer.toJSON()).toEqual(null); // Flush the remaining work - renderer.unstable_flushAll(['C:1']); + expect(renderer).toFlushAll(['C:1']); expect(renderer.toJSON()).toEqual(['A:1', 'B:1', 'C:1']); }); @@ -129,7 +126,7 @@ describe('ReactTestRendererAsync', () => { }); // Flush the some of the changes, but don't commit - expect(renderer.unstable_flushThrough(['A:1'])).toEqual(['A:1']); + expect(renderer).toFlushThrough(['A:1']); expect(renderer.toJSON()).toEqual(null); // Interrupt with higher priority properties @@ -141,89 +138,136 @@ describe('ReactTestRendererAsync', () => { expect(renderer.toJSON()).toEqual(['A:2', 'B:2']); }); - it('should error if flushThrough params dont match yielded values', () => { - const Yield = ({id}) => { - ReactTestRenderer.unstable_yield(id); - return id; - }; + describe('Jest matchers', () => { + it('toFlushThrough', () => { + const Yield = ({id}) => { + ReactTestRenderer.unstable_yield(id); + return id; + }; - const renderer = ReactTestRenderer.create( -
- - - -
, - { - unstable_isAsync: true, - }, - ); + const renderer = ReactTestRenderer.create( +
+ + + +
, + { + unstable_isAsync: true, + }, + ); - expect(() => renderer.unstable_flushThrough(['foo', 'baz'])).toThrow( - 'Flush expected to yield "baz", but "bar" was yielded', - ); - }); - - it('should error if flushAll params dont match yielded values', () => { - const Yield = ({id}) => { - ReactTestRenderer.unstable_yield(id); - return id; - }; - - const renderer = ReactTestRenderer.create( -
- - - -
, - { - unstable_isAsync: true, - }, - ); - - expect(() => renderer.unstable_flushAll([])).toThrow( - 'Flush expected to yield 0 values, but yielded 3', - ); - - renderer.update( -
- - - -
, - ); - - expect(() => renderer.unstable_flushAll(['foo', 'baz'])).toThrow( - 'Flush expected to yield "baz", but "bar" was yielded', - ); - }); - - it('should error if flushThrough yields the wrong number of values', () => { - const Yield = ({id}) => { - ReactTestRenderer.unstable_yield(id); - return id; - }; - - const renderer = ReactTestRenderer.create( -
- -
, - { - unstable_isAsync: true, - }, - ); - - expect(() => renderer.unstable_flushThrough(['foo', 'bar'])).toThrow( - 'Flush expected to yield "bar", but nothing was yielded', - ); - }); - - it('should error if flushThrough yields no values', () => { - const renderer = ReactTestRenderer.create(null, { - unstable_isAsync: true, + expect(() => expect(renderer).toFlushThrough(['foo', 'baz'])).toThrow( + 'Expected value to equal:', + ); }); - expect(() => renderer.unstable_flushThrough(['foo'])).toThrow( - 'Flush expected to yield "foo", but nothing was yielded', + it('toFlushAll', () => { + const Yield = ({id}) => { + ReactTestRenderer.unstable_yield(id); + return id; + }; + + const renderer = ReactTestRenderer.create( +
+ + + +
, + { + unstable_isAsync: true, + }, + ); + + expect(() => expect(renderer).toFlushAll([])).toThrowError( + 'Expected value to equal:', + ); + + renderer.update( +
+ + + +
, + ); + + expect(() => expect(renderer).toFlushAll(['foo', 'baz'])).toThrow( + 'Expected value to equal:', + ); + }); + + it('toFlushAndThrow', () => { + const Yield = ({id}) => { + ReactTestRenderer.unstable_yield(id); + return id; + }; + + function BadRender() { + throw new Error('Oh no!'); + } + + function App() { + return ( +
+ + + + + +
+ ); + } + + const renderer = ReactTestRenderer.create(, { + unstable_isAsync: true, + }); + + expect(() => { + expect(renderer).toFlushAndThrow( + // Wrong expected values + ['A', 'B'], + 'Oh no!', + ); + }).toThrow('Expected value to equal:'); + + renderer.update(); + + expect(() => { + expect(renderer).toFlushAndThrow( + ['A', 'B', 'C', 'D', 'A', 'B', 'C', 'D'], + // Wrong error message + 'Oops!', + ); + }).toThrow('Expected the function to throw an error matching:'); + + renderer.update(); + + // Passes + expect(renderer).toFlushAndThrow( + ['A', 'B', 'C', 'D', 'A', 'B', 'C', 'D'], + 'Oh no!', + ); + }); + }); + + it('toClearYields', () => { + const Yield = ({id}) => { + ReactTestRenderer.unstable_yield(id); + return id; + }; + + function App() { + return ( +
+ + + +
+ ); + } + + ReactTestRenderer.create(); + expect(() => expect(ReactTestRenderer).toClearYields(['A', 'B'])).toThrow( + 'Expected value to equal:', ); }); }); diff --git a/packages/react/src/__tests__/ReactProfiler-test.internal.js b/packages/react/src/__tests__/ReactProfiler-test.internal.js index 39a512a0c5..b105363f06 100644 --- a/packages/react/src/__tests__/ReactProfiler-test.internal.js +++ b/packages/react/src/__tests__/ReactProfiler-test.internal.js @@ -178,9 +178,9 @@ describe('Profiler', () => { ); // Times are logged until a render is committed. - renderer.unstable_flushThrough(['first']); + expect(renderer).toFlushThrough(['first']); expect(callback).toHaveBeenCalledTimes(0); - renderer.unstable_flushAll(['last']); + expect(renderer).toFlushAll(['last']); expect(callback).toHaveBeenCalledTimes(1); }); @@ -528,13 +528,11 @@ describe('Profiler', () => { , {unstable_isAsync: true}, ); - expect(renderer.unstable_flushThrough(['Yield:2'])).toEqual([ - 'Yield:2', - ]); + expect(renderer).toFlushThrough(['Yield:2']); expect(callback).toHaveBeenCalledTimes(0); // Resume render for remaining children. - renderer.unstable_flushAll(['Yield:3']); + expect(renderer).toFlushAll(['Yield:3']); // Verify that logged times include both durations above. expect(callback).toHaveBeenCalledTimes(1); @@ -568,9 +566,7 @@ describe('Profiler', () => { , {unstable_isAsync: true}, ); - expect(renderer.unstable_flushThrough(['Yield:5'])).toEqual([ - 'Yield:5', - ]); + expect(renderer).toFlushThrough(['Yield:5']); expect(callback).toHaveBeenCalledTimes(0); // Simulate time moving forward while frame is paused. @@ -578,7 +574,7 @@ describe('Profiler', () => { // Flush the remaninig work, // Which should take an additional 10ms of simulated time. - renderer.unstable_flushAll(['Yield:10', 'Yield:17']); + expect(renderer).toFlushAll(['Yield:10', 'Yield:17']); expect(callback).toHaveBeenCalledTimes(2); const [innerCall, outerCall] = callback.mock.calls; @@ -617,9 +613,7 @@ describe('Profiler', () => { , {unstable_isAsync: true}, ); - expect(renderer.unstable_flushThrough(['Yield:10'])).toEqual([ - 'Yield:10', - ]); + expect(renderer).toFlushThrough(['Yield:10']); expect(callback).toHaveBeenCalledTimes(0); // Simulate time moving forward while frame is paused. @@ -634,7 +628,7 @@ describe('Profiler', () => { , ); }); - expect(ReactTestRenderer.unstable_clearYields()).toEqual(['Yield:5']); + expect(ReactTestRenderer).toClearYields(['Yield:5']); // The initial work was thrown away in this case, // So the actual and base times should only include the final rendered tree times. @@ -648,7 +642,7 @@ describe('Profiler', () => { callback.mockReset(); // Verify no more unexpected callbacks from low priority work - renderer.unstable_flushAll([]); + expect(renderer).toFlushAll([]); expect(callback).toHaveBeenCalledTimes(0); }); @@ -673,7 +667,7 @@ describe('Profiler', () => { // Render everything initially. // This should take 21 seconds of actual and base time. - renderer.unstable_flushAll(['Yield:6', 'Yield:15']); + expect(renderer).toFlushAll(['Yield:6', 'Yield:15']); expect(callback).toHaveBeenCalledTimes(1); let call = callback.mock.calls[0]; expect(call[2]).toBe(21); // actual time @@ -694,18 +688,14 @@ describe('Profiler', () => { , ); - expect(renderer.unstable_flushThrough(['Yield:3'])).toEqual([ - 'Yield:3', - ]); + expect(renderer).toFlushThrough(['Yield:3']); expect(callback).toHaveBeenCalledTimes(0); // Simulate time moving forward while frame is paused. advanceTimeBy(100); // 59 -> 159 // Render another 5ms of simulated time. - expect(renderer.unstable_flushThrough(['Yield:5'])).toEqual([ - 'Yield:5', - ]); + expect(renderer).toFlushThrough(['Yield:5']); expect(callback).toHaveBeenCalledTimes(0); // Simulate time moving forward while frame is paused. @@ -720,7 +710,7 @@ describe('Profiler', () => { , ); }); - expect(ReactTestRenderer.unstable_clearYields()).toEqual(['Yield:11']); + expect(ReactTestRenderer).toClearYields(['Yield:11']); // The actual time should include only the most recent render, // Because this lets us avoid a lot of commit phase reset complexity. @@ -733,7 +723,7 @@ describe('Profiler', () => { expect(call[5]).toBe(275); // commit time // Verify no more unexpected callbacks from low priority work - renderer.unstable_flushAll([]); + expect(renderer).toFlushAll([]); expect(callback).toHaveBeenCalledTimes(1); }); @@ -784,7 +774,7 @@ describe('Profiler', () => { // Render everything initially. // This simulates a total of 14ms of actual render time. // The base render time is also 14ms for the initial render. - renderer.unstable_flushAll([ + expect(renderer).toFlushAll([ 'FirstComponent:1', 'Yield:4', 'SecondComponent:2', @@ -804,9 +794,7 @@ describe('Profiler', () => { // Render a partially update, but don't finish. // This partial render will take 10ms of actual render time. first.setState({renderTime: 10}); - expect(renderer.unstable_flushThrough(['FirstComponent:10'])).toEqual([ - 'FirstComponent:10', - ]); + expect(renderer).toFlushThrough(['FirstComponent:10']); expect(callback).toHaveBeenCalledTimes(0); // Simulate time moving forward while frame is paused. @@ -815,7 +803,7 @@ describe('Profiler', () => { // Interrupt with higher priority work. // This simulates a total of 37ms of actual render time. renderer.unstable_flushSync(() => second.setState({renderTime: 30})); - expect(ReactTestRenderer.unstable_clearYields()).toEqual([ + expect(ReactTestRenderer).toClearYields([ 'SecondComponent:30', 'Yield:7', ]); @@ -842,7 +830,7 @@ describe('Profiler', () => { // The tree contains 42ms of base render time at this point, // Reflecting the most recent (longer) render durations. // TODO: This actual time should decrease by 10ms once the scheduler supports resuming. - renderer.unstable_flushAll(['FirstComponent:10', 'Yield:4']); + expect(renderer).toFlushAll(['FirstComponent:10', 'Yield:4']); expect(callback).toHaveBeenCalledTimes(1); call = callback.mock.calls[0]; expect(call[2]).toBe(14); // actual time diff --git a/scripts/jest/matchers/testRenderer.js b/scripts/jest/matchers/testRenderer.js new file mode 100644 index 0000000000..7f1bcd36d7 --- /dev/null +++ b/scripts/jest/matchers/testRenderer.js @@ -0,0 +1,57 @@ +'use strict'; + +function captureAssertion(fn) { + // Trick to use a Jest matcher inside another Jest matcher. `fn` contains an + // assertion; if it throws, we capture the error and return it, so the stack + // trace presented to the user points to the original assertion in the + // test file. + try { + fn(); + } catch (error) { + return { + pass: false, + message: () => error.message, + }; + } + return {pass: true}; +} + +function toFlushAll(renderer, expectedYields) { + const actualYields = renderer.unstable_flushAll(); + return captureAssertion(() => expect(actualYields).toEqual(expectedYields)); +} + +function toFlushThrough(renderer, expectedYields) { + const actualYields = renderer.unstable_flushNumberOfYields( + expectedYields.length + ); + return captureAssertion(() => expect(actualYields).toEqual(expectedYields)); +} + +function toClearYields(ReactTestRenderer, expectedYields) { + const actualYields = ReactTestRenderer.unstable_clearYields(); + return captureAssertion(() => expect(actualYields).toEqual(expectedYields)); +} + +function toFlushAndThrow(renderer, expectedYields, ...rest) { + return captureAssertion(() => { + try { + expect(() => { + renderer.unstable_flushAll(); + }).toThrow(...rest); + } catch (error) { + const actualYields = renderer.unstable_clearYields(); + expect(actualYields).toEqual(expectedYields); + throw error; + } + const actualYields = renderer.unstable_clearYields(); + expect(actualYields).toEqual(expectedYields); + }); +} + +module.exports = { + toFlushAll, + toFlushThrough, + toFlushAndThrow, + toClearYields, +}; diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js index 3a9b7396c3..63bdd6e529 100644 --- a/scripts/jest/setupTests.js +++ b/scripts/jest/setupTests.js @@ -44,6 +44,7 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { expect.extend({ ...require('./matchers/toWarnDev'), + ...require('./matchers/testRenderer'), }); // We have a Babel transform that inserts guards against infinite loops. diff --git a/scripts/jest/spec-equivalence-reporter/setupTests.js b/scripts/jest/spec-equivalence-reporter/setupTests.js index e91d34b1b1..76c71fe716 100644 --- a/scripts/jest/spec-equivalence-reporter/setupTests.js +++ b/scripts/jest/spec-equivalence-reporter/setupTests.js @@ -47,6 +47,7 @@ global.spyOnProd = function(...args) { expect.extend({ ...require('../matchers/toWarnDev'), + ...require('../matchers/testRenderer'), }); beforeEach(() => (numExpectations = 0));