Skip to content

Commit 66e540d

Browse files
anishamaldemdjastrzebski
authored andcommitted
feat: toHaveAccessibleName matcher
1 parent 9757e5b commit 66e540d

File tree

7 files changed

+208
-1
lines changed

7 files changed

+208
-1
lines changed

src/helpers/accessiblity.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
StyleSheet,
55
} from 'react-native';
66
import { ReactTestInstance } from 'react-test-renderer';
7-
import { getHostSiblings } from './component-tree';
7+
import { getHostSiblings, getHostParent } from './component-tree';
88
import { getHostComponentNames } from './host-component-names';
99

1010
type IsInaccessibleOptions = {
@@ -233,3 +233,34 @@ export function isElementSelected(
233233
const { accessibilityState, 'aria-selected': ariaSelected } = element.props;
234234
return ariaSelected ?? accessibilityState?.selected ?? false;
235235
}
236+
237+
export function getAccessibleName(
238+
element: ReactTestInstance
239+
): string | undefined {
240+
const labelTextFromLabel = getAccessibilityLabel(element);
241+
const labelTextFromLabelledBy = getAccessibilityLabelledBy(element);
242+
243+
if (labelTextFromLabelledBy) {
244+
const parentElement = getHostParent(element);
245+
246+
if (parentElement) {
247+
const labelElement = parentElement.findByProps({
248+
nativeID: labelTextFromLabelledBy,
249+
});
250+
251+
if (labelElement) {
252+
// Extract and return the text content of the label element
253+
const label = labelElement.props.children;
254+
return label;
255+
}
256+
}
257+
}
258+
259+
if (labelTextFromLabel) {
260+
return labelTextFromLabel;
261+
}
262+
263+
const ownText = element?.children?.join(' ');
264+
265+
return ownText || undefined;
266+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ReactTestInstance } from 'react-test-renderer';
2+
import { getAccessibleName } from '../accessiblity';
3+
import { TextMatchOptions, matches, TextMatch } from '../../matches';
4+
5+
export function matchAccessibleName(
6+
node: ReactTestInstance,
7+
expectedName?: TextMatch,
8+
normalizer?: TextMatchOptions['normalizer'],
9+
exact?: TextMatchOptions['exact']
10+
): boolean {
11+
const accessibleName = getAccessibleName(node);
12+
13+
if (expectedName) {
14+
return matches(expectedName, accessibleName, normalizer, exact);
15+
}
16+
17+
return !!accessibleName;
18+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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() on view with accessibilityLabel prop', () => {
7+
render(<View testID="accessibility-label" accessibilityLabel="Test Label" />);
8+
const element = screen.getByTestId('accessibility-label');
9+
expect(element).toHaveAccessibleName('Test Label');
10+
});
11+
12+
test('toHaveAccessibleName() on view with aria-label prop', () => {
13+
render(<View testID="aria-label" aria-label="Aria Test Label" />);
14+
const element = screen.getByTestId('aria-label');
15+
expect(element).toHaveAccessibleName('Aria Test Label');
16+
});
17+
18+
test('toHaveAccessibleName() on view with accessibilityLabel prop with no expectedName', () => {
19+
render(<View testID="no-expectName-label" accessibilityLabel="Test Label" />);
20+
const element = screen.getByTestId('no-expectName-label');
21+
expect(element).toHaveAccessibleName();
22+
});
23+
24+
test('toHaveAccessibleName() on view with no accessibility props', () => {
25+
render(<Text testID="accessibility-label">Text</Text>);
26+
const element = screen.getByTestId('accessibility-label');
27+
expect(element).toHaveAccessibleName('Text');
28+
});
29+
30+
test('toHaveAccessibleName() on view with that does not have the expected accessible name', () => {
31+
render(<View testID="wrong-label" accessibilityLabel="The actual label" />);
32+
const element = screen.getByTestId('wrong-label');
33+
expect(() => expect(element).toHaveAccessibleName('Not the label'))
34+
.toThrowErrorMatchingInlineSnapshot(`
35+
"expect(element).toHaveAccessibleName()
36+
37+
Expected element to have accessible name:
38+
Not the label
39+
Received:
40+
The actual label"
41+
`);
42+
});
43+
44+
test('toHaveAccessibleName() on view that doesnt have accessible name defined', () => {
45+
render(<View testID="no-accessibile-name" />);
46+
const element = screen.getByTestId('no-accessibile-name');
47+
48+
expect(() => expect(element).toHaveAccessibleName())
49+
.toThrowErrorMatchingInlineSnapshot(`
50+
"expect(element).toHaveAccessibleName()
51+
52+
Expected element to have accessible name:
53+
54+
Received:
55+
undefined"
56+
`);
57+
});
58+
59+
it('toHaveAccessibleName() on a non-host element', () => {
60+
const nonElement = 'This is not a ReactTestInstance';
61+
expect(() => expect(nonElement).toHaveAccessibleName())
62+
.toThrowErrorMatchingInlineSnapshot(`
63+
"expect(received).toHaveAccessibleName()
64+
65+
received value must be a host element.
66+
Received has type: string
67+
Received has value: "This is not a ReactTestInstance""
68+
`);
69+
});
70+
71+
test('toHaveAccessibleName() on view with accessibilityLabelledBy prop', async () => {
72+
render(
73+
<View>
74+
<Text nativeID="formLabel">Accessibility LabelledBy</Text>
75+
<TextInput
76+
testID="accessibility-labelledby"
77+
accessibilityLabelledBy="formLabel"
78+
/>
79+
</View>
80+
);
81+
82+
const element = screen.getByTestId('accessibility-labelledby');
83+
expect(element).toHaveAccessibleName('Accessibility LabelledBy');
84+
});
85+
86+
test('toHaveAccessibleName() on view with ariaLabelledBy prop', async () => {
87+
render(
88+
<View>
89+
<Text nativeID="formLabel">Aria LabelledBy</Text>
90+
<TextInput testID="aria-labelledby" aria-labelledby="formLabel" />
91+
</View>
92+
);
93+
94+
const element = screen.getByTestId('aria-labelledby');
95+
expect(element).toHaveAccessibleName('Aria LabelledBy');
96+
});
97+
98+
//TODO: This fails as expected as I am unsure on how to extract the Text from the nested Element
99+
test('getByLabelText supports nested aria-labelledby', async () => {
100+
const screen = render(
101+
<>
102+
<View nativeID="label">
103+
<Text>Nested Aria LabelledBy</Text>
104+
</View>
105+
<TextInput testID="text-input" aria-labelledby="label" />
106+
</>
107+
);
108+
109+
const element = screen.getByTestId('text-input');
110+
expect(element).toHaveAccessibleName('Nested Aria LabelledBy');
111+
});

