Skip to content

Commit d261f61

Browse files
pierrezimmermannbampierrezimmermannmdjastrzebski
authored
feat(breaking): Return host component for all queries (#1234)
* BREAKING CHANGE : make placeholdertext queries return host elements * BREAKING CHANGE : make displayValue queries return host elements * BREAKING CHANGE: make text queries return host elements * refactor: reset config to default before each test * feat: use component names from config and add api to configure them * feat: remove legacy option from queries * refactor: reorganize legacy and breaking tests for queries * feat: change error messages for mismatching host component names * fix: do not update screen when running auto detection * fix: typo in comment * refactor: use an object for matchTextContent params * tests: rewrite some tests to match repo patterns * fix: use legacy queries by default to prevent breaking changes * fix: remove wrong uppercase in flow typings * docs: remove section on useLegacy queries as it wont be a public api * docs: small improvements based on review * fix: remove typo in error message * refactor: rename getReactNativeHostComponentNames to detectHostComponentNames * refactor: use useBreakingChange from internal config instead of useLegacyQueries * fix: use fake timers in test so it doesnt take too much time * refactor: replace type assertions by real type checking * fix: revert change due to rebase * fix: improvements after review * refactor: define type to match inside matchTextContent function * refactor: rename function to get error message * feat: display error when auto detect of host component names throw * fix: remove unused ts-expect-error after ts version bump * refactor: run auto detection on render if it hasnt been run yet * refactor: reuse type imports that had been converted to regular ones * refactor: add comments to distinguish breaking and legacy * refactor: move config logic into detectComponentHostNames function * refactor: rename function to detectComponentHostNamesIfNeeded * tests: add test on detectHostComponentNamesIfNeeded * fix: use host component name from config for textinput in fireEvent * refactor: run autodetection in text, displayvalue and placeholder queries rather than in render * refactor: remove useless async declaration * refactor: improvements for readability from review * Update src/__tests__/host-component-names.test.tsx * refactor: declare variable for host component names in detection function * refactor: rename detectHostComponentNamesIfNeeded to getHostComponentNames * feat: use getHostComponentNames in fireEvent for textinput host name * refactor: revert refactor on matchTextContent * refactor: add comment to indicate impossible path * chore: simplify jest-setup files * refactor: code review changes Co-authored-by: pierrezimmermann <pierrez@nam.tech> Co-authored-by: Maciej Jastrzebski <mdjastrzebski@gmail.com>
1 parent e38c627 commit d261f61

24 files changed

+946
-50
lines changed

jest-setup.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { resetToDefaults } from './src/pure';
2+
3+
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
4+
5+
beforeEach(() => {
6+
resetToDefaults();
7+
});

jestSetup.js

-1
This file was deleted.

package.json

+1-3
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,7 @@
8787
},
8888
"jest": {
8989
"preset": "./jest-preset",
90-
"setupFiles": [
91-
"./jestSetup.js"
92-
],
90+
"setupFilesAfterEnv": ["./jest-setup.ts"],
9391
"testPathIgnorePatterns": [
9492
"timerUtils",
9593
"examples/"

src/__tests__/config.test.ts

-4
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@ import {
55
configureInternal,
66
} from '../config';
77

8-
beforeEach(() => {
9-
resetToDefaults();
10-
});
11-
128
test('getConfig() returns existing configuration', () => {
139
expect(getConfig().useBreakingChanges).toEqual(false);
1410
expect(getConfig().asyncUtilTimeout).toEqual(1000);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { View } from 'react-native';
2+
import TestRenderer from 'react-test-renderer';
3+
import { configureInternal, getConfig } from '../config';
4+
import { getHostComponentNames } from '../helpers/host-component-names';
5+
6+
const mockCreate = jest.spyOn(TestRenderer, 'create') as jest.Mock;
7+
8+
describe('getHostComponentNames', () => {
9+
test('updates internal config with host component names when they are not defined', () => {
10+
expect(getConfig().hostComponentNames).toBeUndefined();
11+
12+
getHostComponentNames();
13+
14+
expect(getConfig().hostComponentNames).toEqual({
15+
text: 'Text',
16+
textInput: 'TextInput',
17+
});
18+
});
19+
20+
test('does not update internal config when host component names are already configured', () => {
21+
configureInternal({
22+
hostComponentNames: { text: 'banana', textInput: 'banana' },
23+
});
24+
25+
getHostComponentNames();
26+
27+
expect(getConfig().hostComponentNames).toEqual({
28+
text: 'banana',
29+
textInput: 'banana',
30+
});
31+
});
32+
33+
test('throw an error when autodetection fails', () => {
34+
mockCreate.mockReturnValue({
35+
root: { type: View, children: [], props: {} },
36+
});
37+
38+
expect(() => getHostComponentNames()).toThrowErrorMatchingInlineSnapshot(`
39+
"Trying to detect host component names triggered the following error:
40+
41+
Unable to find an element with testID: text
42+
43+
There seems to be an issue with your configuration that prevents React Native Testing Library from working correctly.
44+
Please check if you are using compatible versions of React Native and React Native Testing Library.
45+
"
46+
`);
47+
});
48+
});

src/__tests__/render-debug.test.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import * as React from 'react';
33
import { View, Text, TextInput, Pressable } from 'react-native';
44
import stripAnsi from 'strip-ansi';
5-
import { render, fireEvent, resetToDefaults, configure } from '..';
5+
import { render, fireEvent, configure } from '..';
66

77
type ConsoleLogMock = jest.Mock<Array<string>>;
88

@@ -18,7 +18,6 @@ const ignoreWarnings = ['Using debug("message") is deprecated'];
1818
const realConsoleWarn = console.warn;
1919

2020
beforeEach(() => {
21-
resetToDefaults();
2221
jest.spyOn(console, 'log').mockImplementation(() => {});
2322
jest.spyOn(console, 'warn').mockImplementation((message) => {
2423
if (!ignoreWarnings.some((warning) => message.includes(warning))) {

src/__tests__/render.test.tsx

+1-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable no-console */
22
import * as React from 'react';
33
import { View, Text, TextInput, Pressable, SafeAreaView } from 'react-native';
4-
import { render, fireEvent, RenderAPI, resetToDefaults } from '..';
4+
import { render, fireEvent, RenderAPI } from '..';
55

66
const PLACEHOLDER_FRESHNESS = 'Add custom freshness';
77
const PLACEHOLDER_CHEF = 'Who inspected freshness?';
@@ -10,10 +10,6 @@ const INPUT_CHEF = 'I inspected freshie';
1010
const DEFAULT_INPUT_CHEF = 'What did you inspect?';
1111
const DEFAULT_INPUT_CUSTOMER = 'What banana?';
1212

13-
beforeEach(() => {
14-
resetToDefaults();
15-
});
16-
1713
class MyButton extends React.Component<any> {
1814
render() {
1915
return (

src/__tests__/waitFor.test.tsx

+1-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22
import { Text, TouchableOpacity, View, Pressable } from 'react-native';
3-
import { fireEvent, render, waitFor, configure, resetToDefaults } from '..';
3+
import { fireEvent, render, waitFor, configure } from '..';
44

55
class Banana extends React.Component<any> {
66
changeFresh = () => {
@@ -19,10 +19,6 @@ class Banana extends React.Component<any> {
1919
}
2020
}
2121

22-
beforeEach(() => {
23-
resetToDefaults();
24-
});
25-
2622
class BananaContainer extends React.Component<{}, any> {
2723
state = { fresh: false };
2824

src/config.ts

+9
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { DebugOptions } from './helpers/debugDeep';
33
/**
44
* Global configuration options for React Native Testing Library.
55
*/
6+
67
export type Config = {
78
/** Default timeout, in ms, for `waitFor` and `findBy*` queries. */
89
asyncUtilTimeout: number;
@@ -19,9 +20,17 @@ export type ConfigAliasOptions = {
1920
defaultHidden: boolean;
2021
};
2122

23+
export type HostComponentNames = {
24+
text: string;
25+
textInput: string;
26+
};
27+
2228
export type InternalConfig = Config & {
2329
/** Whether to use breaking changes intended for next major version release. */
2430
useBreakingChanges: boolean;
31+
32+
/** Names for key React Native host components. */
33+
hostComponentNames?: HostComponentNames;
2534
};
2635

2736
const defaultConfig: InternalConfig = {

src/fireEvent.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { TextInput } from 'react-native';
33
import act from './act';
44
import { isHostElement } from './helpers/component-tree';
55
import { filterNodeByType } from './helpers/filterNodeByType';
6+
import { getHostComponentNames } from './helpers/host-component-names';
67

78
type EventHandler = (...args: any) => unknown;
89

@@ -14,12 +15,11 @@ const isTextInput = (element?: ReactTestInstance) => {
1415
// We have to test if the element type is either the TextInput component
1516
// (which would if it is a composite component) or the string
1617
// TextInput (which would be true if it is a host component)
17-
// All queries but the one by testID return composite component and event
18-
// if all queries returned host components, since fireEvent bubbles up
18+
// All queries return host components but since fireEvent bubbles up
1919
// it would trigger the parent prop without the composite component check
2020
return (
2121
filterNodeByType(element, TextInput) ||
22-
filterNodeByType(element, 'TextInput')
22+
filterNodeByType(element, getHostComponentNames().textInput)
2323
);
2424
};
2525

src/helpers/host-component-names.tsx

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import React from 'react';
2+
import { Text, TextInput, View } from 'react-native';
3+
import TestRenderer from 'react-test-renderer';
4+
import { configureInternal, getConfig, HostComponentNames } from '../config';
5+
import { getQueriesForElement } from '../within';
6+
7+
const defaultErrorMessage = `There seems to be an issue with your configuration that prevents React Native Testing Library from working correctly.
8+
Please check if you are using compatible versions of React Native and React Native Testing Library.`;
9+
10+
export function getHostComponentNames(): HostComponentNames {
11+
const configHostComponentNames = getConfig().hostComponentNames;
12+
if (configHostComponentNames) {
13+
return configHostComponentNames;
14+
}
15+
16+
try {
17+
const renderer = TestRenderer.create(
18+
<View>
19+
<Text testID="text">Hello</Text>
20+
<TextInput testID="textInput" />
21+
</View>
22+
);
23+
const { getByTestId } = getQueriesForElement(renderer.root);
24+
const textHostName = getByTestId('text').type;
25+
const textInputHostName = getByTestId('textInput').type;
26+
27+
// This code path should not happen as getByTestId always returns host elements.
28+
if (
29+
typeof textHostName !== 'string' ||
30+
typeof textInputHostName !== 'string'
31+
) {
32+
throw new Error(defaultErrorMessage);
33+
}
34+
35+
const hostComponentNames = {
36+
text: textHostName,
37+
textInput: textInputHostName,
38+
};
39+
configureInternal({ hostComponentNames });
40+
return hostComponentNames;
41+
} catch (error) {
42+
const errorMessage =
43+
error && typeof error === 'object' && 'message' in error
44+
? error.message
45+
: null;
46+
47+
throw new Error(`Trying to detect host component names triggered the following error:\n\n${errorMessage}\n\n${defaultErrorMessage}
48+
`);
49+
}
50+
}

src/helpers/matchers/matchTextContent.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import { Text } from 'react-native';
22
import type { ReactTestInstance } from 'react-test-renderer';
3+
import { getConfig } from '../../config';
34
import { matches, TextMatch, TextMatchOptions } from '../../matches';
45
import { filterNodeByType } from '../filterNodeByType';
56
import { getTextContent } from '../getTextContent';
7+
import { getHostComponentNames } from '../host-component-names';
68

79
export function matchTextContent(
810
node: ReactTestInstance,
911
text: TextMatch,
1012
options: TextMatchOptions = {}
1113
) {
12-
if (!filterNodeByType(node, Text)) {
14+
const textType = getConfig().useBreakingChanges
15+
? getHostComponentNames().text
16+
: Text;
17+
if (!filterNodeByType(node, textType)) {
1318
return false;
1419
}
1520

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import * as React from 'react';
2+
import { View, TextInput } from 'react-native';
3+
4+
import { render } from '../..';
5+
import { configureInternal } from '../../config';
6+
7+
const PLACEHOLDER_FRESHNESS = 'Add custom freshness';
8+
const PLACEHOLDER_CHEF = 'Who inspected freshness?';
9+
const INPUT_FRESHNESS = 'Custom Freshie';
10+
const INPUT_CHEF = 'I inspected freshie';
11+
const DEFAULT_INPUT_CHEF = 'What did you inspect?';
12+
const DEFAULT_INPUT_CUSTOMER = 'What banana?';
13+
14+
beforeEach(() => configureInternal({ useBreakingChanges: true }));
15+
16+
const Banana = () => (
17+
<View>
18+
<TextInput
19+
testID="bananaCustomFreshness"
20+
placeholder={PLACEHOLDER_FRESHNESS}
21+
value={INPUT_FRESHNESS}
22+
/>
23+
<TextInput
24+
testID="bananaChef"
25+
placeholder={PLACEHOLDER_CHEF}
26+
value={INPUT_CHEF}
27+
defaultValue={DEFAULT_INPUT_CHEF}
28+
/>
29+
<TextInput defaultValue={DEFAULT_INPUT_CUSTOMER} />
30+
<TextInput defaultValue={'hello'} value="" />
31+
</View>
32+
);
33+
34+
test('getByDisplayValue, queryByDisplayValue', () => {
35+
const { getByDisplayValue, queryByDisplayValue } = render(<Banana />);
36+
const input = getByDisplayValue(/custom/i);
37+
38+
expect(input.props.value).toBe(INPUT_FRESHNESS);
39+
40+
const sameInput = getByDisplayValue(INPUT_FRESHNESS);
41+
42+
expect(sameInput.props.value).toBe(INPUT_FRESHNESS);
43+
expect(() => getByDisplayValue('no value')).toThrow(
44+
'Unable to find an element with displayValue: no value'
45+
);
46+
47+
expect(queryByDisplayValue(/custom/i)).toBe(input);
48+
expect(queryByDisplayValue('no value')).toBeNull();
49+
expect(() => queryByDisplayValue(/fresh/i)).toThrow(
50+
'Found multiple elements with display value: /fresh/i'
51+
);
52+
});
53+
54+
test('getByDisplayValue, queryByDisplayValue get element by default value only when value is undefined', () => {
55+
const { getByDisplayValue, queryByDisplayValue } = render(<Banana />);
56+
expect(() =>
57+
getByDisplayValue(DEFAULT_INPUT_CHEF)
58+
).toThrowErrorMatchingInlineSnapshot(
59+
`"Unable to find an element with displayValue: What did you inspect?"`
60+
);
61+
expect(queryByDisplayValue(DEFAULT_INPUT_CHEF)).toBeNull();
62+
63+
expect(() => getByDisplayValue('hello')).toThrowErrorMatchingInlineSnapshot(
64+
`"Unable to find an element with displayValue: hello"`
65+
);
66+
expect(queryByDisplayValue('hello')).toBeNull();
67+
68+
expect(getByDisplayValue(DEFAULT_INPUT_CUSTOMER)).toBeTruthy();
69+
expect(queryByDisplayValue(DEFAULT_INPUT_CUSTOMER)).toBeTruthy();
70+
});
71+
72+
test('getAllByDisplayValue, queryAllByDisplayValue', () => {
73+
const { getAllByDisplayValue, queryAllByDisplayValue } = render(<Banana />);
74+
const inputs = getAllByDisplayValue(/fresh/i);
75+
76+
expect(inputs).toHaveLength(2);
77+
expect(() => getAllByDisplayValue('no value')).toThrow(
78+
'Unable to find an element with displayValue: no value'
79+
);
80+
81+
expect(queryAllByDisplayValue(/fresh/i)).toEqual(inputs);
82+
expect(queryAllByDisplayValue('no value')).toHaveLength(0);
83+
});
84+
85+
test('findBy queries work asynchronously', async () => {
86+
const options = { timeout: 10 }; // Short timeout so that this test runs quickly
87+
const { rerender, findByDisplayValue, findAllByDisplayValue } = render(
88+
<View />
89+
);
90+
91+
await expect(
92+
findByDisplayValue('Display Value', {}, options)
93+
).rejects.toBeTruthy();
94+
await expect(
95+
findAllByDisplayValue('Display Value', {}, options)
96+
).rejects.toBeTruthy();
97+
98+
setTimeout(
99+
() =>
100+
rerender(
101+
<View>
102+
<TextInput value="Display Value" />
103+
</View>
104+
),
105+
20
106+
);
107+
108+
await expect(findByDisplayValue('Display Value')).resolves.toBeTruthy();
109+
await expect(findAllByDisplayValue('Display Value')).resolves.toHaveLength(1);
110+
}, 20000);
111+
112+
test('byDisplayValue queries support hidden option', () => {
113+
const { getByDisplayValue, queryByDisplayValue } = render(
114+
<TextInput value="hidden" style={{ display: 'none' }} />
115+
);
116+
117+
expect(getByDisplayValue('hidden')).toBeTruthy();
118+
expect(
119+
getByDisplayValue('hidden', { includeHiddenElements: true })
120+
).toBeTruthy();
121+
122+
expect(
123+
queryByDisplayValue('hidden', { includeHiddenElements: false })
124+
).toBeFalsy();
125+
expect(() =>
126+
getByDisplayValue('hidden', { includeHiddenElements: false })
127+
).toThrowErrorMatchingInlineSnapshot(
128+
`"Unable to find an element with displayValue: hidden"`
129+
);
130+
});
131+
132+
test('byDisplayValue should return host component', () => {
133+
const { getByDisplayValue } = render(<TextInput value="value" />);
134+
135+
expect(getByDisplayValue('value').type).toBe('TextInput');
136+
});

0 commit comments

Comments
 (0)