Skip to content

Commit f9bc0b2

Browse files
Esemesekthymikee
authored andcommitted
feat: add a11y queries (#178)
* First implementation of matcher generator * A11y queries added * extract prepareErrorMessage * Add tests * Make aliases * Move create queryByError * Add missing aliases * simplify valid node check * add docs * fix types
1 parent efc4415 commit f9bc0b2

File tree

12 files changed

+576
-30
lines changed

12 files changed

+576
-30
lines changed

docs/Queries.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,63 @@ const { getByTestId } = render(<MyComponent />);
8383
const element = getByTestId('unique-id');
8484
```
8585

86+
### `ByA11yLabel`, `ByAccessibilityLabel`
87+
88+
> getByA11yLabel, getAllByA11yLabel, queryByA11yLabel, queryAllByA11yLabel
89+
> getByAccessibilityLabel, getAllByAccessibilityLabel, queryByAccessibilityLabel, queryAllByAccessibilityLabel
90+
91+
Returns a `ReactTestInstance` with matching `accessibilityLabel` prop.
92+
93+
```jsx
94+
import { render } from 'react-native-testing-library';
95+
96+
const { getByA11yLabel } = render(<MyComponent />);
97+
const element = getByA11yLabel('my-label');
98+
```
99+
100+
### `ByA11yHint`, `ByAccessibilityHint`
101+
102+
> getByA11yHint, getAllByA11yHint, queryByA11yHint, queryAllByA11yHint
103+
> getByAccessibilityHint, getAllByAccessibilityHint, queryByAccessibilityHint, queryAllByAccessibilityHint
104+
105+
Returns a `ReactTestInstance` with matching `accessibilityHint` prop.
106+
107+
```jsx
108+
import { render } from 'react-native-testing-library';
109+
110+
const { getByA11yHint } = render(<MyComponent />);
111+
const element = getByA11yHint('my-hint');
112+
```
113+
114+
### `ByA11yStates`, `ByAccessibilityStates`
115+
116+
> getByA11yStates, getAllByA11yStates, queryByA11yStates, queryAllByA11yStates
117+
> getByAccessibilityStates, getAllByAccessibilityStates, queryByAccessibilityStates, queryAllByAccessibilityStates
118+
119+
Returns a `ReactTestInstance` with matching `accessibilityStates` prop.
120+
121+
```jsx
122+
import { render } from 'react-native-testing-library';
123+
124+
const { getByA11yStates } = render(<MyComponent />);
125+
const element = getByA11yStates(['checked']);
126+
const element2 = getByA11yStates('checked');
127+
```
128+
129+
### `ByA11yRole`, `ByAccessibilityRole`
130+
131+
> getByA11yRole, getAllByA11yRole, queryByA11yRole, queryAllByA11yRole
132+
> getByAccessibilityRole, getAllByAccessibilityRole, queryByAccessibilityRole, queryAllByAccessibilityRole
133+
134+
Returns a `ReactTestInstance` with matching `accessibilityRole` prop.
135+
136+
```jsx
137+
import { render } from 'react-native-testing-library';
138+
139+
const { getByA11yRole } = render(<MyComponent />);
140+
const element = getByA11yRole('button');
141+
```
142+
86143
## Unit testing helpers
87144

88145
> Use sparingly and responsibly, escape hatches here

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@callstack/eslint-config": "^6.0.0",
2121
"@types/react": "^16.7.11",
2222
"@types/react-test-renderer": "^16.0.3",
23+
"@typescript-eslint/eslint-plugin": "^1.9.0",
2324
"babel-jest": "^24.7.1",
2425
"chalk": "^2.4.1",
2526
"conventional-changelog-cli": "^2.0.11",

src/__tests__/a11yAPI.test.js

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// @flow
2+
import React from 'react';
3+
import { TouchableOpacity, Text } from 'react-native';
4+
import { render } from '..';
5+
6+
const BUTTON_LABEL = 'cool button';
7+
const BUTTON_HINT = 'click this button';
8+
const BUTTON_ROLE = 'button';
9+
const TEXT_LABEL = 'cool text';
10+
const TEXT_HINT = 'static text';
11+
const TEXT_ROLE = 'link';
12+
const NO_MATCHES_TEXT = 'not-existent-element';
13+
14+
const NO_INSTANCES_FOUND = 'No instances found';
15+
const FOUND_TWO_INSTANCES = 'Expected 1 but found 2 instances';
16+
17+
const Typography = ({ children, ...rest }) => {
18+
return <Text {...rest}>{children}</Text>;
19+
};
20+
21+
class Button extends React.Component<*> {
22+
render() {
23+
return (
24+
<TouchableOpacity
25+
accessibilityHint={BUTTON_HINT}
26+
accessibilityLabel={BUTTON_LABEL}
27+
accessibilityRole={BUTTON_ROLE}
28+
accessibilityStates={['selected']}
29+
>
30+
<Typography
31+
accessibilityHint={TEXT_HINT}
32+
accessibilityLabel={TEXT_LABEL}
33+
accessibilityRole={TEXT_ROLE}
34+
accessibilityStates={['selected']}
35+
>
36+
{this.props.children}
37+
</Typography>
38+
</TouchableOpacity>
39+
);
40+
}
41+
}
42+
43+
function Section() {
44+
return (
45+
<>
46+
<Typography
47+
accessibilityHint={TEXT_HINT}
48+
accessibilityLabel={TEXT_LABEL}
49+
accessibilityRole={TEXT_ROLE}
50+
accessibilityStates={['selected', 'disabled']}
51+
>
52+
Title
53+
</Typography>
54+
<Button>{TEXT_LABEL}</Button>
55+
</>
56+
);
57+
}
58+
59+
test('getByA11yLabel, queryByA11yLabel', () => {
60+
const { getByA11yLabel, queryByA11yLabel } = render(<Section />);
61+
62+
expect(getByA11yLabel(BUTTON_LABEL).props.accessibilityLabel).toEqual(
63+
BUTTON_LABEL
64+
);
65+
const button = queryByA11yLabel(/button/g);
66+
expect(button && button.props.accessibilityLabel).toEqual(BUTTON_LABEL);
67+
expect(() => getByA11yLabel(NO_MATCHES_TEXT)).toThrow(NO_INSTANCES_FOUND);
68+
expect(queryByA11yLabel(NO_MATCHES_TEXT)).toBeNull();
69+
70+
expect(() => getByA11yLabel(TEXT_LABEL)).toThrow(FOUND_TWO_INSTANCES);
71+
expect(() => queryByA11yLabel(TEXT_LABEL)).toThrow(FOUND_TWO_INSTANCES);
72+
});
73+
74+
test('getAllByA11yLabel, queryAllByA11yLabel', () => {
75+
const { getAllByA11yLabel, queryAllByA11yLabel } = render(<Section />);
76+
77+
expect(getAllByA11yLabel(TEXT_LABEL)).toHaveLength(2);
78+
expect(queryAllByA11yLabel(/cool/g)).toHaveLength(3);
79+
expect(() => getAllByA11yLabel(NO_MATCHES_TEXT)).toThrow(NO_INSTANCES_FOUND);
80+
expect(queryAllByA11yLabel(NO_MATCHES_TEXT)).toEqual([]);
81+
});
82+
83+
test('getByA11yHint, queryByA11yHint', () => {
84+
const { getByA11yHint, queryByA11yHint } = render(<Section />);
85+
86+
expect(getByA11yHint(BUTTON_HINT).props.accessibilityHint).toEqual(
87+
BUTTON_HINT
88+
);
89+
const button = queryByA11yHint(/button/g);
90+
expect(button && button.props.accessibilityHint).toEqual(BUTTON_HINT);
91+
expect(() => getByA11yHint(NO_MATCHES_TEXT)).toThrow(NO_INSTANCES_FOUND);
92+
expect(queryByA11yHint(NO_MATCHES_TEXT)).toBeNull();
93+
94+
expect(() => getByA11yHint(TEXT_HINT)).toThrow(FOUND_TWO_INSTANCES);
95+
expect(() => queryByA11yHint(TEXT_HINT)).toThrow(FOUND_TWO_INSTANCES);
96+
});
97+
98+
test('getAllByA11yHint, queryAllByA11yHint', () => {
99+
const { getAllByA11yHint, queryAllByA11yHint } = render(<Section />);
100+
101+
expect(getAllByA11yHint(TEXT_HINT)).toHaveLength(2);
102+
expect(queryAllByA11yHint(/static/g)).toHaveLength(2);
103+
expect(() => getAllByA11yHint(NO_MATCHES_TEXT)).toThrow(NO_INSTANCES_FOUND);
104+
expect(queryAllByA11yHint(NO_MATCHES_TEXT)).toEqual([]);
105+
});
106+
107+
test('getByA11yRole, queryByA11yRole', () => {
108+
const { getByA11yRole, queryByA11yRole } = render(<Section />);
109+
110+
expect(getByA11yRole('button').props.accessibilityRole).toEqual('button');
111+
const button = queryByA11yRole(/button/g);
112+
expect(button && button.props.accessibilityRole).toEqual('button');
113+
expect(() => getByA11yRole(NO_MATCHES_TEXT)).toThrow(NO_INSTANCES_FOUND);
114+
expect(queryByA11yRole(NO_MATCHES_TEXT)).toBeNull();
115+
116+
expect(() => getByA11yRole('link')).toThrow(FOUND_TWO_INSTANCES);
117+
expect(() => queryByA11yRole('link')).toThrow(FOUND_TWO_INSTANCES);
118+
});
119+
120+
test('getAllByA11yRole, queryAllByA11yRole', () => {
121+
const { getAllByA11yRole, queryAllByA11yRole } = render(<Section />);
122+
123+
expect(getAllByA11yRole('link')).toHaveLength(2);
124+
expect(queryAllByA11yRole(/ink/g)).toHaveLength(2);
125+
expect(() => getAllByA11yRole(NO_MATCHES_TEXT)).toThrow(NO_INSTANCES_FOUND);
126+
expect(queryAllByA11yRole(NO_MATCHES_TEXT)).toEqual([]);
127+
});
128+
129+
test('getByA11yStates, queryByA11yStates', () => {
130+
const { getByA11yStates, queryByA11yStates } = render(<Section />);
131+
132+
expect(getByA11yStates('disabled').props.accessibilityStates).toEqual([
133+
'selected',
134+
'disabled',
135+
]);
136+
const disabled = queryByA11yStates(['disabled']);
137+
expect(disabled && disabled.props.accessibilityStates).toMatchObject([
138+
'selected',
139+
'disabled',
140+
]);
141+
const disabledSelected = queryByA11yStates(['selected', 'disabled']);
142+
expect(
143+
disabledSelected && disabledSelected.props.accessibilityStates
144+
).toEqual(['selected', 'disabled']);
145+
146+
expect(() => getByA11yStates(NO_MATCHES_TEXT)).toThrow(NO_INSTANCES_FOUND);
147+
expect(queryByA11yStates(NO_MATCHES_TEXT)).toBeNull();
148+
expect(queryByA11yStates([])).toBeNull();
149+
150+
expect(() => getByA11yStates('selected')).toThrow(FOUND_TWO_INSTANCES);
151+
expect(() => queryByA11yStates('selected')).toThrow(FOUND_TWO_INSTANCES);
152+
});
153+
154+
test('getAllByA11yStates, queryAllByA11yStates', () => {
155+
const { getAllByA11yStates, queryAllByA11yStates } = render(<Section />);
156+
157+
expect(getAllByA11yStates('selected')).toHaveLength(3);
158+
expect(queryAllByA11yStates(['selected'])).toHaveLength(3);
159+
160+
expect(() => getAllByA11yStates([])).toThrow(NO_INSTANCES_FOUND);
161+
expect(queryAllByA11yStates(NO_MATCHES_TEXT)).toEqual([]);
162+
});

src/helpers/a11yAPI.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// @flow
2+
import makeQuery from './makeQuery';
3+
4+
type QueryFn = (string | RegExp) => ReactTestInstance | null;
5+
type GetFn = (string | RegExp) => ReactTestInstance;
6+
type GetAllFn = (string | RegExp) => Array<ReactTestInstance> | [];
7+
type ArrayQueryFn = (string | Array<string>) => ReactTestInstance | null;
8+
type ArrayGetFn = (string | Array<string>) => ReactTestInstance;
9+
type ArrayGetAllFn = (string | Array<string>) => Array<ReactTestInstance> | [];
10+
11+
type A11yAPI = {
12+
getByA11yLabel: GetFn,
13+
getAllByA11yLabel: GetAllFn,
14+
queryByA11yLabel: QueryFn,
15+
queryAllByA11yLabel: GetAllFn,
16+
getByA11yHint: GetFn,
17+
getAllByA11yHint: GetAllFn,
18+
queryByA11yHint: QueryFn,
19+
queryAllByA11yHint: GetAllFn,
20+
getByA11yRole: GetFn,
21+
getAllByA11yRole: GetAllFn,
22+
queryByA11yRole: QueryFn,
23+
queryAllByA11yRole: GetAllFn,
24+
getByA11yStates: ArrayGetFn,
25+
getAllByA11yStates: ArrayGetAllFn,
26+
queryByA11yStates: ArrayQueryFn,
27+
queryAllByA11yStates: ArrayGetAllFn,
28+
};
29+
30+
export function matchStringValue(prop?: string, matcher: string | RegExp) {
31+
if (!prop) {
32+
return false;
33+
}
34+
35+
if (typeof matcher === 'string') {
36+
return prop === matcher;
37+
}
38+
39+
return Boolean(prop.match(matcher));
40+
}
41+
42+
export function matchArrayValue(
43+
prop?: Array<string>,
44+
matcher: string | Array<string>
45+
) {
46+
if (!prop || matcher.length === 0) {
47+
return false;
48+
}
49+
50+
if (typeof matcher === 'string') {
51+
return prop.includes(matcher);
52+
}
53+
54+
// $FlowFixMe - callback is sync hence prop exists
55+
return !matcher.some(e => !prop.includes(e));
56+
}
57+
58+
const a11yAPI = (instance: ReactTestInstance): A11yAPI =>
59+
({
60+
...makeQuery(
61+
'accessibilityLabel',
62+
{
63+
getBy: ['getByA11yLabel', 'getByAccessibilityLabel'],
64+
getAllBy: ['getAllByA11yLabel', 'getAllByAccessibilityLabel'],
65+
queryBy: ['queryByA11yLabel', 'queryByAccessibilityLabel'],
66+
queryAllBy: ['queryAllByA11yLabel', 'queryAllByAccessibilityLabel'],
67+
},
68+
matchStringValue
69+
)(instance),
70+
...makeQuery(
71+
'accessibilityHint',
72+
{
73+
getBy: ['getByA11yHint', 'getByAccessibilityHint'],
74+
getAllBy: ['getAllByA11yHint', 'getAllByAccessibilityHint'],
75+
queryBy: ['queryByA11yHint', 'queryByAccessibilityHint'],
76+
queryAllBy: ['queryAllByA11yHint', 'queryAllByAccessibilityHint'],
77+
},
78+
matchStringValue
79+
)(instance),
80+
...makeQuery(
81+
'accessibilityRole',
82+
{
83+
getBy: ['getByA11yRole', 'getByAccessibilityRole'],
84+
getAllBy: ['getAllByA11yRole', 'getAllByAccessibilityRole'],
85+
queryBy: ['queryByA11yRole', 'queryByAccessibilityRole'],
86+
queryAllBy: ['queryAllByA11yRole', 'queryAllByAccessibilityRole'],
87+
},
88+
matchStringValue
89+
)(instance),
90+
...makeQuery(
91+
'accessibilityStates',
92+
{
93+
getBy: ['getByA11yStates', 'getByAccessibilityStates'],
94+
getAllBy: ['getAllByA11yStates', 'getAllByAccessibilityStates'],
95+
queryBy: ['queryByA11yStates', 'queryByAccessibilityStates'],
96+
queryAllBy: ['queryAllByA11yStates', 'queryAllByAccessibilityStates'],
97+
},
98+
matchArrayValue
99+
)(instance),
100+
}: any);
101+
102+
export default a11yAPI;

src/helpers/errors.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,14 @@ export const logDeprecationWarning = (
3838

3939
warned[deprecatedFnName] = true;
4040
};
41+
42+
export const prepareErrorMessage = (error: Error) =>
43+
// Strip info about custom predicate
44+
error.message.replace(/ matching custom predicate[^]*/gm, '');
45+
46+
export const createQueryByError = (error: Error, callsite: Function) => {
47+
if (error.message.includes('No instances found')) {
48+
return null;
49+
}
50+
throw new ErrorWithStack(error.message, callsite);
51+
};

src/helpers/getByAPI.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ErrorWithStack,
66
createLibraryNotSupportedError,
77
logDeprecationWarning,
8+
prepareErrorMessage,
89
} from './errors';
910

1011
const filterNodeByType = (node, type) => node.type === type;
@@ -53,10 +54,6 @@ const getTextInputNodeByPlaceholder = (node, placeholder) => {
5354
}
5455
};
5556

56-
const prepareErrorMessage = error =>
57-
// Strip info about custom predicate
58-
error.message.replace(/ matching custom predicate[^]*/gm, '');
59-
6057
export const getByName = (instance: ReactTestInstance) =>
6158
function getByNameFn(name: string | React.ComponentType<*>) {
6259
logDeprecationWarning('getByName', 'getByType');

0 commit comments

Comments
 (0)