src/matchers/extend-expect.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface JestNativeMatchers<R> {
2222
toHaveProp(name: string, expectedValue?: unknown): R;
2323
toHaveStyle(style: StyleProp<Style>): R;
2424
toHaveTextContent(expectedText: TextMatch, options?: TextMatchOptions): R;
25+
toHaveAccessibleName(expectedName?: TextMatch, options?: TextMatchOptions): R;
2526
}
2627

2728
// Implicit Jest global `expect`.

src/matchers/extend-expect.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { toHaveDisplayValue } from './to-have-display-value';
1616
import { toHaveProp } from './to-have-prop';
1717
import { toHaveStyle } from './to-have-style';
1818
import { toHaveTextContent } from './to-have-text-content';
19+
import { toHaveAccessibleName } from './to-have-accessible-name';
1920

2021
expect.extend({
2122
toBeOnTheScreen,
@@ -35,4 +36,5 @@ expect.extend({
3536
toHaveProp,
3637
toHaveStyle,
3738
toHaveTextContent,
39+
toHaveAccessibleName,
3840
});

src/matchers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export { toHaveDisplayValue } from './to-have-display-value';
1414
export { toHaveProp } from './to-have-prop';
1515
export { toHaveStyle } from './to-have-style';
1616
export { toHaveTextContent } from './to-have-text-content';
17+
export { toHaveAccessibleName } from './to-have-accessible-name';
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { ReactTestInstance } from 'react-test-renderer';
2+
import { matcherHint } from 'jest-matcher-utils';
3+
import { matchAccessibleName } from '../helpers/matchers/accessibilityName';
4+
import { getAccessibleName } from '../helpers/accessiblity';
5+
import { TextMatch, TextMatchOptions } from '../matches';
6+
import { checkHostElement, formatMessage } from './utils';
7+
8+
export function toHaveAccessibleName(
9+
this: jest.MatcherContext,
10+
element: ReactTestInstance,
11+
expectedName?: TextMatch,
12+
options?: TextMatchOptions
13+
) {
14+
checkHostElement(element, toHaveAccessibleName, this);
15+
16+
const receivedName = getAccessibleName(element);
17+
18+
return {
19+
pass: matchAccessibleName(
20+
element,
21+
expectedName,
22+
options?.normalizer,
23+
options?.exact
24+
),
25+
message: () => {
26+
return [
27+
formatMessage(
28+
matcherHint(
29+
`${this.isNot ? '.not' : ''}.toHaveAccessibleName`,
30+
'element',
31+
''
32+
),
33+
`Expected element ${
34+
this.isNot ? 'not to' : 'to'
35+
} have accessible name`,
36+
`${expectedName ? expectedName : ''}`,
37+
'Received',
38+
receivedName
39+
),
40+
].join('\n');
41+
},
42+
};
43+
}

0 commit comments

Comments
 (0)