Skip to content

Commit

Permalink
feat: Render element tree in query error messages (#1378)
Browse files Browse the repository at this point in the history
Co-authored-by: Maciej Jastrzebski <mdjastrzebski@gmail.com>
  • Loading branch information
stevehanson and mdjastrzebski committed Apr 27, 2023
1 parent 5f770cb commit f7c8400
Show file tree
Hide file tree
Showing 22 changed files with 1,321 additions and 196 deletions.
3 changes: 3 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
18 changes: 9 additions & 9 deletions src/__tests__/__snapshots__/render-debug.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ exports[`debug 1`] = `
/>
<View
accessibilityState={
Object {
{
"busy": undefined,
"checked": undefined,
"disabled": undefined,
Expand All @@ -39,7 +39,7 @@ exports[`debug 1`] = `
}
}
accessibilityValue={
Object {
{
"max": undefined,
"min": undefined,
"now": undefined,
Expand Down Expand Up @@ -109,7 +109,7 @@ exports[`debug changing component: bananaFresh button message should now be "fre
/>
<View
accessibilityState={
Object {
{
"busy": undefined,
"checked": undefined,
"disabled": undefined,
Expand All @@ -118,7 +118,7 @@ exports[`debug changing component: bananaFresh button message should now be "fre
}
}
accessibilityValue={
Object {
{
"max": undefined,
"min": undefined,
"now": undefined,
Expand Down Expand Up @@ -169,7 +169,7 @@ exports[`debug should use debugOptions from config when no option is specified 1
exports[`debug should use given options over config debugOptions 1`] = `
"<View
style={
Object {
{
"backgroundColor": "red",
}
}
Expand Down Expand Up @@ -315,7 +315,7 @@ exports[`debug: another custom message 1`] = `
/>
<View
accessibilityState={
Object {
{
"busy": undefined,
"checked": undefined,
"disabled": undefined,
Expand All @@ -324,7 +324,7 @@ exports[`debug: another custom message 1`] = `
}
}
accessibilityValue={
Object {
{
"max": undefined,
"min": undefined,
"now": undefined,
Expand Down Expand Up @@ -498,7 +498,7 @@ exports[`debug: with message 1`] = `
/>
<View
accessibilityState={
Object {
{
"busy": undefined,
"checked": undefined,
"disabled": undefined,
Expand All @@ -507,7 +507,7 @@ exports[`debug: with message 1`] = `
}
}
accessibilityValue={
Object {
{
"max": undefined,
"min": undefined,
"now": undefined,
Expand Down
31 changes: 7 additions & 24 deletions src/__tests__/host-component-names.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@ import {
getHostComponentNames,
configureHostComponentNamesIfNeeded,
} from '../helpers/host-component-names';
import * as within from '../within';
import { act, render } from '..';

const mockCreate = jest.spyOn(TestRenderer, 'create') as jest.Mock;
const mockGetQueriesForElements = jest.spyOn(
within,
'getQueriesForElement'
) as jest.Mock;

beforeEach(() => {
mockCreate.mockReset();
});

describe('getHostComponentNames', () => {
test('returns host component names from internal config', () => {
Expand Down Expand Up @@ -79,8 +78,10 @@ describe('configureHostComponentNamesIfNeeded', () => {
});

test('throw an error when autodetection fails', () => {
const renderer = TestRenderer.create(<View />);

mockCreate.mockReturnValue({
root: { type: View, children: [], props: {} },
root: renderer.root,
});

expect(() => configureHostComponentNamesIfNeeded())
Expand All @@ -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."
`);
});
});
9 changes: 9 additions & 0 deletions src/__tests__/waitFor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
);
});
114 changes: 114 additions & 0 deletions src/helpers/__tests__/format-default.tsx
Original file line number Diff line number Diff line change
@@ -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({});
});
});
72 changes: 72 additions & 0 deletions src/helpers/format-default.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function removeUndefinedKeys(prop: unknown) {
if (!isObject(prop)) {
return prop;
}

const result: Record<string, unknown> = {};
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;
}
9 changes: 7 additions & 2 deletions src/helpers/format.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
node: ReactTestRendererJSON
) => Record<string, unknown>;
Expand All @@ -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 => {
Expand All @@ -39,4 +40,8 @@ const getCustomPlugin = (mapProps?: MapPropsFunction): NewPlugin => {
};
};

function shouldHighlight() {
return process?.env?.COLORS !== 'false';
}

export default format;
Loading

0 comments on commit f7c8400

Please sign in to comment.