diff --git a/jest-setup.ts b/jest-setup.ts index 5d2c4d5b2..948c468d1 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -5,3 +5,6 @@ jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); beforeEach(() => { resetToDefaults(); }); + +// Disable colors in our local tests in order to generate clear snapshots +process.env.COLORS = 'false'; diff --git a/src/__tests__/__snapshots__/render-debug.test.tsx.snap b/src/__tests__/__snapshots__/render-debug.test.tsx.snap index 2b44623dd..e72298ccc 100644 --- a/src/__tests__/__snapshots__/render-debug.test.tsx.snap +++ b/src/__tests__/__snapshots__/render-debug.test.tsx.snap @@ -30,7 +30,7 @@ exports[`debug 1`] = ` /> { + mockCreate.mockReset(); +}); describe('getHostComponentNames', () => { test('returns host component names from internal config', () => { @@ -79,8 +78,10 @@ describe('configureHostComponentNamesIfNeeded', () => { }); test('throw an error when autodetection fails', () => { + const renderer = TestRenderer.create(); + mockCreate.mockReturnValue({ - root: { type: View, children: [], props: {} }, + root: renderer.root, }); expect(() => configureHostComponentNamesIfNeeded()) @@ -93,22 +94,4 @@ describe('configureHostComponentNamesIfNeeded', () => { Please check if you are using compatible versions of React Native and React Native Testing Library." `); }); - - test('throw an error when autodetection fails due to getByTestId returning non-host component', () => { - mockGetQueriesForElements.mockReturnValue({ - getByTestId: () => { - return { type: View }; - }, - }); - - expect(() => configureHostComponentNamesIfNeeded()) - .toThrowErrorMatchingInlineSnapshot(` - "Trying to detect host component names triggered the following error: - - getByTestId returned non-host component - - There seems to be an issue with your configuration that prevents React Native Testing Library from working correctly. - Please check if you are using compatible versions of React Native and React Native Testing Library." - `); - }); }); diff --git a/src/__tests__/waitFor.test.tsx b/src/__tests__/waitFor.test.tsx index 6e73fb3a5..a4dc061a1 100644 --- a/src/__tests__/waitFor.test.tsx +++ b/src/__tests__/waitFor.test.tsx @@ -316,3 +316,12 @@ test.each([ expect(onPress).toHaveBeenCalledWith('red'); } ); + +test('waitFor throws if expectation is not a function', async () => { + await expect( + // @ts-expect-error intentionally passing non-function + waitFor('not a function') + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Received \`expectation\` arg must be a function"` + ); +}); diff --git a/src/helpers/__tests__/format-default.tsx b/src/helpers/__tests__/format-default.tsx new file mode 100644 index 000000000..6cd49c5fc --- /dev/null +++ b/src/helpers/__tests__/format-default.tsx @@ -0,0 +1,114 @@ +import { ReactTestRendererJSON } from 'react-test-renderer'; +import { defaultMapProps } from '../format-default'; + +const node: ReactTestRendererJSON = { + type: 'View', + props: {}, + children: null, +}; + +describe('mapPropsForQueryError', () => { + test('preserves props that are helpful for debugging', () => { + const props = { + accessibilityElementsHidden: true, + accessibilityViewIsModal: true, + importantForAccessibility: 'yes', + testID: 'TEST_ID', + nativeID: 'NATIVE_ID', + accessibilityLabel: 'LABEL', + accessibilityLabelledBy: 'LABELLED_BY', + accessibilityRole: 'ROLE', + accessibilityHint: 'HINT', + placeholder: 'PLACEHOLDER', + value: 'VALUE', + defaultValue: 'DEFAULT_VALUE', + }; + + const result = defaultMapProps(props, node); + + expect(result).toStrictEqual(props); + }); + + test('does not preserve less helpful props', () => { + const result = defaultMapProps( + { + style: [{ flex: 1 }, { display: 'flex' }], + onPress: () => null, + key: 'foo', + }, + node + ); + + expect(result).toStrictEqual({}); + }); + + test('preserves "display: none" style but no other style', () => { + const result = defaultMapProps( + { style: [{ flex: 1 }, { display: 'none', flex: 2 }] }, + node + ); + + expect(result).toStrictEqual({ + style: { display: 'none' }, + }); + }); + + test('removes undefined keys from accessibilityState', () => { + const result = defaultMapProps( + { accessibilityState: { checked: undefined, selected: false } }, + node + ); + + expect(result).toStrictEqual({ + accessibilityState: { selected: false }, + }); + }); + + test('removes accessibilityState if all keys are undefined', () => { + const result = defaultMapProps( + { accessibilityState: { checked: undefined, selected: undefined } }, + node + ); + + expect(result).toStrictEqual({}); + }); + + test('does not fail if accessibilityState is a string, passes through', () => { + const result = defaultMapProps({ accessibilityState: 'foo' }, node); + expect(result).toStrictEqual({ accessibilityState: 'foo' }); + }); + + test('does not fail if accessibilityState is an array, passes through', () => { + const result = defaultMapProps({ accessibilityState: [1] }, node); + expect(result).toStrictEqual({ accessibilityState: [1] }); + }); + + test('does not fail if accessibilityState is null, passes through', () => { + const result = defaultMapProps({ accessibilityState: null }, node); + expect(result).toStrictEqual({ accessibilityState: null }); + }); + + test('does not fail if accessibilityState is nested object, passes through', () => { + const accessibilityState = { 1: { 2: 3 }, 2: undefined }; + const result = defaultMapProps({ accessibilityState }, node); + expect(result).toStrictEqual({ accessibilityState: { 1: { 2: 3 } } }); + }); + + test('removes undefined keys from accessibilityValue', () => { + const result = defaultMapProps( + { accessibilityValue: { min: 1, max: undefined } }, + node + ); + + expect(result).toStrictEqual({ accessibilityValue: { min: 1 } }); + }); + + test('removes accessibilityValue if all keys are undefined', () => { + const result = defaultMapProps( + { accessibilityValue: { min: undefined } }, + node + ); + + expect(result).toStrictEqual({}); + }); +}); diff --git a/src/helpers/format-default.ts b/src/helpers/format-default.ts new file mode 100644 index 000000000..0f2329329 --- /dev/null +++ b/src/helpers/format-default.ts @@ -0,0 +1,72 @@ +import { StyleSheet, ViewStyle } from 'react-native'; +import { MapPropsFunction } from './format'; + +const propsToDisplay = [ + 'testID', + 'nativeID', + 'accessibilityElementsHidden', + 'accessibilityViewIsModal', + 'importantForAccessibility', + 'accessibilityRole', + 'accessibilityLabel', + 'accessibilityLabelledBy', + 'accessibilityHint', + 'placeholder', + 'value', + 'defaultValue', + 'title', +]; + +/** + * Preserve props that are helpful in diagnosing test failures, while stripping rest + */ +export const defaultMapProps: MapPropsFunction = (props) => { + const result: Record = {}; + + const styles = StyleSheet.flatten(props.style as ViewStyle); + if (styles?.display === 'none') { + result.style = { display: 'none' }; + } + + const accessibilityState = removeUndefinedKeys(props.accessibilityState); + if (accessibilityState !== undefined) { + result.accessibilityState = accessibilityState; + } + + const accessibilityValue = removeUndefinedKeys(props.accessibilityValue); + if (accessibilityValue !== undefined) { + result.accessibilityValue = accessibilityValue; + } + + propsToDisplay.forEach((propName) => { + if (propName in props) { + result[propName] = props[propName]; + } + }); + + return result; +}; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function removeUndefinedKeys(prop: unknown) { + if (!isObject(prop)) { + return prop; + } + + const result: Record = {}; + Object.entries(prop).forEach(([key, value]) => { + if (value !== undefined) { + result[key] = value; + } + }); + + // If object does not have any props we will ignore it. + if (Object.keys(result).length === 0) { + return undefined; + } + + return result; +} diff --git a/src/helpers/format.ts b/src/helpers/format.ts index f8dbf6d3e..3bffc5917 100644 --- a/src/helpers/format.ts +++ b/src/helpers/format.ts @@ -1,7 +1,7 @@ import type { ReactTestRendererJSON } from 'react-test-renderer'; import prettyFormat, { NewPlugin, plugins } from 'pretty-format'; -type MapPropsFunction = ( +export type MapPropsFunction = ( props: Record, node: ReactTestRendererJSON ) => Record; @@ -16,7 +16,8 @@ const format = ( ) => prettyFormat(input, { plugins: [getCustomPlugin(options.mapProps), plugins.ReactElement], - highlight: true, + highlight: shouldHighlight(), + printBasicPrototype: false, }); const getCustomPlugin = (mapProps?: MapPropsFunction): NewPlugin => { @@ -39,4 +40,8 @@ const getCustomPlugin = (mapProps?: MapPropsFunction): NewPlugin => { }; }; +function shouldHighlight() { + return process?.env?.COLORS !== 'false'; +} + export default format; diff --git a/src/helpers/host-component-names.tsx b/src/helpers/host-component-names.tsx index 475ba8518..ac91cc71a 100644 --- a/src/helpers/host-component-names.tsx +++ b/src/helpers/host-component-names.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; +import { ReactTestInstance } from 'react-test-renderer'; import { Text, TextInput, View } from 'react-native'; import { configureInternal, getConfig, HostComponentNames } from '../config'; import { renderWithAct } from '../render-act'; -import { getQueriesForElement } from '../within'; const userConfigErrorMessage = `There seems to be an issue with your configuration that prevents React Native Testing Library from working correctly. Please check if you are using compatible versions of React Native and React Native Testing Library.`; @@ -35,21 +35,10 @@ function detectHostComponentNames(): HostComponentNames { ); - const { getByTestId } = getQueriesForElement(renderer.root); - const textHostName = getByTestId('text').type; - const textInputHostName = getByTestId('textInput').type; - - // This code path should not happen as getByTestId always returns host elements. - if ( - typeof textHostName !== 'string' || - typeof textInputHostName !== 'string' - ) { - throw new Error('getByTestId returned non-host component'); - } return { - text: textHostName, - textInput: textInputHostName, + text: getByTestId(renderer.root, 'text').type as string, + textInput: getByTestId(renderer.root, 'textInput').type as string, }; } catch (error) { const errorMessage = @@ -62,3 +51,15 @@ function detectHostComponentNames(): HostComponentNames { ); } } + +function getByTestId(instance: ReactTestInstance, testID: string) { + const nodes = instance.findAll( + (node) => typeof node.type === 'string' && node.props.testID === testID + ); + + if (nodes.length === 0) { + throw new Error(`Unable to find an element with testID: ${testID}`); + } + + return nodes[0]; +} diff --git a/src/queries/__tests__/a11yState.test.tsx b/src/queries/__tests__/a11yState.test.tsx index 41ed4b7c0..ea3163b46 100644 --- a/src/queries/__tests__/a11yState.test.tsx +++ b/src/queries/__tests__/a11yState.test.tsx @@ -254,9 +254,26 @@ test('byA11yState queries support hidden option', () => { ).toBeFalsy(); expect(() => getByA11yState({ expanded: false }, { includeHiddenElements: false }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with expanded state: false"` - ); + ).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with expanded state: false + + + + Hidden from accessibility + + " + `); }); test('*ByA11yState deprecation warnings', () => { @@ -352,3 +369,71 @@ test('*ByAccessibilityState deprecation warnings', () => { Use findAllByRole(role, { disabled, selected, checked, busy, expanded }) query or expect(...).toHaveAccessibilityState(...) matcher from "@testing-library/jest-native" package instead." `); }); + +test('error message renders the element tree, preserving only helpful props', async () => { + const view = render( + null}> + Some text + + ); + + expect(() => view.getByA11yState({ checked: true })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with checked state: true + + + Some text + " + `); + + expect(() => view.getAllByA11yState({ checked: true })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with checked state: true + + + Some text + " + `); + + await expect(view.findByA11yState({ checked: true })).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with checked state: true + + + Some text + " + `); + + await expect(view.findAllByA11yState({ checked: true })).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with checked state: true + + + Some text + " + `); +}); diff --git a/src/queries/__tests__/a11yValue.test.tsx b/src/queries/__tests__/a11yValue.test.tsx index a3f6fb74e..8cbd2851a 100644 --- a/src/queries/__tests__/a11yValue.test.tsx +++ b/src/queries/__tests__/a11yValue.test.tsx @@ -107,35 +107,53 @@ test('byA11yValue queries support hidden option', () => { expect( queryByA11yValue({ max: 10 }, { includeHiddenElements: false }) ).toBeFalsy(); - expect(() => - getByA11yValue({ max: 10 }, { includeHiddenElements: false }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with max value: 10"` - ); + expect(() => getByA11yValue({ max: 10 }, { includeHiddenElements: false })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with max value: 10 + + + Hidden from accessibility + " + `); }); test('byA11yValue error messages', () => { const { getByA11yValue } = render(); - expect(() => - getByA11yValue({ min: 10, max: 10 }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with min value: 10, max value: 10"` - ); - expect(() => - getByA11yValue({ max: 20, now: 5 }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with max value: 20, now value: 5"` - ); - expect(() => - getByA11yValue({ min: 1, max: 2, now: 3 }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with min value: 1, max value: 2, now value: 3"` - ); - expect(() => - getByA11yValue({ min: 1, max: 2, now: 3, text: /foo/i }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with min value: 1, max value: 2, now value: 3, text value: /foo/i"` - ); + expect(() => getByA11yValue({ min: 10, max: 10 })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with min value: 10, max value: 10 + + " + `); + expect(() => getByA11yValue({ max: 20, now: 5 })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with max value: 20, now value: 5 + + " + `); + expect(() => getByA11yValue({ min: 1, max: 2, now: 3 })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with min value: 1, max value: 2, now value: 3 + + " + `); + expect(() => getByA11yValue({ min: 1, max: 2, now: 3, text: /foo/i })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with min value: 1, max value: 2, now value: 3, text value: /foo/i + + " + `); }); test('*ByA11yValue deprecation warnings', () => { @@ -231,3 +249,61 @@ test('*ByAccessibilityValue deprecation warnings', () => { Use expect(...).toHaveAccessibilityValue(...) matcher from "@testing-library/jest-native" package or findAllByRole(role, { value: ... }) query instead." `); }); + +test('error message renders the element tree, preserving only helpful props', async () => { + const view = render( + + ); + + expect(() => view.getByA11yValue({ min: 1 })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with min value: 1 + + " + `); + + expect(() => view.getAllByA11yValue({ min: 1 })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with min value: 1 + + " + `); + + await expect(view.findByA11yValue({ min: 1 })).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with min value: 1 + + " + `); + + await expect(view.findAllByA11yValue({ min: 1 })).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with min value: 1 + + " + `); +}); diff --git a/src/queries/__tests__/displayValue.test.tsx b/src/queries/__tests__/displayValue.test.tsx index c1c7f3713..f3e895af6 100644 --- a/src/queries/__tests__/displayValue.test.tsx +++ b/src/queries/__tests__/displayValue.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, TextInput } from 'react-native'; +import { TextInput, View } from 'react-native'; import { render } from '../..'; @@ -50,16 +50,57 @@ test('getByDisplayValue, queryByDisplayValue', () => { test('getByDisplayValue, queryByDisplayValue get element by default value only when value is undefined', () => { const { getByDisplayValue, queryByDisplayValue } = render(); - expect(() => - getByDisplayValue(DEFAULT_INPUT_CHEF) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with displayValue: What did you inspect?"` - ); + expect(() => getByDisplayValue(DEFAULT_INPUT_CHEF)) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with displayValue: What did you inspect? + + + + + + + " + `); expect(queryByDisplayValue(DEFAULT_INPUT_CHEF)).toBeNull(); - expect(() => getByDisplayValue('hello')).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with displayValue: hello"` - ); + expect(() => getByDisplayValue('hello')).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with displayValue: hello + + + + + + + " + `); expect(queryByDisplayValue('hello')).toBeNull(); expect(getByDisplayValue(DEFAULT_INPUT_CUSTOMER)).toBeTruthy(); @@ -119,11 +160,19 @@ test('byDisplayValue queries support hidden option', () => { expect( queryByDisplayValue('hidden', { includeHiddenElements: false }) ).toBeFalsy(); - expect(() => - getByDisplayValue('hidden', { includeHiddenElements: false }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with displayValue: hidden"` - ); + expect(() => getByDisplayValue('hidden', { includeHiddenElements: false })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with displayValue: hidden + + " + `); }); test('byDisplayValue should return host component', () => { @@ -131,3 +180,42 @@ test('byDisplayValue should return host component', () => { expect(getByDisplayValue('value').type).toBe('TextInput'); }); + +test('error message renders the element tree, preserving only helpful props', async () => { + const view = render(); + + expect(() => view.getByDisplayValue('2')).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with displayValue: 2 + + " + `); + + expect(() => view.getAllByDisplayValue('2')) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with displayValue: 2 + + " + `); + + await expect(view.findByDisplayValue('2')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with displayValue: 2 + + " + `); + + await expect(view.findAllByDisplayValue('2')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with displayValue: 2 + + " + `); +}); diff --git a/src/queries/__tests__/hintText.test.tsx b/src/queries/__tests__/hintText.test.tsx index d97427e3c..4d4478768 100644 --- a/src/queries/__tests__/hintText.test.tsx +++ b/src/queries/__tests__/hintText.test.tsx @@ -120,9 +120,58 @@ test('byHintText queries support hidden option', () => { expect( queryByHintText('hidden', { includeHiddenElements: false }) ).toBeFalsy(); - expect(() => - getByHintText('hidden', { includeHiddenElements: false }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with accessibilityHint: hidden"` - ); + expect(() => getByHintText('hidden', { includeHiddenElements: false })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with accessibilityHint: hidden + + + Hidden from accessiblity + " + `); +}); + +test('error message renders the element tree, preserving only helpful props', async () => { + const view = render(); + + expect(() => view.getByHintText('FOO')).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with accessibilityHint: FOO + + " + `); + + expect(() => view.getAllByHintText('FOO')) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with accessibilityHint: FOO + + " + `); + + await expect(view.findByHintText('FOO')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with accessibilityHint: FOO + + " + `); + + await expect(view.findAllByHintText('FOO')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with accessibilityHint: FOO + + " + `); }); diff --git a/src/queries/__tests__/labelText.test.tsx b/src/queries/__tests__/labelText.test.tsx index da6de4ec4..694d982cf 100644 --- a/src/queries/__tests__/labelText.test.tsx +++ b/src/queries/__tests__/labelText.test.tsx @@ -159,11 +159,21 @@ test('byLabelText queries support hidden option', () => { expect( queryByLabelText('hidden', { includeHiddenElements: false }) ).toBeFalsy(); - expect(() => - getByLabelText('hidden', { includeHiddenElements: false }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with accessibilityLabel: hidden"` - ); + expect(() => getByLabelText('hidden', { includeHiddenElements: false })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with accessibilityLabel: hidden + + + Hidden from accessibility + " + `); }); test('getByLabelText supports accessibilityLabelledBy', async () => { @@ -191,3 +201,42 @@ test('getByLabelText supports nested accessibilityLabelledBy', async () => { expect(getByLabelText('Label for input')).toBe(getByTestId('textInput')); expect(getByLabelText(/input/)).toBe(getByTestId('textInput')); }); + +test('error message renders the element tree, preserving only helpful props', async () => { + const view = render(); + + expect(() => view.getByLabelText('FOO')).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with accessibilityLabel: FOO + + " + `); + + expect(() => view.getAllByLabelText('FOO')) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with accessibilityLabel: FOO + + " + `); + + await expect(view.findByLabelText('FOO')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with accessibilityLabel: FOO + + " + `); + + await expect(view.findAllByLabelText('FOO')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with accessibilityLabel: FOO + + " + `); +}); diff --git a/src/queries/__tests__/makeQueries.test.tsx b/src/queries/__tests__/makeQueries.test.tsx new file mode 100644 index 000000000..f7658a4c0 --- /dev/null +++ b/src/queries/__tests__/makeQueries.test.tsx @@ -0,0 +1,235 @@ +import * as React from 'react'; +import { Text, TextInput, View } from 'react-native'; +import { render, screen } from '../..'; + +describe('printing element tree', () => { + test('includes element tree on error with less-helpful props stripped', async () => { + const { getByText } = render( null}>Some text); + + expect(() => getByText(/foo/)).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + + Some text + " + `); + }); + + test('prints helpful props but not others', async () => { + const { getByText } = render( + + + Some Text + + ); + + expect(() => getByText(/foo/)).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + + + + Some Text + + " + `); + }); + + test('prints tree and filters props with getBy, getAllBy, findBy, findAllBy', async () => { + const view = render( + + ); + + expect(() => view.getByText(/foo/)).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + " + `); + + expect(() => view.getAllByText(/foo/)).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + " + `); + + await expect(view.findByText(/foo/)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + " + `); + + await expect(view.findAllByText(/foo/)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + " + `); + }); + + test('only appends element tree on last failure with findBy', async () => { + const { findByText } = render( + + ); + + jest.spyOn(screen, 'toJSON'); + + await expect(findByText(/foo/)).rejects.toThrow(); + + expect(screen.toJSON).toHaveBeenCalledTimes(1); + }); + + test('onTimeout with findBy receives error without element tree', async () => { + const { findByText } = render(); + + const onTimeout = jest.fn((_: Error) => new Error('Replacement error')); + + await expect(() => + findByText(/foo/, undefined, { onTimeout }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Replacement error"`); + + expect(onTimeout).toHaveBeenCalledTimes(1); + expect(onTimeout.mock.calls[0][0].message).not.toMatch(/View/); + expect(onTimeout.mock.calls[0][0].message).toMatchInlineSnapshot( + `"Unable to find an element with text: /foo/"` + ); + }); + + test('onTimeout with findAllBy receives error without element tree', async () => { + const { findAllByText } = render(); + + const onTimeout = jest.fn((_: Error) => new Error('Replacement error')); + + await expect(() => + findAllByText(/foo/, undefined, { onTimeout }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Replacement error"`); + + expect(onTimeout).toHaveBeenCalledTimes(1); + expect(onTimeout.mock.calls[0][0].message).not.toMatch(/View/); + expect(onTimeout.mock.calls[0][0].message).toMatchInlineSnapshot( + `"Unable to find an element with text: /foo/"` + ); + }); + + test('does not strip display: none from "style" prop, but does strip other styles', () => { + const { getByText } = render( + + + Some text + + + ); + + expect(() => getByText(/foo/)).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + + + Some text + + " + `); + }); + + test('strips undefined values from accessibilityState', () => { + const { getByText } = render( + + + + ); + + expect(() => getByText(/foo/)).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + + + " + `); + }); + + test('strips undefined values from accessibilityValue', () => { + const { getByText } = render( + + + + ); + + expect(() => getByText(/foo/)).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + + + " + `); + }); + + test('does not render element tree when toJSON() returns null', () => { + const view = render(); + + jest.spyOn(screen, 'toJSON').mockImplementation(() => null); + expect(() => view.getByText(/foo/)).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with text: /foo/"` + ); + }); +}); diff --git a/src/queries/__tests__/placeholderText.test.tsx b/src/queries/__tests__/placeholderText.test.tsx index 276a301bb..0bfe1c443 100644 --- a/src/queries/__tests__/placeholderText.test.tsx +++ b/src/queries/__tests__/placeholderText.test.tsx @@ -72,11 +72,19 @@ test('byPlaceholderText queries support hidden option', () => { expect( queryByPlaceholderText('hidden', { includeHiddenElements: false }) ).toBeFalsy(); - expect(() => - getByPlaceholderText('hidden', { includeHiddenElements: false }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with placeholder: hidden"` - ); + expect(() => getByPlaceholderText('hidden', { includeHiddenElements: false })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with placeholder: hidden + + " + `); }); test('byPlaceHolderText should return host component', () => { @@ -86,3 +94,43 @@ test('byPlaceHolderText should return host component', () => { expect(getByPlaceholderText('placeholder').type).toBe('TextInput'); }); + +test('error message renders the element tree, preserving only helpful props', async () => { + const view = render(); + + expect(() => view.getByPlaceholderText('FOO')) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with placeholder: FOO + + " + `); + + expect(() => view.getAllByPlaceholderText('FOO')) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with placeholder: FOO + + " + `); + + await expect(view.findByPlaceholderText('FOO')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with placeholder: FOO + + " + `); + + await expect(view.findAllByPlaceholderText('FOO')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with placeholder: FOO + + " + `); +}); diff --git a/src/queries/__tests__/role-value.test.tsx b/src/queries/__tests__/role-value.test.tsx index 80c2c3ea8..c1da194fa 100644 --- a/src/queries/__tests__/role-value.test.tsx +++ b/src/queries/__tests__/role-value.test.tsx @@ -79,25 +79,98 @@ describe('accessibility value', () => { getByRole('adjustable', { disabled: true, value: { min: 10 } }) ).toBeTruthy(); - expect(() => - getByRole('adjustable', { name: 'Hello', value: { min: 5 } }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with role: "adjustable", name: "Hello", min value: 5"` - ); - expect(() => - getByRole('adjustable', { name: 'World', value: { min: 10 } }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with role: "adjustable", name: "World", min value: 10"` - ); - expect(() => - getByRole('adjustable', { name: 'Hello', value: { min: 5 } }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with role: "adjustable", name: "Hello", min value: 5"` - ); + expect(() => getByRole('adjustable', { name: 'Hello', value: { min: 5 } })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "adjustable", name: "Hello", min value: 5 + + + Hello + " + `); + expect(() => getByRole('adjustable', { name: 'World', value: { min: 10 } })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "adjustable", name: "World", min value: 10 + + + Hello + " + `); + expect(() => getByRole('adjustable', { name: 'Hello', value: { min: 5 } })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "adjustable", name: "Hello", min value: 5 + + + Hello + " + `); expect(() => getByRole('adjustable', { selected: true, value: { min: 10 } }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with role: "adjustable", selected state: true, min value: 10"` - ); + ).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "adjustable", selected state: true, min value: 10 + + + Hello + " + `); }); }); diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx index f953061d6..c3fe8e2b2 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -652,29 +652,33 @@ describe('error messages', () => { test('gives a descriptive error message when querying with a role', () => { const { getByRole } = render(); - expect(() => getByRole('button')).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with role: "button""` - ); + expect(() => getByRole('button')).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "button" + + " + `); }); test('gives a descriptive error message when querying with a role and a name', () => { const { getByRole } = render(); - expect(() => - getByRole('button', { name: 'Save' }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with role: "button", name: "Save""` - ); + expect(() => getByRole('button', { name: 'Save' })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "button", name: "Save" + + " + `); }); test('gives a descriptive error message when querying with a role, a name and accessibility state', () => { const { getByRole } = render(); - expect(() => - getByRole('button', { name: 'Save', disabled: true }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with role: "button", name: "Save", disabled state: true"` - ); + expect(() => getByRole('button', { name: 'Save', disabled: true })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "button", name: "Save", disabled state: true + + " + `); }); test('gives a descriptive error message when querying with a role, a name and several accessibility state', () => { @@ -682,37 +686,43 @@ describe('error messages', () => { expect(() => getByRole('button', { name: 'Save', disabled: true, selected: true }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with role: "button", name: "Save", disabled state: true, selected state: true"` - ); + ).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "button", name: "Save", disabled state: true, selected state: true + + " + `); }); test('gives a descriptive error message when querying with a role and an accessibility state', () => { const { getByRole } = render(); - expect(() => - getByRole('button', { disabled: true }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with role: "button", disabled state: true"` - ); + expect(() => getByRole('button', { disabled: true })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "button", disabled state: true + + " + `); }); test('gives a descriptive error message when querying with a role and an accessibility value', () => { const { getByRole } = render(); - expect(() => - getByRole('adjustable', { value: { min: 1 } }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with role: "adjustable", min value: 1"` - ); + expect(() => getByRole('adjustable', { value: { min: 1 } })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "adjustable", min value: 1 + + " + `); expect(() => getByRole('adjustable', { value: { min: 1, max: 2, now: 1, text: /hello/ }, }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with role: "adjustable", min value: 1, max value: 2, now value: 1, text value: /hello/"` - ); + ).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "adjustable", min value: 1, max value: 2, now value: 1, text value: /hello/ + + " + `); }); }); @@ -727,11 +737,23 @@ test('byRole queries support hidden option', () => { expect(queryByRole('button')).toBeFalsy(); expect(queryByRole('button', { includeHiddenElements: false })).toBeFalsy(); - expect(() => - getByRole('button', { includeHiddenElements: false }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with role: "button""` - ); + expect(() => getByRole('button', { includeHiddenElements: false })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "button" + + + + Hidden from accessibility + + " + `); }); describe('matches only accessible elements', () => { @@ -762,3 +784,41 @@ describe('matches only accessible elements', () => { expect(queryByRole('menu', { name: 'Action' })).toBeFalsy(); }); }); + +test('error message renders the element tree, preserving only helpful props', async () => { + const view = render(); + + expect(() => view.getByRole('link')).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "link" + + " + `); + + expect(() => view.getAllByRole('link')).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "link" + + " + `); + + await expect(view.findByRole('link')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "link" + + " + `); + + await expect(view.findAllByRole('link')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with role: "link" + + " + `); +}); diff --git a/src/queries/__tests__/testId.test.tsx b/src/queries/__tests__/testId.test.tsx index ae615ea86..0a37f5391 100644 --- a/src/queries/__tests__/testId.test.tsx +++ b/src/queries/__tests__/testId.test.tsx @@ -144,9 +144,57 @@ test('byTestId queries support hidden option', () => { expect(queryByTestId('hidden')).toBeFalsy(); expect(queryByTestId('hidden', { includeHiddenElements: false })).toBeFalsy(); - expect(() => - getByTestId('hidden', { includeHiddenElements: false }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with testID: hidden"` - ); + expect(() => getByTestId('hidden', { includeHiddenElements: false })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with testID: hidden + + + Hidden from accessibility + " + `); +}); + +test('error message renders the element tree, preserving only helpful props', async () => { + const view = render(); + + expect(() => view.getByTestId('FOO')).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with testID: FOO + + " + `); + + expect(() => view.getAllByTestId('FOO')).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with testID: FOO + + " + `); + + await expect(view.findByTestId('FOO')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with testID: FOO + + " + `); + + await expect(view.findAllByTestId('FOO')).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with testID: FOO + + " + `); }); diff --git a/src/queries/__tests__/text.test.tsx b/src/queries/__tests__/text.test.tsx index 0f42daff7..f66d39310 100644 --- a/src/queries/__tests__/text.test.tsx +++ b/src/queries/__tests__/text.test.tsx @@ -1,13 +1,13 @@ import * as React from 'react'; import { - View, - Text, - TouchableOpacity, - Image, Button, + Image, + Text, TextInput, + TouchableOpacity, + View, } from 'react-native'; -import { render, getDefaultNormalizer, within } from '../..'; +import { getDefaultNormalizer, render, within } from '../..'; test('byText matches simple text', () => { const { getByText } = render(Hello World); @@ -496,11 +496,58 @@ test('byText support hidden option', () => { expect(queryByText(/hidden/i)).toBeFalsy(); expect(queryByText(/hidden/i, { includeHiddenElements: false })).toBeFalsy(); - expect(() => - getByText(/hidden/i, { includeHiddenElements: false }) - ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with text: /hidden/i"` - ); + expect(() => getByText(/hidden/i, { includeHiddenElements: false })) + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /hidden/i + + + Hidden from accessibility + " + `); +}); + +test('error message renders the element tree, preserving only helpful props', async () => { + const view = render(); + + expect(() => view.getByText(/foo/)).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + " + `); + + expect(() => view.getAllByText(/foo/)).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + " + `); + + await expect(view.findByText(/foo/)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + " + `); + + await expect(view.findAllByText(/foo/)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with text: /foo/ + + " + `); }); test('byText should return host component', () => { diff --git a/src/queries/makeQueries.ts b/src/queries/makeQueries.ts index a309d10fa..b96430dd5 100644 --- a/src/queries/makeQueries.ts +++ b/src/queries/makeQueries.ts @@ -2,6 +2,9 @@ import type { ReactTestInstance } from 'react-test-renderer'; import { ErrorWithStack } from '../helpers/errors'; import waitFor from '../waitFor'; import type { WaitForOptions } from '../waitFor'; +import format from '../helpers/format'; +import { screen } from '../screen'; +import { defaultMapProps } from '../helpers/format-default'; export type GetByQuery = ( predicate: Predicate, @@ -72,8 +75,8 @@ function extractDeprecatedWaitForOptions(options?: WaitForOptions) { if (option) { // eslint-disable-next-line no-console console.warn( - `Use of option "${key}" in a findBy* query options (2nd parameter) is deprecated. Please pass this option in the waitForOptions (3rd parameter). -Example: + `Use of option "${key}" in a findBy* query options (2nd parameter) is deprecated. Please pass this option in the waitForOptions (3rd parameter). +Example: findByText(text, {}, { ${key}: ${option.toString()} })` ); @@ -83,30 +86,68 @@ Example: return waitForOptions; } +function formatErrorMessage(message: string, printElementTree: boolean) { + if (!printElementTree) { + return message; + } + + const json = screen.toJSON(); + if (!json) { + return message; + } + + return `${message}\n\n${format(json, { + mapProps: defaultMapProps, + })}`; +} + +function appendElementTreeToError(error: Error) { + const oldMessage = error.message; + error.message = formatErrorMessage(oldMessage, true); + + // Required to make Jest print the element tree on error + error.stack = error.stack?.replace(oldMessage, error.message); + + return error; +} + export function makeQueries( queryAllByQuery: UnboundQuery>, getMissingError: (predicate: Predicate, options?: Options) => string, getMultipleError: (predicate: Predicate, options?: Options) => string ): UnboundQueries { - function getAllByQuery(instance: ReactTestInstance) { + function getAllByQuery( + instance: ReactTestInstance, + { printElementTree = true } = {} + ) { return function getAllFn(predicate: Predicate, options?: Options) { const results = queryAllByQuery(instance)(predicate, options); if (results.length === 0) { - throw new ErrorWithStack(getMissingError(predicate, options), getAllFn); + const errorMessage = formatErrorMessage( + getMissingError(predicate, options), + printElementTree + ); + throw new ErrorWithStack(errorMessage, getAllFn); } return results; }; } - function queryByQuery(instance: ReactTestInstance) { + function queryByQuery( + instance: ReactTestInstance, + { printElementTree = true } = {} + ) { return function singleQueryFn(predicate: Predicate, options?: Options) { const results = queryAllByQuery(instance)(predicate, options); if (results.length > 1) { throw new ErrorWithStack( - getMultipleError(predicate, options), + formatErrorMessage( + getMultipleError(predicate, options), + printElementTree + ), singleQueryFn ); } @@ -119,7 +160,10 @@ export function makeQueries( }; } - function getByQuery(instance: ReactTestInstance) { + function getByQuery( + instance: ReactTestInstance, + { printElementTree = true } = {} + ) { return function getFn(predicate: Predicate, options?: Options) { const results = queryAllByQuery(instance)(predicate, options); @@ -128,7 +172,11 @@ export function makeQueries( } if (results.length === 0) { - throw new ErrorWithStack(getMissingError(predicate, options), getFn); + const errorMessage = formatErrorMessage( + getMissingError(predicate, options), + printElementTree + ); + throw new ErrorWithStack(errorMessage, getFn); } return results[0]; @@ -139,14 +187,26 @@ export function makeQueries( return function findAllFn( predicate: Predicate, queryOptions?: Options & WaitForOptions, - waitForOptions: WaitForOptions = {} + { + onTimeout = (error) => appendElementTreeToError(error), + ...waitForOptions + }: WaitForOptions = {} ) { const deprecatedWaitForOptions = extractDeprecatedWaitForOptions(queryOptions); - return waitFor(() => getAllByQuery(instance)(predicate, queryOptions), { - ...deprecatedWaitForOptions, - ...waitForOptions, - }); + + return waitFor( + () => + getAllByQuery(instance, { printElementTree: false })( + predicate, + queryOptions + ), + { + ...deprecatedWaitForOptions, + ...waitForOptions, + onTimeout, + } + ); }; } @@ -154,14 +214,26 @@ export function makeQueries( return function findFn( predicate: Predicate, queryOptions?: Options & WaitForOptions, - waitForOptions: WaitForOptions = {} + { + onTimeout = (error) => appendElementTreeToError(error), + ...waitForOptions + }: WaitForOptions = {} ) { const deprecatedWaitForOptions = extractDeprecatedWaitForOptions(queryOptions); - return waitFor(() => getByQuery(instance)(predicate, queryOptions), { - ...deprecatedWaitForOptions, - ...waitForOptions, - }); + + return waitFor( + () => + getByQuery(instance, { printElementTree: false })( + predicate, + queryOptions + ), + { + ...deprecatedWaitForOptions, + ...waitForOptions, + onTimeout, + } + ); }; } diff --git a/src/waitFor.ts b/src/waitFor.ts index 58feb224a..ac515fe86 100644 --- a/src/waitFor.ts +++ b/src/waitFor.ts @@ -17,7 +17,7 @@ export type WaitForOptions = { timeout?: number; interval?: number; stackTraceError?: ErrorWithStack; - onTimeout?: (error: unknown) => Error; + onTimeout?: (error: Error) => Error; }; function waitForInternal( @@ -164,9 +164,14 @@ function waitForInternal( } function handleTimeout() { - let error; + let error: Error; if (lastError) { - error = lastError; + if (lastError instanceof Error) { + error = lastError; + } else { + error = new Error(String(lastError)); + } + if (stackTraceError) { copyStackTrace(error, stackTraceError); } @@ -177,7 +182,10 @@ function waitForInternal( } } if (typeof onTimeout === 'function') { - onTimeout(error); + const result = onTimeout(error); + if (result) { + error = result; + } } onDone({ type: 'error', error }); } diff --git a/typings/index.flow.js b/typings/index.flow.js index 4ac2124d2..006dbc183 100644 --- a/typings/index.flow.js +++ b/typings/index.flow.js @@ -71,7 +71,7 @@ declare type A11yValue = { type WaitForOptions = { timeout?: number, interval?: number, - onTimeout?: (error: mixed) => Error, + onTimeout?: (error: Error) => Error, }; type WaitForFunction = (