Skip to content

Commit f7c8400

Browse files
Stephen Hansonmdjastrzebski
Stephen Hanson
andauthored
feat: Render element tree in query error messages (#1378)
Co-authored-by: Maciej Jastrzebski <mdjastrzebski@gmail.com>
1 parent 5f770cb commit f7c8400

22 files changed

+1321
-196
lines changed

jest-setup.ts

+3
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
55
beforeEach(() => {
66
resetToDefaults();
77
});
8+
9+
// Disable colors in our local tests in order to generate clear snapshots
10+
process.env.COLORS = 'false';

src/__tests__/__snapshots__/render-debug.test.tsx.snap

+9-9
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ exports[`debug 1`] = `
3030
/>
3131
<View
3232
accessibilityState={
33-
Object {
33+
{
3434
"busy": undefined,
3535
"checked": undefined,
3636
"disabled": undefined,
@@ -39,7 +39,7 @@ exports[`debug 1`] = `
3939
}
4040
}
4141
accessibilityValue={
42-
Object {
42+
{
4343
"max": undefined,
4444
"min": undefined,
4545
"now": undefined,
@@ -109,7 +109,7 @@ exports[`debug changing component: bananaFresh button message should now be "fre
109109
/>
110110
<View
111111
accessibilityState={
112-
Object {
112+
{
113113
"busy": undefined,
114114
"checked": undefined,
115115
"disabled": undefined,
@@ -118,7 +118,7 @@ exports[`debug changing component: bananaFresh button message should now be "fre
118118
}
119119
}
120120
accessibilityValue={
121-
Object {
121+
{
122122
"max": undefined,
123123
"min": undefined,
124124
"now": undefined,
@@ -169,7 +169,7 @@ exports[`debug should use debugOptions from config when no option is specified 1
169169
exports[`debug should use given options over config debugOptions 1`] = `
170170
"<View
171171
style={
172-
Object {
172+
{
173173
"backgroundColor": "red",
174174
}
175175
}
@@ -315,7 +315,7 @@ exports[`debug: another custom message 1`] = `
315315
/>
316316
<View
317317
accessibilityState={
318-
Object {
318+
{
319319
"busy": undefined,
320320
"checked": undefined,
321321
"disabled": undefined,
@@ -324,7 +324,7 @@ exports[`debug: another custom message 1`] = `
324324
}
325325
}
326326
accessibilityValue={
327-
Object {
327+
{
328328
"max": undefined,
329329
"min": undefined,
330330
"now": undefined,
@@ -498,7 +498,7 @@ exports[`debug: with message 1`] = `
498498
/>
499499
<View
500500
accessibilityState={
501-
Object {
501+
{
502502
"busy": undefined,
503503
"checked": undefined,
504504
"disabled": undefined,
@@ -507,7 +507,7 @@ exports[`debug: with message 1`] = `
507507
}
508508
}
509509
accessibilityValue={
510-
Object {
510+
{
511511
"max": undefined,
512512
"min": undefined,
513513
"now": undefined,

src/__tests__/host-component-names.test.tsx

+7-24
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@ import {
66
getHostComponentNames,
77
configureHostComponentNamesIfNeeded,
88
} from '../helpers/host-component-names';
9-
import * as within from '../within';
109
import { act, render } from '..';
1110

1211
const mockCreate = jest.spyOn(TestRenderer, 'create') as jest.Mock;
13-
const mockGetQueriesForElements = jest.spyOn(
14-
within,
15-
'getQueriesForElement'
16-
) as jest.Mock;
12+
13+
beforeEach(() => {
14+
mockCreate.mockReset();
15+
});
1716

1817
describe('getHostComponentNames', () => {
1918
test('returns host component names from internal config', () => {
@@ -79,8 +78,10 @@ describe('configureHostComponentNamesIfNeeded', () => {
7978
});
8079

8180
test('throw an error when autodetection fails', () => {
81+
const renderer = TestRenderer.create(<View />);
82+
8283
mockCreate.mockReturnValue({
83-
root: { type: View, children: [], props: {} },
84+
root: renderer.root,
8485
});
8586

8687
expect(() => configureHostComponentNamesIfNeeded())
@@ -93,22 +94,4 @@ describe('configureHostComponentNamesIfNeeded', () => {
9394
Please check if you are using compatible versions of React Native and React Native Testing Library."
9495
`);
9596
});
96-
97-
test('throw an error when autodetection fails due to getByTestId returning non-host component', () => {
98-
mockGetQueriesForElements.mockReturnValue({
99-
getByTestId: () => {
100-
return { type: View };
101-
},
102-
});
103-
104-
expect(() => configureHostComponentNamesIfNeeded())
105-
.toThrowErrorMatchingInlineSnapshot(`
106-
"Trying to detect host component names triggered the following error:
107-
108-
getByTestId returned non-host component
109-
110-
There seems to be an issue with your configuration that prevents React Native Testing Library from working correctly.
111-
Please check if you are using compatible versions of React Native and React Native Testing Library."
112-
`);
113-
});
11497
});

src/__tests__/waitFor.test.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,12 @@ test.each([
316316
expect(onPress).toHaveBeenCalledWith('red');
317317
}
318318
);
319+
320+
test('waitFor throws if expectation is not a function', async () => {
321+
await expect(
322+
// @ts-expect-error intentionally passing non-function
323+
waitFor('not a function')
324+
).rejects.toThrowErrorMatchingInlineSnapshot(
325+
`"Received \`expectation\` arg must be a function"`
326+
);
327+
});
+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { ReactTestRendererJSON } from 'react-test-renderer';
2+
import { defaultMapProps } from '../format-default';
3+
4+
const node: ReactTestRendererJSON = {
5+
type: 'View',
6+
props: {},
7+
children: null,
8+
};
9+
10+
describe('mapPropsForQueryError', () => {
11+
test('preserves props that are helpful for debugging', () => {
12+
const props = {
13+
accessibilityElementsHidden: true,
14+
accessibilityViewIsModal: true,
15+
importantForAccessibility: 'yes',
16+
testID: 'TEST_ID',
17+
nativeID: 'NATIVE_ID',
18+
accessibilityLabel: 'LABEL',
19+
accessibilityLabelledBy: 'LABELLED_BY',
20+
accessibilityRole: 'ROLE',
21+
accessibilityHint: 'HINT',
22+
placeholder: 'PLACEHOLDER',
23+
value: 'VALUE',
24+
defaultValue: 'DEFAULT_VALUE',
25+
};
26+
27+
const result = defaultMapProps(props, node);
28+
29+
expect(result).toStrictEqual(props);
30+
});
31+
32+
test('does not preserve less helpful props', () => {
33+
const result = defaultMapProps(
34+
{
35+
style: [{ flex: 1 }, { display: 'flex' }],
36+
onPress: () => null,
37+
key: 'foo',
38+
},
39+
node
40+
);
41+
42+
expect(result).toStrictEqual({});
43+
});
44+
45+
test('preserves "display: none" style but no other style', () => {
46+
const result = defaultMapProps(
47+
{ style: [{ flex: 1 }, { display: 'none', flex: 2 }] },
48+
node
49+
);
50+
51+
expect(result).toStrictEqual({
52+
style: { display: 'none' },
53+
});
54+
});
55+
56+
test('removes undefined keys from accessibilityState', () => {
57+
const result = defaultMapProps(
58+
{ accessibilityState: { checked: undefined, selected: false } },
59+
node
60+
);
61+
62+
expect(result).toStrictEqual({
63+
accessibilityState: { selected: false },
64+
});
65+
});
66+
67+
test('removes accessibilityState if all keys are undefined', () => {
68+
const result = defaultMapProps(
69+
{ accessibilityState: { checked: undefined, selected: undefined } },
70+
node
71+
);
72+
73+
expect(result).toStrictEqual({});
74+
});
75+
76+
test('does not fail if accessibilityState is a string, passes through', () => {
77+
const result = defaultMapProps({ accessibilityState: 'foo' }, node);
78+
expect(result).toStrictEqual({ accessibilityState: 'foo' });
79+
});
80+
81+
test('does not fail if accessibilityState is an array, passes through', () => {
82+
const result = defaultMapProps({ accessibilityState: [1] }, node);
83+
expect(result).toStrictEqual({ accessibilityState: [1] });
84+
});
85+
86+
test('does not fail if accessibilityState is null, passes through', () => {
87+
const result = defaultMapProps({ accessibilityState: null }, node);
88+
expect(result).toStrictEqual({ accessibilityState: null });
89+
});
90+
91+
test('does not fail if accessibilityState is nested object, passes through', () => {
92+
const accessibilityState = { 1: { 2: 3 }, 2: undefined };
93+
const result = defaultMapProps({ accessibilityState }, node);
94+
expect(result).toStrictEqual({ accessibilityState: { 1: { 2: 3 } } });
95+
});
96+
97+
test('removes undefined keys from accessibilityValue', () => {
98+
const result = defaultMapProps(
99+
{ accessibilityValue: { min: 1, max: undefined } },
100+
node
101+
);
102+
103+
expect(result).toStrictEqual({ accessibilityValue: { min: 1 } });
104+
});
105+
106+
test('removes accessibilityValue if all keys are undefined', () => {
107+
const result = defaultMapProps(
108+
{ accessibilityValue: { min: undefined } },
109+
node
110+
);
111+
112+
expect(result).toStrictEqual({});
113+
});
114+
});

src/helpers/format-default.ts

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { StyleSheet, ViewStyle } from 'react-native';
2+
import { MapPropsFunction } from './format';
3+
4+
const propsToDisplay = [
5+
'testID',
6+
'nativeID',
7+
'accessibilityElementsHidden',
8+
'accessibilityViewIsModal',
9+
'importantForAccessibility',
10+
'accessibilityRole',
11+
'accessibilityLabel',
12+
'accessibilityLabelledBy',
13+
'accessibilityHint',
14+
'placeholder',
15+
'value',
16+
'defaultValue',
17+
'title',
18+
];
19+
20+
/**
21+
* Preserve props that are helpful in diagnosing test failures, while stripping rest
22+
*/
23+
export const defaultMapProps: MapPropsFunction = (props) => {
24+
const result: Record<string, unknown> = {};
25+
26+
const styles = StyleSheet.flatten(props.style as ViewStyle);
27+
if (styles?.display === 'none') {
28+
result.style = { display: 'none' };
29+
}
30+
31+
const accessibilityState = removeUndefinedKeys(props.accessibilityState);
32+
if (accessibilityState !== undefined) {
33+
result.accessibilityState = accessibilityState;
34+
}
35+
36+
const accessibilityValue = removeUndefinedKeys(props.accessibilityValue);
37+
if (accessibilityValue !== undefined) {
38+
result.accessibilityValue = accessibilityValue;
39+
}
40+
41+
propsToDisplay.forEach((propName) => {
42+
if (propName in props) {
43+
result[propName] = props[propName];
44+
}
45+
});
46+
47+
return result;
48+
};
49+
50+
function isObject(value: unknown): value is Record<string, unknown> {
51+
return typeof value === 'object' && value !== null && !Array.isArray(value);
52+
}
53+
54+
function removeUndefinedKeys(prop: unknown) {
55+
if (!isObject(prop)) {
56+
return prop;
57+
}
58+
59+
const result: Record<string, unknown> = {};
60+
Object.entries(prop).forEach(([key, value]) => {
61+
if (value !== undefined) {
62+
result[key] = value;
63+
}
64+
});
65+
66+
// If object does not have any props we will ignore it.
67+
if (Object.keys(result).length === 0) {
68+
return undefined;
69+
}
70+
71+
return result;
72+
}

src/helpers/format.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ReactTestRendererJSON } from 'react-test-renderer';
22
import prettyFormat, { NewPlugin, plugins } from 'pretty-format';
33

4-
type MapPropsFunction = (
4+
export type MapPropsFunction = (
55
props: Record<string, unknown>,
66
node: ReactTestRendererJSON
77
) => Record<string, unknown>;
@@ -16,7 +16,8 @@ const format = (
1616
) =>
1717
prettyFormat(input, {
1818
plugins: [getCustomPlugin(options.mapProps), plugins.ReactElement],
19-
highlight: true,
19+
highlight: shouldHighlight(),
20+
printBasicPrototype: false,
2021
});
2122

2223
const getCustomPlugin = (mapProps?: MapPropsFunction): NewPlugin => {
@@ -39,4 +40,8 @@ const getCustomPlugin = (mapProps?: MapPropsFunction): NewPlugin => {
3940
};
4041
};
4142

43+
function shouldHighlight() {
44+
return process?.env?.COLORS !== 'false';
45+
}
46+
4247
export default format;

0 commit comments

Comments
 (0)