Skip to content

Commit 2ada536

Browse files
feat: toHaveAccessibleName matcher (#1509)
* feat: toHaveAccessibleName matcher * feat: toHaveAccessibleName matcher * feat: toHaveAccessibleName matcher * refactor: check logic * chore: fix lint * refactor: tests * chore: test tweaks --------- Co-authored-by: Maciej Jastrzębski <mdjastrzebski@gmail.com>
1 parent b019479 commit 2ada536

File tree

7 files changed

+214
-2
lines changed

7 files changed

+214
-2
lines changed

src/helpers/accessiblity.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {
44
StyleSheet,
55
} from 'react-native';
66
import { ReactTestInstance } from 'react-test-renderer';
7-
import { getHostSiblings } from './component-tree';
7+
import { getTextContent } from './text-content';
8+
import { getHostSiblings, getUnsafeRootElement } from './component-tree';
89
import { getHostComponentNames } from './host-component-names';
910

1011
type IsInaccessibleOptions = {
@@ -233,3 +234,23 @@ export function isElementSelected(
233234
const { accessibilityState, 'aria-selected': ariaSelected } = element.props;
234235
return ariaSelected ?? accessibilityState?.selected ?? false;
235236
}
237+
238+
export function getAccessibleName(
239+
element: ReactTestInstance
240+
): string | undefined {
241+
const label = getAccessibilityLabel(element);
242+
if (label) {
243+
return label;
244+
}
245+
246+
const labelElementId = getAccessibilityLabelledBy(element);
247+
if (labelElementId) {
248+
const rootElement = getUnsafeRootElement(element);
249+
const labelElement = rootElement?.findByProps({ nativeID: labelElementId });
250+
if (labelElement) {
251+
return getTextContent(labelElement);
252+
}
253+
}
254+
255+
return getTextContent(element);
256+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import * as React from 'react';
2+
import { View, Text, TextInput } from 'react-native';
3+
import { render, screen } from '../..';
4+
import '../extend-expect';
5+
6+
test('toHaveAccessibleName() handles view with "accessibilityLabel" prop', () => {
7+
render(<View testID="view" accessibilityLabel="Test label" />);
8+
const element = screen.getByTestId('view');
9+
expect(element).toHaveAccessibleName('Test label');
10+
expect(element).not.toHaveAccessibleName('Other label');
11+
});
12+
13+
test('toHaveAccessibleName() handles view with "aria-label" prop', () => {
14+
render(<View testID="view" aria-label="Test label" />);
15+
const element = screen.getByTestId('view');
16+
expect(element).toHaveAccessibleName('Test label');
17+
expect(element).not.toHaveAccessibleName('Other label');
18+
});
19+
20+
test('toHaveAccessibleName() handles view with "accessibilityLabelledBy" prop', async () => {
21+
render(
22+
<View>
23+
<Text nativeID="label">External label</Text>
24+
<TextInput testID="input" accessibilityLabelledBy="label" />
25+
</View>
26+
);
27+
28+
const element = screen.getByTestId('input');
29+
expect(element).toHaveAccessibleName('External label');
30+
expect(element).not.toHaveAccessibleName('Other label');
31+
});
32+
33+
test('toHaveAccessibleName() handles nested "accessibilityLabelledBy"', async () => {
34+
render(
35+
<>
36+
<View nativeID="label">
37+
<Text>External label</Text>
38+
</View>
39+
<TextInput testID="input" accessibilityLabelledBy="label" />
40+
</>
41+
);
42+
43+
const element = screen.getByTestId('input');
44+
expect(element).toHaveAccessibleName('External label');
45+
expect(element).not.toHaveAccessibleName('Other label');
46+
});
47+
48+
test('toHaveAccessibleName() handles view with nested "accessibilityLabelledBy" with no text', async () => {
49+
render(
50+
<>
51+
<View nativeID="label">
52+
<View />
53+
</View>
54+
<TextInput testID="text-input" accessibilityLabelledBy="label" />
55+
</>
56+
);
57+
58+
const element = screen.getByTestId('text-input');
59+
expect(element).not.toHaveAccessibleName();
60+
});
61+
62+
test('toHaveAccessibleName() handles view with "aria-labelledby" prop', async () => {
63+
render(
64+
<View>
65+
<Text nativeID="label">External label</Text>
66+
<TextInput testID="input" aria-labelledby="label" />
67+
</View>
68+
);
69+
70+
const element = screen.getByTestId('input');
71+
expect(element).toHaveAccessibleName('External label');
72+
expect(element).not.toHaveAccessibleName('Other label');
73+
});
74+
75+
test('toHaveAccessibleName() handles view with implicit accessible name', () => {
76+
render(<Text testID="view">Text</Text>);
77+
const element = screen.getByTestId('view');
78+
expect(element).toHaveAccessibleName('Text');
79+
expect(element).not.toHaveAccessibleName('Other text');
80+
});
81+
82+
test('toHaveAccessibleName() supports calling without expected name', () => {
83+
render(<View testID="view" accessibilityLabel="Test label" />);
84+
const element = screen.getByTestId('view');
85+
86+
expect(element).toHaveAccessibleName();
87+
expect(() => expect(element).not.toHaveAccessibleName())
88+
.toThrowErrorMatchingInlineSnapshot(`
89+
"expect(element).not.toHaveAccessibleName()
90+
91+
Expected element not to have accessible name:
92+
undefined
93+
Received:
94+
Test label"
95+
`);
96+
});
97+
98+
test('toHaveAccessibleName() handles a view without name when called without expected name', () => {
99+
render(<View testID="view" />);
100+
const element = screen.getByTestId('view');
101+
102+
expect(element).not.toHaveAccessibleName();
103+
expect(() => expect(element).toHaveAccessibleName())
104+
.toThrowErrorMatchingInlineSnapshot(`
105+
"expect(element).toHaveAccessibleName()
106+
107+
Expected element to have accessible name:
108+
undefined
109+
Received:
110+
"
111+
`);
112+
});
113+
114+
it('toHaveAccessibleName() rejects non-host element', () => {
115+
const nonElement = 'This is not a ReactTestInstance';
116+
117+
expect(() => expect(nonElement).toHaveAccessibleName())
118+
.toThrowErrorMatchingInlineSnapshot(`
119+
"expect(received).toHaveAccessibleName()
120+
121+
received value must be a host element.
122+
Received has type: string
123+
Received has value: "This is not a ReactTestInstance""
124+
`);
125+
126+
expect(() => expect(nonElement).not.toHaveAccessibleName())
127+
.toThrowErrorMatchingInlineSnapshot(`
128+
"expect(received).not.toHaveAccessibleName()
129+
130+
received value must be a host element.
131+
Received has type: string
132+
Received has value: "This is not a ReactTestInstance""
133+
`);
134+
});

src/matchers/extend-expect.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface JestNativeMatchers<R> {
1818
toBeVisible(): R;
1919
toContainElement(element: ReactTestInstance | null): R;
2020
toHaveAccessibilityValue(expectedValue: AccessibilityValueMatcher): R;
21+
toHaveAccessibleName(expectedName?: TextMatch, options?: TextMatchOptions): R;
2122
toHaveDisplayValue(expectedValue: TextMatch, options?: TextMatchOptions): R;
2223
toHaveProp(name: string, expectedValue?: unknown): R;
2324
toHaveStyle(style: StyleProp<Style>): R;

src/matchers/extend-expect.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { toBeSelected } from './to-be-selected';
1212
import { toBeVisible } from './to-be-visible';
1313
import { toContainElement } from './to-contain-element';
1414
import { toHaveAccessibilityValue } from './to-have-accessibility-value';
15+
import { toHaveAccessibleName } from './to-have-accessible-name';
1516
import { toHaveDisplayValue } from './to-have-display-value';
1617
import { toHaveProp } from './to-have-prop';
1718
import { toHaveStyle } from './to-have-style';
@@ -31,6 +32,7 @@ expect.extend({
3132
toBeVisible,
3233
toContainElement,
3334
toHaveAccessibilityValue,
35+
toHaveAccessibleName,
3436
toHaveDisplayValue,
3537
toHaveProp,
3638
toHaveStyle,

src/matchers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export { toBeSelected } from './to-be-selected';
1010
export { toBeVisible } from './to-be-visible';
1111
export { toContainElement } from './to-contain-element';
1212
export { toHaveAccessibilityValue } from './to-have-accessibility-value';
13+
export { toHaveAccessibleName } from './to-have-accessible-name';
1314
export { toHaveDisplayValue } from './to-have-display-value';
1415
export { toHaveProp } from './to-have-prop';
1516
export { toHaveStyle } from './to-have-style';
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { ReactTestInstance } from 'react-test-renderer';
2+
import { matcherHint } from 'jest-matcher-utils';
3+
import { getAccessibleName } from '../helpers/accessiblity';
4+
import { TextMatch, TextMatchOptions, matches } from '../matches';
5+
import { checkHostElement, formatMessage } from './utils';
6+
7+
export function toHaveAccessibleName(
8+
this: jest.MatcherContext,
9+
element: ReactTestInstance,
10+
expectedName?: TextMatch,
11+
options?: TextMatchOptions
12+
) {
13+
checkHostElement(element, toHaveAccessibleName, this);
14+
15+
const receivedName = getAccessibleName(element);
16+
const missingExpectedValue = arguments.length === 1;
17+
18+
let pass = false;
19+
if (missingExpectedValue) {
20+
pass = receivedName !== '';
21+
} else {
22+
pass =
23+
expectedName != null
24+
? matches(
25+
expectedName,
26+
receivedName,
27+
options?.normalizer,
28+
options?.exact
29+
)
30+
: false;
31+
}
32+
33+
return {
34+
pass,
35+
message: () => {
36+
return [
37+
formatMessage(
38+
matcherHint(
39+
`${this.isNot ? '.not' : ''}.toHaveAccessibleName`,
40+
'element',
41+
''
42+
),
43+
`Expected element ${
44+
this.isNot ? 'not to' : 'to'
45+
} have accessible name`,
46+
expectedName,
47+
'Received',
48+
receivedName
49+
),
50+
].join('\n');
51+
},
52+
};
53+
}

src/matchers/utils.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export function formatElementArray(elements: ReactTestInstance[]) {
109109
export function formatMessage(
110110
matcher: string,
111111
expectedLabel: string,
112-
expectedValue: string | RegExp,
112+
expectedValue: string | RegExp | null | undefined,
113113
receivedLabel: string,
114114
receivedValue: string | null | undefined
115115
) {

0 commit comments

Comments
 (0)