diff --git a/src/__tests__/__snapshots__/render.breaking.test.tsx.snap b/src/__tests__/__snapshots__/render.breaking.test.tsx.snap new file mode 100644 index 000000000..018ae7d63 --- /dev/null +++ b/src/__tests__/__snapshots__/render.breaking.test.tsx.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`toJSON renders host output 1`] = ` + + + press me + + +`; diff --git a/src/__tests__/__snapshots__/render.test.tsx.snap b/src/__tests__/__snapshots__/render.test.tsx.snap index 7b42db7e6..018ae7d63 100644 --- a/src/__tests__/__snapshots__/render.test.tsx.snap +++ b/src/__tests__/__snapshots__/render.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`toJSON 1`] = ` +exports[`toJSON renders host output 1`] = ` { + configureInternal({ useBreakingChanges: true }); +}); + +const PLACEHOLDER_FRESHNESS = 'Add custom freshness'; +const PLACEHOLDER_CHEF = 'Who inspected freshness?'; +const INPUT_FRESHNESS = 'Custom Freshie'; +const INPUT_CHEF = 'I inspected freshie'; +const DEFAULT_INPUT_CHEF = 'What did you inspect?'; +const DEFAULT_INPUT_CUSTOMER = 'What banana?'; + +class MyButton extends React.Component { + render() { + return ( + + {this.props.children} + + ); + } +} + +class Banana extends React.Component { + state = { + fresh: false, + }; + + componentDidUpdate() { + if (this.props.onUpdate) { + this.props.onUpdate(); + } + } + + componentWillUnmount() { + if (this.props.onUnmount) { + this.props.onUnmount(); + } + } + + changeFresh = () => { + this.setState((state) => ({ + fresh: !state.fresh, + })); + }; + + render() { + const test = 0; + return ( + + Is the banana fresh? + + {this.state.fresh ? 'fresh' : 'not fresh'} + + + + + + + Change freshness! + + First Text + Second Text + {test} + + ); + } +} + +test('UNSAFE_getAllByType, UNSAFE_queryAllByType', () => { + const { UNSAFE_getAllByType, UNSAFE_queryAllByType } = render(); + const [text, status, button] = UNSAFE_getAllByType(Text); + const InExistent = () => null; + + expect(text.props.children).toBe('Is the banana fresh?'); + expect(status.props.children).toBe('not fresh'); + expect(button.props.children).toBe('Change freshness!'); + expect(() => UNSAFE_getAllByType(InExistent)).toThrow('No instances found'); + + expect(UNSAFE_queryAllByType(Text)[1]).toBe(status); + expect(UNSAFE_queryAllByType(InExistent)).toHaveLength(0); +}); + +test('UNSAFE_getByProps, UNSAFE_queryByProps', () => { + const { UNSAFE_getByProps, UNSAFE_queryByProps } = render(); + const primaryType = UNSAFE_getByProps({ type: 'primary' }); + + expect(primaryType.props.children).toBe('Change freshness!'); + expect(() => UNSAFE_getByProps({ type: 'inexistent' })).toThrow( + 'No instances found' + ); + + expect(UNSAFE_queryByProps({ type: 'primary' })).toBe(primaryType); + expect(UNSAFE_queryByProps({ type: 'inexistent' })).toBeNull(); +}); + +test('UNSAFE_getAllByProp, UNSAFE_queryAllByProps', () => { + const { UNSAFE_getAllByProps, UNSAFE_queryAllByProps } = render(); + const primaryTypes = UNSAFE_getAllByProps({ type: 'primary' }); + + expect(primaryTypes).toHaveLength(1); + expect(() => UNSAFE_getAllByProps({ type: 'inexistent' })).toThrow( + 'No instances found' + ); + + expect(UNSAFE_queryAllByProps({ type: 'primary' })).toEqual(primaryTypes); + expect(UNSAFE_queryAllByProps({ type: 'inexistent' })).toHaveLength(0); +}); + +test('update', () => { + const fn = jest.fn(); + const { getByText, update, rerender } = render(); + + fireEvent.press(getByText('Change freshness!')); + + update(); + rerender(); + + expect(fn).toHaveBeenCalledTimes(3); +}); + +test('unmount', () => { + const fn = jest.fn(); + const { unmount } = render(); + unmount(); + expect(fn).toHaveBeenCalled(); +}); + +test('unmount should handle cleanup functions', () => { + const cleanup = jest.fn(); + const Component = () => { + React.useEffect(() => cleanup); + return null; + }; + + const { unmount } = render(); + + unmount(); + + expect(cleanup).toHaveBeenCalledTimes(1); +}); + +test('toJSON renders host output', () => { + const { toJSON } = render(press me); + expect(toJSON()).toMatchSnapshot(); +}); + +test('renders options.wrapper around node', () => { + type WrapperComponentProps = { children: React.ReactNode }; + const WrapperComponent = ({ children }: WrapperComponentProps) => ( + {children} + ); + + const { toJSON, getByTestId } = render(, { + wrapper: WrapperComponent, + }); + + expect(getByTestId('wrapper')).toBeTruthy(); + expect(toJSON()).toMatchInlineSnapshot(` + + + + `); +}); + +test('renders options.wrapper around updated node', () => { + type WrapperComponentProps = { children: React.ReactNode }; + const WrapperComponent = ({ children }: WrapperComponentProps) => ( + {children} + ); + + const { toJSON, getByTestId, rerender } = render(, { + wrapper: WrapperComponent, + }); + + rerender( + + ); + + expect(getByTestId('wrapper')).toBeTruthy(); + expect(toJSON()).toMatchInlineSnapshot(` + + + + `); +}); + +test('returns host root', () => { + const { root } = render(); + + expect(root).toBeDefined(); + expect(root.type).toBe('View'); + expect(root.props.testID).toBe('inner'); +}); + +test('returns composite UNSAFE_root', () => { + const { UNSAFE_root } = render(); + + expect(UNSAFE_root).toBeDefined(); + expect(UNSAFE_root.type).toBe(View); + expect(UNSAFE_root.props.testID).toBe('inner'); +}); + +test('container displays deprecation', () => { + const view = render(); + + expect(() => view.container).toThrowErrorMatchingInlineSnapshot(` + "'container' property has been renamed to 'UNSAFE_root'. + + Consider using 'root' property which returns root host element." + `); + expect(() => screen.container).toThrowErrorMatchingInlineSnapshot(` + "'container' property has been renamed to 'UNSAFE_root'. + + Consider using 'root' property which returns root host element." + `); +}); + +test('RenderAPI type', () => { + render() as RenderAPI; + expect(true).toBeTruthy(); +}); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index 53a0185c8..145fc6248 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -3,6 +3,12 @@ import * as React from 'react'; import { View, Text, TextInput, Pressable, SafeAreaView } from 'react-native'; import { render, fireEvent, RenderAPI } from '..'; +type ConsoleLogMock = jest.Mock; + +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); + const PLACEHOLDER_FRESHNESS = 'Add custom freshness'; const PLACEHOLDER_CHEF = 'Who inspected freshness?'; const INPUT_FRESHNESS = 'Custom Freshie'; @@ -148,7 +154,7 @@ test('unmount should handle cleanup functions', () => { expect(cleanup).toHaveBeenCalledTimes(1); }); -test('toJSON', () => { +test('toJSON renders host output', () => { const { toJSON } = render(press me); expect(toJSON()).toMatchSnapshot(); @@ -204,9 +210,32 @@ test('renders options.wrapper around updated node', () => { `); }); +test('returns host root', () => { + const { root } = render(); + + expect(root).toBeDefined(); + expect(root.type).toBe('View'); + expect(root.props.testID).toBe('inner'); +}); + +test('returns composite UNSAFE_root', () => { + const { UNSAFE_root } = render(); + + expect(UNSAFE_root).toBeDefined(); + expect(UNSAFE_root.type).toBe(View); + expect(UNSAFE_root.props.testID).toBe('inner'); +}); + test('returns container', () => { const { container } = render(); + const mockCalls = (console.warn as any as ConsoleLogMock).mock.calls; + expect(mockCalls[0][0]).toMatchInlineSnapshot(` + "'container' property is deprecated and has been renamed to 'UNSAFE_root'. + + Consider using 'root' property which returns root host element." + `); + expect(container).toBeDefined(); // `View` composite component is returned. This behavior will break if we // start returning only host components. @@ -214,7 +243,7 @@ test('returns container', () => { expect(container.props.testID).toBe('inner'); }); -test('returns wrapped component as container', () => { +test('returns wrapper component as container', () => { type WrapperComponentProps = { children: React.ReactNode }; const WrapperComponent = ({ children }: WrapperComponentProps) => ( {children} diff --git a/src/__tests__/screen.test.tsx b/src/__tests__/screen.test.tsx index 10f1751d4..f13685a51 100644 --- a/src/__tests__/screen.test.tsx +++ b/src/__tests__/screen.test.tsx @@ -52,6 +52,10 @@ test('screen works with nested re-mounting rerender', () => { }); test('screen throws without render', () => { + expect(() => screen.root).toThrow('`render` method has not been called'); + expect(() => screen.UNSAFE_root).toThrow( + '`render` method has not been called' + ); expect(() => screen.container).toThrow('`render` method has not been called'); expect(() => screen.debug()).toThrow('`render` method has not been called'); expect(() => screen.debug.shallow()).toThrow( diff --git a/src/queries/__tests__/displayValue.breaking.test.tsx b/src/queries/__tests__/displayValue.breaking.test.tsx index 1bcb79b88..01949d375 100644 --- a/src/queries/__tests__/displayValue.breaking.test.tsx +++ b/src/queries/__tests__/displayValue.breaking.test.tsx @@ -1,3 +1,5 @@ +/** This is a copy of regular tests with `useBreakingChanges` flag turned on. */ + import * as React from 'react'; import { View, TextInput } from 'react-native'; diff --git a/src/queries/__tests__/placeholderText.breaking.test.tsx b/src/queries/__tests__/placeholderText.breaking.test.tsx index c3a35fb0d..ab2c82264 100644 --- a/src/queries/__tests__/placeholderText.breaking.test.tsx +++ b/src/queries/__tests__/placeholderText.breaking.test.tsx @@ -1,3 +1,5 @@ +/** This is a copy of regular tests with `useBreakingChanges` flag turned on. */ + import * as React from 'react'; import { View, TextInput } from 'react-native'; import { render } from '../..'; diff --git a/src/render.tsx b/src/render.tsx index b60d15139..9fac09ee3 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -10,6 +10,7 @@ import { getQueriesForElement } from './within'; import { setRenderResult, screen } from './screen'; import { validateStringsRenderedWithinText } from './helpers/stringValidation'; import { getConfig } from './config'; +import { getHostChildren } from './helpers/component-tree'; import { configureHostComponentNamesIfNeeded } from './helpers/host-component-names'; export type RenderOptions = { @@ -103,10 +104,28 @@ function buildRenderResult( ...getQueriesForElement(instance), update, unmount, - container: instance, rerender: update, // alias for `update` toJSON: renderer.toJSON, debug: debug(instance, renderer), + get root() { + return getHostChildren(instance)[0]; + }, + UNSAFE_root: instance, + get container() { + if (!getConfig().useBreakingChanges) { + // eslint-disable-next-line no-console + console.warn( + "'container' property is deprecated and has been renamed to 'UNSAFE_root'.\n\n" + + "Consider using 'root' property which returns root host element." + ); + return instance; + } + + throw new Error( + "'container' property has been renamed to 'UNSAFE_root'.\n\n" + + "Consider using 'root' property which returns root host element." + ); + }, }; setRenderResult(result); diff --git a/src/screen.ts b/src/screen.ts index 34a1504d5..9de2c5465 100644 --- a/src/screen.ts +++ b/src/screen.ts @@ -16,6 +16,12 @@ const defaultScreen: RenderResult = { get container(): ReactTestInstance { throw new Error(SCREEN_ERROR); }, + get root(): ReactTestInstance { + throw new Error(SCREEN_ERROR); + }, + get UNSAFE_root(): ReactTestInstance { + throw new Error(SCREEN_ERROR); + }, debug: notImplementedDebug, update: notImplemented, unmount: notImplemented, diff --git a/typings/index.flow.js b/typings/index.flow.js index 112609d60..16736cf28 100644 --- a/typings/index.flow.js +++ b/typings/index.flow.js @@ -436,6 +436,8 @@ declare module '@testing-library/react-native' { unmount(nextElement?: React.Element): void; toJSON(): ReactTestRendererJSON[] | ReactTestRendererJSON | null; debug: Debug; + root: ReactTestInstance; + UNSAFE_root: ReactTestInstance; container: ReactTestInstance; } diff --git a/website/docs/API.md b/website/docs/API.md index a859bd008..3f395fa23 100644 --- a/website/docs/API.md +++ b/website/docs/API.md @@ -19,7 +19,8 @@ title: API - [`mapProps` option](#mapprops-option) - [`debug.shallow`](#debugshallow) - [`toJSON`](#tojson) - - [`container`](#container) + - [`root`](#root) + - [`UNSAFE_root`](#unsafe_root) - [`screen`](#screen) - [`cleanup`](#cleanup) - [`fireEvent`](#fireevent) @@ -251,13 +252,31 @@ toJSON(): ReactTestRendererJSON | null Get the rendered component JSON representation, e.g. for snapshot testing. -### `container` +### `root` ```ts -container: ReactTestInstance; +root: ReactTestInstance; ``` -A reference to the rendered root element. +Returns the rendered root [host element](testing-env#host-and-composite-components). + +This API is primarily useful in component tests, as it allows you to access root host view without using `*ByTestId` queries or similar methods. + +### `UNSAFE_root` + +```ts +UNSAFE_root: ReactTestInstance; +``` + +Returns the rendered [composite root element](testing-env#host-and-composite-components). + +:::caution +This API typically will return a composite view which goes against recommended testing practices. This API is primarily available for legacy test suites that rely on such testing. +::: + +:::note +This API has been previously named `container` for compatibility with [React Testing Library](https://testing-library.com/docs/react-testing-library/api#container-1). However, despite the same name, the actual behavior has been signficantly different, hence the name change to `UNSAFE_root`. +::: ## `screen` diff --git a/website/docs/Queries.md b/website/docs/Queries.md index fa539ec48..e40adb46b 100644 --- a/website/docs/Queries.md +++ b/website/docs/Queries.md @@ -39,7 +39,7 @@ title: Queries ## Variants -> `getBy` queries are shown by default in the [query documentation](#queries) +> `getBy*` queries are shown by default in the [query documentation](#queries) > below. ### getBy @@ -60,22 +60,22 @@ title: Queries ### findBy -`findBy` queries return a promise which resolves when a matching element is found. The promise is rejected if no elements match or if more than one match is found after a default timeout of 1000 ms. If you need to find more than one element, then use `findAllBy`. +`findBy*` queries return a promise which resolves when a matching element is found. The promise is rejected if no elements match or if more than one match is found after a default timeout of 1000 ms. If you need to find more than one element, then use `findAllBy*`. ### findAllBy -`findAllBy` queries return a promise which resolves to an array of matching elements. The promise is rejected if no elements match after a default timeout of 1000 ms. +`findAllBy*` queries return a promise which resolves to an array of matching elements. The promise is rejected if no elements match after a default timeout of 1000 ms. :::info -`findBy` and `findAllBy` queries accept optional `waitForOptions` object argument which can contain `timeout`, `interval` and `onTimeout` properies which have the same meaning as respective options for [`waitFor`](api#waitfor) function. +`findBy*` and `findAllBy*` queries accept optional `waitForOptions` object argument which can contain `timeout`, `interval` and `onTimeout` properies which have the same meaning as respective options for [`waitFor`](api#waitfor) function. ::: :::info -In cases when your `findBy` and `findAllBy` queries throw when not able to find matching elements it is useful to pass `onTimeout: () => { screen.debug(); }` callback using `waitForOptions` parameter. +In cases when your `findBy*` and `findAllBy*` queries throw when not able to find matching elements it is useful to pass `onTimeout: () => { screen.debug(); }` callback using `waitForOptions` parameter. ::: :::info -In order to properly use `findBy` and `findAllBy` queries you need at least React >=16.9.0 (featuring async `act`) or React Native >=0.61 (which comes with React >=16.9.0). +In order to properly use `findBy*` and `findAllBy*` queries you need at least React >=16.9.0 (featuring async `act`) or React Native >=0.61 (which comes with React >=16.9.0). ::: ## Queries