From 6b4c926029a717eaf959451b904a909f5338e609 Mon Sep 17 00:00:00 2001 From: Nicolas DUBIEN Date: Sat, 18 Apr 2020 20:43:51 +0200 Subject: [PATCH] Migrate ReactSuspense fuzz tests to Property Based Tests --- package.json | 1 + .../ReactSuspenseFuzz-test.internal.js | 243 ++++++++---------- scripts/jest/setupTests.js | 8 + yarn.lock | 14 + 4 files changed, 124 insertions(+), 142 deletions(-) diff --git a/package.json b/package.json index 1b7b848bde2a8..d5aec49f29b31 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "eslint-plugin-no-for-of-loops": "^1.0.0", "eslint-plugin-react": "^6.7.1", "eslint-plugin-react-internal": "link:./scripts/eslint-rules", + "fast-check": "^1.24.1", "fbjs-scripts": "0.8.3", "filesize": "^6.0.1", "flow-bin": "0.97", diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js index eabcb41be0e6d..f388ade94a692 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js @@ -3,9 +3,8 @@ let Suspense; let ReactNoop; let Scheduler; let ReactFeatureFlags; -let Random; -const SEED = process.env.FUZZ_TEST_SEED || 'default'; +const fc = require('fast-check'); const prettyFormatPkg = require('pretty-format'); function prettyFormat(thing) { @@ -27,7 +26,6 @@ describe('ReactSuspenseFuzz', () => { Suspense = React.Suspense; ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); - Random = require('random-seed'); }); function createFuzzer() { @@ -185,123 +183,101 @@ describe('ReactSuspenseFuzz', () => { expect(concurrentOutput).toEqual(expectedOutput); } - function pickRandomWeighted(rand, options) { - let totalWeight = 0; - for (let i = 0; i < options.length; i++) { - totalWeight += options[i].weight; - } - let remainingWeight = rand.floatBetween(0, totalWeight); - for (let i = 0; i < options.length; i++) { - const {value, weight} = options[i]; - remainingWeight -= weight; - if (remainingWeight <= 0) { - return value; - } - } - } - - function generateTestCase(rand, numberOfElements) { - let remainingElements = numberOfElements; - - function createRandomChild(hasSibling) { - const possibleActions = [ - {value: 'return', weight: 1}, - {value: 'text', weight: 1}, - ]; - - if (hasSibling) { - possibleActions.push({value: 'container', weight: 1}); - possibleActions.push({value: 'suspense', weight: 1}); - } - - const action = pickRandomWeighted(rand, possibleActions); - - switch (action) { - case 'text': { - remainingElements--; - - const numberOfUpdates = pickRandomWeighted(rand, [ - {value: 0, weight: 8}, - {value: 1, weight: 4}, - {value: 2, weight: 1}, - ]); - - const updates = []; - for (let i = 0; i < numberOfUpdates; i++) { - updates.push({ - beginAfter: rand.intBetween(0, 10000), - suspendFor: rand.intBetween(0, 10000), - }); - } - - return ( - - ); - } - case 'container': { - const numberOfUpdates = pickRandomWeighted(rand, [ - {value: 0, weight: 8}, - {value: 1, weight: 4}, - {value: 2, weight: 1}, - ]); - - const updates = []; - for (let i = 0; i < numberOfUpdates; i++) { - updates.push({ - remountAfter: rand.intBetween(0, 10000), - }); - } - - remainingElements--; - const children = createRandomChildren(3); - return React.createElement(Container, {updates}, ...children); - } - case 'suspense': { - remainingElements--; - const children = createRandomChildren(3); - - const fallbackType = pickRandomWeighted(rand, [ - {value: 'none', weight: 1}, - {value: 'normal', weight: 1}, - {value: 'nested suspense', weight: 1}, - ]); - - let fallback; - if (fallbackType === 'normal') { - fallback = 'Loading...'; - } else if (fallbackType === 'nested suspense') { - fallback = React.createElement( - React.Fragment, - null, - ...createRandomChildren(3), - ); - } - - return React.createElement(Suspense, {fallback}, ...children); - } - case 'return': - default: - return null; - } - } - - function createRandomChildren(limit) { - const children = []; - while (remainingElements > 0 && children.length < limit) { - children.push(createRandomChild(children.length > 0)); - } - return children; - } - - const children = createRandomChildren(Infinity); - return React.createElement(React.Fragment, null, ...children); + function testCaseArbitrary() { + const updatesArbitrary = arb => + fc.frequency( + // Remark: Using a frequency to build an array + // Remove the ability to shrink it automatically + // But its content remains shrinkable + {arbitrary: fc.constant([]), weight: 8}, + {arbitrary: fc.array(arb, 1, 1), weight: 4}, + {arbitrary: fc.array(arb, 2, 2), weight: 1}, + ); + + const {rootChildrenArbitrary} = fc.letrec(tie => ({ + // Produce one specific type of child + returnChildArbitrary: fc.constant(null), + textChildArbitrary: fc + .tuple( + fc.hexaString().noShrink(), + updatesArbitrary( + fc.record({ + beginAfter: fc.nat(10000), + suspendFor: fc.nat(10000), + }), + ), + fc.nat(10000), + ) + .map(([text, updates, initialDelay]) => ( + + )), + containerChildArbitrary: fc + .tuple( + updatesArbitrary(fc.record({remountAfter: fc.nat(10000)})), + tie('subChildrenArbitrary'), + ) + .map(([updates, children]) => + React.createElement(Container, {updates}, ...children), + ), + suspenseChildArbitrary: fc + .tuple( + fc.oneof( + // fallback = none + fc.constant(undefined), + // fallback = loading + fc.constant('Loading...'), + // fallback = nested suspense + tie('subChildrenArbitrary').map(children => + React.createElement(React.Fragment, null, ...children), + ), + ), + tie('subChildrenArbitrary'), + ) + .map(([fallback, children]) => + React.createElement(Suspense, {fallback}, ...children), + ), + // Produce the first child + childArbitrary: fc.oneof( + tie('returnChildArbitrary'), + tie('textChildArbitrary'), + ), + // Produce a child with sibling + childWithSiblingArbitrary: fc.oneof( + tie('returnChildArbitrary'), + tie('textChildArbitrary'), + tie('containerChildArbitrary'), + tie('suspenseChildArbitrary'), + ), + // Produce sub children + subChildrenArbitrary: fc + .tuple( + tie('childArbitrary'), + fc.array(tie('childWithSiblingArbitrary'), 0, 2), + ) + .map(([firstChild, others]) => [firstChild, ...others]), + // Produce the root children + rootChildrenArbitrary: fc + .tuple( + tie('childArbitrary'), + fc.array(tie('childWithSiblingArbitrary')), + ) + .map(([firstChild, others]) => [firstChild, ...others]), + })); + + return rootChildrenArbitrary.map(children => { + const el = React.createElement(React.Fragment, null, ...children); + return { + randomTestCase: React.createElement( + React.Fragment, + null, + ...children, + ), + toString: () => prettyFormat(el), + }; + }); } - return {Container, Text, testResolvedOutput, generateTestCase}; + return {Container, Text, testResolvedOutput, testCaseArbitrary}; } it('basic cases', () => { @@ -318,30 +294,13 @@ describe('ReactSuspenseFuzz', () => { ); }); - it(`generative tests (random seed: ${SEED})`, () => { - const {generateTestCase, testResolvedOutput} = createFuzzer(); - - const rand = Random.create(SEED); - - const NUMBER_OF_TEST_CASES = 500; - const ELEMENTS_PER_CASE = 12; - - for (let i = 0; i < NUMBER_OF_TEST_CASES; i++) { - const randomTestCase = generateTestCase(rand, ELEMENTS_PER_CASE); - try { - testResolvedOutput(randomTestCase); - } catch (e) { - console.log(` -Failed fuzzy test case: - -${prettyFormat(randomTestCase)} - -Random seed is ${SEED} -`); - - throw e; - } - } + it(`generative tests`, () => { + const {testCaseArbitrary, testResolvedOutput} = createFuzzer(); + fc.assert( + fc.property(testCaseArbitrary(), ({randomTestCase}) => + testResolvedOutput(randomTestCase), + ), + ); }); describe('hard-coded cases', () => { diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js index 34f5633ea372e..a9786002a8bba 100644 --- a/scripts/jest/setupTests.js +++ b/scripts/jest/setupTests.js @@ -1,6 +1,7 @@ 'use strict'; const chalk = require('chalk'); +const fc = require('fast-check'); const util = require('util'); const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError'); const {getTestFlags} = require('./TestFlags'); @@ -313,3 +314,10 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { require('jasmine-check').install(); } + +// Configure fuzzer based on environment variables if any +// Do not require fast-check in beforeEach if you want to benefit from this configuration +fc.configureGlobal({ + numRuns: 500, // default is 100 + seed: +process.env.FUZZ_TEST_SEED || undefined, +}); diff --git a/yarn.lock b/yarn.lock index 65ede48b12081..9b25b81dec226 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5158,6 +5158,7 @@ eslint-plugin-no-unsafe-innerhtml@1.0.16: "eslint-plugin-react-internal@link:./scripts/eslint-rules": version "0.0.0" + uid "" eslint-plugin-react@^6.7.1: version "6.10.3" @@ -5701,6 +5702,14 @@ fancy-log@^1.3.2: color-support "^1.1.3" time-stamp "^1.0.0" +fast-check@^1.24.1: + version "1.24.1" + resolved "https://registry.yarnpkg.com/fast-check/-/fast-check-1.24.1.tgz#42a153e664122b1a2defdaea4e9b8311635fa7e7" + integrity sha512-ECF5LDbt4F8sJyTDI62fRLn0BdHDAdBacxlEsxaYbtqwbsdWofoYZUSaUp9tJrLsqCQ8jG28SkNvPZpDfNo3tw== + dependencies: + pure-rand "^2.0.0" + tslib "^1.10.0" + fast-deep-equal@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614" @@ -10746,6 +10755,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +pure-rand@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-2.0.0.tgz#3324633545207907fe964c2f0ebf05d8e9a7f129" + integrity sha512-mk98aayyd00xbfHgE3uEmAUGzz3jCdm8Mkf5DUXUhc7egmOaGG2D7qhVlynGenNe9VaNJZvzO9hkc8myuTkDgw== + qs@6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"