diff --git a/docs/API.md b/docs/API.md index 915834ec6..d59702ad3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -379,3 +379,7 @@ const { queryAllByText } = render(); const submitButtons = queryAllByText('submit'); expect(submitButtons).toHaveLength(3); // expect 3 elements ``` + +## `act` + +Useful function to help testing components that use hooks API. By default any `render` and `fireEvent` calls are wrapped by this function, so there is no need to wrap it manually. This method is re-exported from [`react-test-renderer`](https://github.com/facebook/react/blob/master/packages/react-test-renderer/src/ReactTestRenderer.js#L567]). diff --git a/flow-typed/npm/react-test-renderer_v16.x.x.js b/flow-typed/npm/react-test-renderer_v16.x.x.js index 4e1d1ffe5..28ca6bade 100644 --- a/flow-typed/npm/react-test-renderer_v16.x.x.js +++ b/flow-typed/npm/react-test-renderer_v16.x.x.js @@ -9,13 +9,13 @@ type ReactComponentInstance = React$Component; type ReactTestRendererJSON = { type: string, props: { [propName: string]: any }, - children: null | ReactTestRendererJSON[] + children: null | ReactTestRendererJSON[], }; type ReactTestRendererTree = ReactTestRendererJSON & { - nodeType: "component" | "host", + nodeType: 'component' | 'host', instance: ?ReactComponentInstance, - rendered: null | ReactTestRendererTree + rendered: null | ReactTestRendererTree, }; type ReactTestInstance = { @@ -40,30 +40,36 @@ type ReactTestInstance = { findAllByProps( props: { [propName: string]: any }, options?: { deep: boolean } - ): ReactTestInstance[] + ): ReactTestInstance[], }; type TestRendererOptions = { - createNodeMock(element: React$Element): any + createNodeMock(element: React$Element): any, +}; + +type Thenable = { + then(resolve: () => mixed, reject?: () => mixed): mixed, }; -declare module "react-test-renderer" { +declare module 'react-test-renderer' { declare export type ReactTestRenderer = { toJSON(): null | ReactTestRendererJSON, toTree(): null | ReactTestRendererTree, unmount(nextElement?: React$Element): void, update(nextElement: React$Element): void, getInstance(): ?ReactComponentInstance, - root: ReactTestInstance + root: ReactTestInstance, }; declare function create( nextElement: React$Element, options?: TestRendererOptions ): ReactTestRenderer; + + declare function act(callback: () => void): Thenable; } -declare module "react-test-renderer/shallow" { +declare module 'react-test-renderer/shallow' { declare export default class ShallowRenderer { static createRenderer(): ShallowRenderer; getMountedInstance(): ReactTestInstance; diff --git a/package.json b/package.json index 2efdb4b5c..386dd8dcf 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ "flow-copy-source": "^2.0.2", "jest": "^24.1.0", "metro-react-native-babel-preset": "^0.49.1", - "react": "16.6.3", + "react": "^16.8.3", "react-native": "^0.58.3", - "react-test-renderer": "16.6.3", + "react-test-renderer": "^16.8.3", "release-it": "^10.0.0", "strip-ansi": "^5.0.0", "typescript": "^3.1.1" @@ -50,7 +50,10 @@ }, "jest": { "preset": "react-native", - "moduleFileExtensions": ["js", "json"] + "moduleFileExtensions": [ + "js", + "json" + ] }, "greenkeeper": { "ignore": [ diff --git a/src/__tests__/act.test.js b/src/__tests__/act.test.js new file mode 100644 index 000000000..db29ea4f3 --- /dev/null +++ b/src/__tests__/act.test.js @@ -0,0 +1,50 @@ +// @flow +import React from 'react'; +import { Text } from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; +import act from '../act'; +import render from '../render'; +import fireEvent from '../fireEvent'; + +const UseEffect = ({ callback }: { callback: Function }) => { + React.useEffect(callback); + return null; +}; + +const Counter = () => { + const [count, setCount] = React.useState(0); + + return ( + setCount(count + 1)}> + {count} + + ); +}; + +test('render should trigger useEffect', () => { + const effectCallback = jest.fn(); + render(); + + expect(effectCallback).toHaveBeenCalledTimes(1); +}); + +test('fireEvent should trigger useState', () => { + const { getByTestId } = render(); + const counter = getByTestId('counter'); + + expect(counter.props.children).toEqual(0); + fireEvent.press(counter); + expect(counter.props.children).toEqual(1); +}); + +test('should act even if there is no act in react-test-renderer', () => { + // $FlowFixMe + ReactTestRenderer.act = undefined; + const callback = jest.fn(); + + act(() => { + callback(); + }); + + expect(callback).toHaveBeenCalled(); +}); diff --git a/src/act.js b/src/act.js new file mode 100644 index 000000000..ac1dcb90e --- /dev/null +++ b/src/act.js @@ -0,0 +1,8 @@ +// @flow +import { act } from 'react-test-renderer'; + +const actMock = (callback: () => void) => { + callback(); +}; + +export default act || actMock; diff --git a/src/fireEvent.js b/src/fireEvent.js index 8386ecd6a..b2c49af78 100644 --- a/src/fireEvent.js +++ b/src/fireEvent.js @@ -1,4 +1,5 @@ // @flow +import act from './act'; import { ErrorWithStack } from './helpers/errors'; const findEventHandler = (element: ReactTestInstance, eventName: string) => { @@ -23,10 +24,16 @@ const invokeEvent = ( element: ReactTestInstance, eventName: string, data?: * -) => { +): any => { const handler = findEventHandler(element, eventName); - return handler(data); + let returnValue; + + act(() => { + returnValue = handler(data); + }); + + return returnValue; }; const toEventHandlerName = (eventName: string) => diff --git a/src/index.js b/src/index.js index f8e6548fe..42547250c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ // @flow +import act from './act'; import render from './render'; import shallow from './shallow'; import flushMicrotasksQueue from './flushMicrotasksQueue'; @@ -12,3 +13,4 @@ export { flushMicrotasksQueue }; export { debug }; export { fireEvent }; export { waitForElement }; +export { act }; diff --git a/src/render.js b/src/render.js index 3262645ee..147175427 100644 --- a/src/render.js +++ b/src/render.js @@ -1,20 +1,26 @@ // @flow import * as React from 'react'; -import TestRenderer from 'react-test-renderer'; // eslint-disable-line import/no-extraneous-dependencies +import TestRenderer, { type ReactTestRenderer } from 'react-test-renderer'; // eslint-disable-line import/no-extraneous-dependencies +import act from './act'; import { getByAPI } from './helpers/getByAPI'; import { queryByAPI } from './helpers/queryByAPI'; import debugShallow from './helpers/debugShallow'; import debugDeep from './helpers/debugDeep'; +type Options = { + createNodeMock: (element: React.Element) => any, +}; + /** * Renders test component deeply using react-test-renderer and exposes helpers * to assert on the output. */ export default function render( component: React.Element, - options?: { createNodeMock: (element: React.Element) => any } + options?: Options ) { - const renderer = TestRenderer.create(component, options); + const renderer = renderWithAct(component, options); + const instance = renderer.root; return { @@ -27,6 +33,19 @@ export default function render( }; } +function renderWithAct( + component: React.Element, + options?: Options +): ReactTestRenderer { + let renderer: ReactTestRenderer; + + act(() => { + renderer = TestRenderer.create(component, options); + }); + + return ((renderer: any): ReactTestRenderer); +} + function debug(instance: ReactTestInstance, renderer) { function debugImpl(message?: string) { return debugDeep(renderer.toJSON(), message); diff --git a/typings/__tests__/index.test.tsx b/typings/__tests__/index.test.tsx index b9312587f..37d084cbc 100644 --- a/typings/__tests__/index.test.tsx +++ b/typings/__tests__/index.test.tsx @@ -7,6 +7,7 @@ import { flushMicrotasksQueue, debug, waitForElement, + act, } from '../..'; interface HasRequiredProp { @@ -131,3 +132,7 @@ const waitBy: Promise = waitForElement( const waitByAll: Promise> = waitForElement< Array >(() => tree.getAllByName('View'), 1000, 50); + +act(() => { + render(); +}); diff --git a/typings/index.d.ts b/typings/index.d.ts index 0b974e597..88a2b29e6 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -33,6 +33,10 @@ export interface QueryByAPI { ) => Array | []; } +export interface Thenable { + then: (resolve: () => any, reject?: () => any) => any, +} + export interface RenderOptions { createNodeMock: (element: React.ReactElement) => any; } @@ -86,3 +90,4 @@ export declare const flushMicrotasksQueue: () => Promise; export declare const debug: DebugAPI; export declare const fireEvent: FireEventAPI; export declare const waitForElement: WaitForElementFunction; +export declare const act: (callback: () => void) => Thenable; diff --git a/yarn.lock b/yarn.lock index 84cecb50e..1d9f2773f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6185,10 +6185,10 @@ react-devtools-core@^3.4.2: shell-quote "^1.6.1" ws "^3.3.1" -react-is@^16.6.3: - version "16.8.0" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.0.tgz#518db476214f3fb0716af9f82dfd420225ae970f" - integrity sha512-LOy+3La39aduxaPfuj+lCXC5RQ8ukjVPAAsFJ3yQ+DIOLf4eR9OMKeWKF0IzjRyE95xMj5QELwiXGgfQsIJguA== +react-is@^16.8.3: + version "16.8.3" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.3.tgz#4ad8b029c2a718fc0cfc746c8d4e1b7221e5387d" + integrity sha512-Y4rC1ZJmsxxkkPuMLwvKvlL1Zfpbcu+Bf4ZigkHup3v9EfdYhAlWAaVyA19olXq2o2mGn0w+dFKvk3pVVlYcIA== react-native@^0.58.3: version "0.58.3" @@ -6258,15 +6258,15 @@ react-proxy@^1.1.7: lodash "^4.6.1" react-deep-force-update "^1.0.0" -react-test-renderer@16.6.3: - version "16.6.3" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.6.3.tgz#5f3a1a7d5c3379d46f7052b848b4b72e47c89f38" - integrity sha512-B5bCer+qymrQz/wN03lT0LppbZUDRq6AMfzMKrovzkGzfO81a9T+PWQW6MzkWknbwODQH/qpJno/yFQLX5IWrQ== +react-test-renderer@^16.8.3: + version "16.8.3" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.8.3.tgz#230006af264cc46aeef94392e04747c21839e05e" + integrity sha512-rjJGYebduKNZH0k1bUivVrRLX04JfIQ0FKJLPK10TAb06XWhfi4gTobooF9K/DEFNW98iGac3OSxkfIJUN9Mdg== dependencies: object-assign "^4.1.1" prop-types "^15.6.2" - react-is "^16.6.3" - scheduler "^0.11.2" + react-is "^16.8.3" + scheduler "^0.13.3" react-transform-hmr@^1.0.4: version "1.0.4" @@ -6275,15 +6275,15 @@ react-transform-hmr@^1.0.4: global "^4.3.0" react-proxy "^1.1.7" -react@16.6.3: - version "16.6.3" - resolved "https://registry.yarnpkg.com/react/-/react-16.6.3.tgz#25d77c91911d6bbdd23db41e70fb094cc1e0871c" - integrity sha512-zCvmH2vbEolgKxtqXL2wmGCUxUyNheYn/C+PD1YAjfxHC54+MhdruyhO7QieQrYsYeTxrn93PM2y0jRH1zEExw== +react@^16.8.3: + version "16.8.3" + resolved "https://registry.yarnpkg.com/react/-/react-16.8.3.tgz#c6f988a2ce895375de216edcfaedd6b9a76451d9" + integrity sha512-3UoSIsEq8yTJuSu0luO1QQWYbgGEILm+eJl2QN/VLDi7hL+EN18M3q3oVZwmVzzBJ3DkM7RMdRwBmZZ+b4IzSA== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "^0.11.2" + scheduler "^0.13.3" read-pkg-up@^1.0.1: version "1.0.1" @@ -6698,10 +6698,10 @@ sax@~1.1.1: version "1.1.6" resolved "https://registry.yarnpkg.com/sax/-/sax-1.1.6.tgz#5d616be8a5e607d54e114afae55b7eaf2fcc3240" -scheduler@^0.11.2: - version "0.11.3" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.11.3.tgz#b5769b90cf8b1464f3f3cfcafe8e3cd7555a2d6b" - integrity sha512-i9X9VRRVZDd3xZw10NY5Z2cVMbdYg6gqFecfj79USv1CFN+YrJ3gIPRKf1qlY+Sxly4djoKdfx1T+m9dnRB8kQ== +scheduler@^0.13.3: + version "0.13.3" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.3.tgz#bed3c5850f62ea9c716a4d781f9daeb9b2a58896" + integrity sha512-UxN5QRYWtpR1egNWzJcVLk8jlegxAugswQc984lD3kU7NuobsO37/sRfbpTdBjtnD5TBNFA2Q2oLV5+UmPSmEQ== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1"