From 7ceb0dda99111d532bf6b143d41484efa195b5da Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 9 Sep 2021 13:56:52 -0700 Subject: [PATCH] reactUtils tests: Start testing `useHasStayedTrueForMs`. This is our first use of `react-test-renderer`. It piggy-backs on our incorporation of Jest's "modern" fake-timer implementation in PRs #4754 and #4931. That was handy! I haven't yet found any test cases that fail with our implementation. (And I'd been hoping to, to debug an unexpected error!) But I did try pasting in an earlier iteration of the hook's implementation, from #4940, that Greg had found bugs in by reading the code. Many of these tests failed on that buggy implementation, which is a good sign. Might as well keep these new tests, then, if they're not an unreasonable maintenance burden. --- package.json | 1 + src/__tests__/reactUtils-test.js | 232 +++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 src/__tests__/reactUtils-test.js diff --git a/package.json b/package.json index b2afc49ecb1..ff34bfed8f9 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "prettier-eslint": "^12.0.0", "prettier-eslint-cli": "^5.0.0", "react-native-cli": "^2.0.1", + "react-test-renderer": "17.0.1", "redux-mock-store": "^1.5.1", "rollup": "^2.26.5", "typescript": "~3.8.3", diff --git a/src/__tests__/reactUtils-test.js b/src/__tests__/reactUtils-test.js new file mode 100644 index 00000000000..9d1c58324c8 --- /dev/null +++ b/src/__tests__/reactUtils-test.js @@ -0,0 +1,232 @@ +/* @flow strict-local */ +import React from 'react'; +import type { ComponentType } from 'react'; +// $FlowFixMe[untyped-import] +import { create, act } from 'react-test-renderer'; + +import { fakeSleep } from './lib/fakeTimers'; +import { useHasStayedTrueForMs } from '../reactUtils'; + +describe('useHasStayedTrueForMs', () => { + /** + * Simulate a mock component using the hook, and inspect hook's value. + * + * - The constructor mounts the component. (`ms` won't change through the + * lifetime of the TestMachine instance.) + * - Use `updateValue` to change the `value` arg passed to the hook. + * - Use `hookOutput` to get the return value of the hook from the latest + * render. + * - (Important) To wait for a duration, use the instance's `sleep` method + * instead of the util `fakeSleep` or similar. It wraps some + * `react-test-renderer` boilerplate. + * - When done, call 'cleanup'. + * + * Encapsulates a few things: + * - react-test-renderer is untyped (so far) + * - boilerplate for using react-test-renderer, like calling `act` + * repeatedly + * - boring details like how the mock component is implemented + */ + // I'm not totally clear on everything `act` does, but react-test-renderer + // seems to recommend it strongly enough that we actually get errors if we + // don't use it. Following links -- + // https://reactjs.org/docs/test-renderer.html#testrendereract + // https://reactjs.org/docs/test-utils.html#act + // https://reactjs.org/docs/testing-recipes.html + // -- I see the following, which I think does the best job of explaining. + // (The `act` in `react-dom/test-utils` might not be identical to the + // `act` in `react-test-renderer`, but `react-test-renderer` says they're + // similar.) + // > When writing UI tests, tasks like rendering, user events, or data + // > fetching can be considered as “units” of interaction with a user + // > interface. `react-dom/test-utils` provides a helper called `act()` + // > that makes sure all updates related to these “units” have been + // > processed and applied to the DOM before you make any assertions + class TestMachine { + static HOOK_VALUE_TRUE = 'HOOK_VALUE_TRUE'; + static HOOK_VALUE_FALSE = 'HOOK_VALUE_FALSE'; + + _TestComponent: ComponentType<{| value: boolean |}>; + _testRenderer: $FlowFixMe; + + constructor(ms: number, initialValue: boolean) { + this._TestComponent = function _TestComponent(props: {| value: boolean |}) { + const hookOutput = useHasStayedTrueForMs(props.value, ms); + return hookOutput ? TestMachine.HOOK_VALUE_TRUE : TestMachine.HOOK_VALUE_FALSE; + }; + this._testRenderer = this._createTestRenderer(initialValue); + } + + updateValue(value: boolean) { + this._updateTestRenderer(value); + } + + hookOutput() { + const result = this._testRenderer.root.children[0] === TestMachine.HOOK_VALUE_TRUE; + return result; + } + + // eslint-disable-next-line class-methods-use-this + async sleep(ms: number): Promise { + // The hook uses `useState`, which seems to make `react-test-renderer` + // complain if we don't use `act`. + return act(() => fakeSleep(ms)); + } + + cleanup() { + // https://reactjs.org/docs/test-renderer.html#testrendererunmount + this._testRenderer.unmount(); + } + + _createTestRenderer(initialValue: boolean) { + const TestComponent = this._TestComponent; + let testRenderer; + act(() => { + // https://reactjs.org/docs/test-renderer.html#testrenderercreate + testRenderer = create(); + }); + return testRenderer; + } + + _updateTestRenderer(value: boolean) { + const TestComponent = this._TestComponent; + act(() => { + // https://reactjs.org/docs/test-renderer.html#testrendererupdate + this._testRenderer.update(); + }); + } + } + + const MS = 1000; + + /** + * Simulate the input value changing over time, checking the hook's output. + * + * On each item in the `sequence`, this will: + * 1. Wait for a specified time + * 2. Read and assert the hook's output from the last render, as specified + * 3. Render again, with the specified input for the hook + */ + // Tell ESLint to recognize `testSequence` as a helper function that runs + // assertions. + /* eslint jest/expect-expect: ["error", { "assertFunctionNames": ["expect", "testSequence"] }] */ + const testSequence = async (args: { + initialValue: boolean, + sequence: $ReadOnlyArray<{| + waitBefore: number, + expectedOutput: boolean, + thenUpdateInputTo?: boolean, + |}>, + }) => { + const { initialValue, sequence } = args; + const testMachine = new TestMachine(MS, initialValue); + + // Should never be true before any time has passed. + expect(testMachine.hookOutput()).toBeFalse(); + + for (let i = 0; i < sequence.length; i++) { + const { waitBefore, expectedOutput, thenUpdateInputTo } = sequence[i]; + await testMachine.sleep(waitBefore); + expect(testMachine.hookOutput()).toBe(expectedOutput); + if (thenUpdateInputTo !== undefined) { + testMachine.updateValue(thenUpdateInputTo); + } + } + + testMachine.cleanup(); + }; + + const sequencesToTest = [ + { + initialValue: false, + sequence: [{ waitBefore: MS * 2, expectedOutput: false }], + }, + { + initialValue: false, + sequence: [{ waitBefore: MS / 2, expectedOutput: false }], + }, + { + initialValue: false, + sequence: [ + { waitBefore: MS / 2, expectedOutput: false, thenUpdateInputTo: true }, + { waitBefore: MS * 2, expectedOutput: true, thenUpdateInputTo: false }, + { waitBefore: MS / 2, expectedOutput: false }, + { waitBefore: MS, expectedOutput: false }, + ], + }, + { + initialValue: false, + sequence: [ + { waitBefore: MS / 2, expectedOutput: false, thenUpdateInputTo: false }, + { waitBefore: MS / 2, expectedOutput: false, thenUpdateInputTo: true }, + { waitBefore: MS / 2, expectedOutput: false, thenUpdateInputTo: false }, + { waitBefore: MS / 2, expectedOutput: false, thenUpdateInputTo: true }, + { waitBefore: MS / 2, expectedOutput: false, thenUpdateInputTo: false }, + { waitBefore: MS * 2, expectedOutput: false }, + ], + }, + { + initialValue: false, + sequence: [ + { waitBefore: MS / 5, expectedOutput: false, thenUpdateInputTo: false }, + { waitBefore: MS / 5, expectedOutput: false, thenUpdateInputTo: false }, + { waitBefore: MS / 5, expectedOutput: false, thenUpdateInputTo: false }, + { waitBefore: MS / 5, expectedOutput: false, thenUpdateInputTo: false }, + { waitBefore: MS / 5, expectedOutput: false, thenUpdateInputTo: false }, + { waitBefore: MS / 5, expectedOutput: false, thenUpdateInputTo: false }, + ], + }, + { + initialValue: true, + sequence: [{ waitBefore: MS / 2, expectedOutput: false }], + }, + { + initialValue: true, + sequence: [{ waitBefore: MS * 2, expectedOutput: true }], + }, + { + initialValue: true, + sequence: [ + { waitBefore: MS / 2, expectedOutput: false, thenUpdateInputTo: false }, + { waitBefore: MS, expectedOutput: false }, + ], + }, + { + initialValue: true, + sequence: [ + { waitBefore: MS * 2, expectedOutput: true, thenUpdateInputTo: false }, + { waitBefore: MS / 2, expectedOutput: false }, + { waitBefore: MS, expectedOutput: false }, + ], + }, + { + initialValue: true, + sequence: [ + { waitBefore: MS / 2, expectedOutput: false, thenUpdateInputTo: false }, + { waitBefore: MS / 2, expectedOutput: false, thenUpdateInputTo: true }, + { waitBefore: MS / 2, expectedOutput: false, thenUpdateInputTo: false }, + { waitBefore: MS / 2, expectedOutput: false, thenUpdateInputTo: true }, + { waitBefore: MS / 2, expectedOutput: false, thenUpdateInputTo: false }, + { waitBefore: MS * 2, expectedOutput: false }, + ], + }, + { + initialValue: true, + sequence: [ + { waitBefore: MS / 5, expectedOutput: false, thenUpdateInputTo: true }, + { waitBefore: MS / 5, expectedOutput: false, thenUpdateInputTo: true }, + { waitBefore: MS / 5, expectedOutput: false, thenUpdateInputTo: true }, + { waitBefore: MS / 5, expectedOutput: false, thenUpdateInputTo: true }, + { waitBefore: MS / 5 - 1, expectedOutput: false, thenUpdateInputTo: true }, + { waitBefore: MS / 5 + 1, expectedOutput: true }, + ], + }, + ]; + + for (let i = 0; i < sequencesToTest.length; i++) { + const currentSequence = sequencesToTest[i]; + test(JSON.stringify(currentSequence, null, 2), async () => { + await testSequence(sequencesToTest[i]); + }); + } +});