[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.
This commit is contained in:
Andrew Clark
2018-07-19 10:26:24 -07:00
committed by GitHub
parent 8121212f0d
commit 71b4e99901
7 changed files with 214 additions and 156 deletions
+1 -1
View File
@@ -472,7 +472,7 @@ const ReactTestRendererFiber = {
TestRendererScheduling.clearYields();
return TestRenderer.flushSync(fn);
},
unstable_flushThrough: TestRendererScheduling.flushThrough,
unstable_flushNumberOfYields: TestRendererScheduling.flushNumberOfYields,
unstable_clearYields: TestRendererScheduling.clearYields,
};
@@ -31,38 +31,7 @@ export function setNowImplementation(implementation: () => number): void {
nowImplementation = implementation;
}
function verifyExpectedValues(expectedValues: Array<mixed>): 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<mixed>): Array<mixed> {
export function flushAll(): Array<mixed> {
yieldedValues = [];
while (scheduledCallback !== null) {
const cb = scheduledCallback;
@@ -78,11 +47,10 @@ export function flushAll(expectedValues: Array<mixed>): Array<mixed> {
didTimeout: false,
});
}
verifyExpectedValues(expectedValues);
return yieldedValues;
}
export function flushThrough(expectedValues: Array<mixed>): Array<mixed> {
export function flushNumberOfYields(count: number): Array<mixed> {
let didStop = false;
yieldedValues = [];
while (scheduledCallback !== null && !didStop) {
@@ -90,7 +58,7 @@ export function flushThrough(expectedValues: Array<mixed>): Array<mixed> {
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<mixed>): Array<mixed> {
didTimeout: false,
});
}
verifyExpectedValues(expectedValues);
return yieldedValues;
}
@@ -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(<Parent step={2} />);
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(
<div>
<Yield id="foo" />
<Yield id="bar" />
<Yield id="baz" />
</div>,
{
unstable_isAsync: true,
},
);
const renderer = ReactTestRenderer.create(
<div>
<Yield id="foo" />
<Yield id="bar" />
<Yield id="baz" />
</div>,
{
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(
<div>
<Yield id="foo" />
<Yield id="bar" />
<Yield id="baz" />
</div>,
{
unstable_isAsync: true,
},
);
expect(() => renderer.unstable_flushAll([])).toThrow(
'Flush expected to yield 0 values, but yielded 3',
);
renderer.update(
<div>
<Yield id="foo" />
<Yield id="bar" />
<Yield id="baz" />
</div>,
);
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(
<div>
<Yield id="foo" />
</div>,
{
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(
<div>
<Yield id="foo" />
<Yield id="bar" />
<Yield id="baz" />
</div>,
{
unstable_isAsync: true,
},
);
expect(() => expect(renderer).toFlushAll([])).toThrowError(
'Expected value to equal:',
);
renderer.update(
<div>
<Yield id="foo" />
<Yield id="bar" />
<Yield id="baz" />
</div>,
);
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 (
<div>
<Yield id="A" />
<Yield id="B" />
<BadRender />
<Yield id="C" />
<Yield id="D" />
</div>
);
}
const renderer = ReactTestRenderer.create(<App />, {
unstable_isAsync: true,
});
expect(() => {
expect(renderer).toFlushAndThrow(
// Wrong expected values
['A', 'B'],
'Oh no!',
);
}).toThrow('Expected value to equal:');
renderer.update(<App />);
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(<App />);
// 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 (
<div>
<Yield id="A" />
<Yield id="B" />
<Yield id="C" />
</div>
);
}
ReactTestRenderer.create(<App />);
expect(() => expect(ReactTestRenderer).toClearYields(['A', 'B'])).toThrow(
'Expected value to equal:',
);
});
});
@@ -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', () => {
</React.unstable_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', () => {
</React.unstable_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', () => {
</React.unstable_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', () => {
</React.unstable_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', () => {
<Yield renderTime={9} />
</React.unstable_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', () => {
</React.unstable_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
+57
View File
@@ -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,
};
+1
View File
@@ -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.
@@ -47,6 +47,7 @@ global.spyOnProd = function(...args) {
expect.extend({
...require('../matchers/toWarnDev'),
...require('../matchers/testRenderer'),
});
beforeEach(() => (numExpectations = 0));