Skip to content

Commit f050c41

Browse files
feat: toHaveAccessibilityState() (#124)
1 parent fae097a commit f050c41

File tree

5 files changed

+230
-3
lines changed

5 files changed

+230
-3
lines changed

README.md

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
- [`toHaveTextContent`](#tohavetextcontent)
4949
- [`toHaveStyle`](#tohavestyle)
5050
- [`toBeVisible`](#tobevisible)
51+
- [`toHaveAccessibilityState`](#tohaveaccessibilitystate)
5152
- [Inspiration](#inspiration)
5253
- [Other solutions](#other-solutions)
5354
- [Contributors](#contributors)
@@ -226,7 +227,7 @@ expect(parent).not.toContainElement(grandparent);
226227
toHaveProp(prop: string, value?: any);
227228
```
228229

229-
Check that an element has a given prop.
230+
Check that the element has a given prop.
230231

231232
You can optionally check that the attribute has a specific expected value.
232233

@@ -431,10 +432,72 @@ const { getByTestId } = render(
431432
expect(getByTestId('test')).not.toBeVisible();
432433
```
433434

435+
### `toHaveAccessibilityState`
436+
437+
```ts
438+
toHaveAccessibilityState(state: {
439+
disabled?: boolean;
440+
selected?: boolean;
441+
checked?: boolean | 'mixed';
442+
busy?: boolean;
443+
expanded?: boolean;
444+
});
445+
```
446+
447+
Check that the element has given accessibility state entries.
448+
449+
This check is based on `accessibilityState` prop but also takes into account the default entries
450+
which have been found by experimenting with accessibility inspector and screen readers on both iOS
451+
and Android.
452+
453+
Some state entries behave as if explicit `false` value is the same as not having given state entry,
454+
so their default value is `false`:
455+
456+
- `disabled`
457+
- `selected`
458+
- `busy`
459+
460+
The remaining state entries behave as if explicit `false` value is different than not having given
461+
state entry, so their default value is `undefined`:
462+
463+
- `checked`
464+
- `expanded`
465+
466+
This matcher is compatible with `*ByRole` and `*ByA11State` queries from React Native Testing
467+
Library.
468+
469+
#### Examples
470+
471+
```js
472+
render(<View testID="view" accessibilityState={{ expanded: true, checked: true }} />);
473+
474+
// Single value match
475+
expect(screen.getByTestId('view')).toHaveAccessibilityState({ expanded: true });
476+
expect(screen.getByTestId('view')).toHaveAccessibilityState({ checked: true });
477+
478+
// Can match multiple entries
479+
expect(screen.getByTestId('view')).toHaveAccessibilityState({ expanded: true, checked: true });
480+
```
481+
482+
Default values handling:
483+
484+
```js
485+
render(<View testID="view" />);
486+
487+
// Matching states where default value is `false`
488+
expect(screen.getByTestId('view')).toHaveAccessibilityState({ disabled: false });
489+
expect(screen.getByTestId('view')).toHaveAccessibilityState({ selected: false });
490+
expect(screen.getByTestId('view')).toHaveAccessibilityState({ busy: false });
491+
492+
// Matching states where default value is `undefined`
493+
expect(screen.getByTestId('view')).not.toHaveAccessibilityState({ checked: false });
494+
expect(screen.getByTestId('view')).not.toHaveAccessibilityState({ expanded: false });
495+
```
496+
434497
## Inspiration
435498

436499
This library was made to be a companion for
437-
[RNTL](https://github.com/callstack/react-native-testing-library).
500+
[React Native Testing Library](https://github.com/callstack/react-native-testing-library).
438501

439502
It was inspired by [jest-dom](https://github.com/gnapse/jest-dom/), the companion library for
440503
[DTL](https://github.com/kentcdodds/dom-testing-library/). We emulated as many of those helpers as

extend-expect.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ImageStyle, StyleProp, TextStyle, ViewStyle } from 'react-native';
1+
import type { AccessibilityState, ImageStyle, StyleProp, TextStyle, ViewStyle } from 'react-native';
22
import type { ReactTestInstance } from 'react-test-renderer';
33

44
declare global {
@@ -16,6 +16,8 @@ declare global {
1616
/** @deprecated This function has been renamed to `toBeEmptyElement`. */
1717
toBeEmpty(): R;
1818
toBeVisible(): R;
19+
20+
toHaveAccessibilityState(state: AccessibilityState): R;
1921
}
2022
}
2123
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import * as React from 'react';
2+
import { View } from 'react-native';
3+
import { render } from '@testing-library/react-native';
4+
5+
test('.toHaveAccessibilityState to handle explicit state', () => {
6+
const { getByTestId } = render(
7+
<View>
8+
<View testID="disabled" accessibilityState={{ disabled: true }} />
9+
<View testID="selected" accessibilityState={{ selected: true }} />
10+
<View testID="busy" accessibilityState={{ busy: true }} />
11+
<View testID="checked-true" accessibilityState={{ checked: true }} />
12+
<View testID="checked-mixed" accessibilityState={{ checked: 'mixed' }} />
13+
<View testID="checked-false" accessibilityState={{ checked: false }} />
14+
<View testID="expanded-true" accessibilityState={{ expanded: true }} />
15+
<View testID="expanded-false" accessibilityState={{ expanded: false }} />
16+
17+
<View testID="disabled-selected" accessibilityState={{ disabled: true, selected: true }} />
18+
</View>,
19+
);
20+
21+
expect(getByTestId('disabled')).toHaveAccessibilityState({ disabled: true });
22+
expect(getByTestId('disabled')).not.toHaveAccessibilityState({ disabled: false });
23+
expect(() => expect(getByTestId('disabled')).toHaveAccessibilityState({ disabled: false }))
24+
.toThrowErrorMatchingInlineSnapshot(`
25+
"expect(element).toHaveAccessibilityState({"disabled": false})
26+
27+
Expected the element to have accessibility state:
28+
{"disabled": false}
29+
Received element with implied accessibility state:
30+
{"busy": false, "disabled": true, "selected": false}"
31+
`);
32+
33+
expect(getByTestId('selected')).toHaveAccessibilityState({ selected: true });
34+
expect(getByTestId('selected')).not.toHaveAccessibilityState({ selected: false });
35+
expect(() => expect(getByTestId('selected')).not.toHaveAccessibilityState({ selected: true }))
36+
.toThrowErrorMatchingInlineSnapshot(`
37+
"expect(element).not.toHaveAccessibilityState({"selected": true})
38+
39+
Expected the element not to have accessibility state:
40+
{"selected": true}
41+
Received element with implied accessibility state:
42+
{"busy": false, "disabled": false, "selected": true}"
43+
`);
44+
45+
expect(getByTestId('busy')).toHaveAccessibilityState({ busy: true });
46+
expect(getByTestId('busy')).not.toHaveAccessibilityState({ busy: false });
47+
48+
expect(getByTestId('checked-true')).toHaveAccessibilityState({ checked: true });
49+
expect(getByTestId('checked-true')).not.toHaveAccessibilityState({ checked: 'mixed' });
50+
expect(getByTestId('checked-true')).not.toHaveAccessibilityState({ checked: false });
51+
52+
expect(getByTestId('checked-mixed')).toHaveAccessibilityState({ checked: 'mixed' });
53+
expect(getByTestId('checked-mixed')).not.toHaveAccessibilityState({ checked: true });
54+
expect(getByTestId('checked-mixed')).not.toHaveAccessibilityState({ checked: false });
55+
56+
expect(getByTestId('checked-false')).toHaveAccessibilityState({ checked: false });
57+
expect(getByTestId('checked-false')).not.toHaveAccessibilityState({ checked: true });
58+
expect(getByTestId('checked-false')).not.toHaveAccessibilityState({ checked: 'mixed' });
59+
60+
expect(getByTestId('expanded-true')).toHaveAccessibilityState({ expanded: true });
61+
expect(getByTestId('expanded-true')).not.toHaveAccessibilityState({ expanded: false });
62+
63+
expect(getByTestId('expanded-false')).toHaveAccessibilityState({ expanded: false });
64+
expect(getByTestId('expanded-false')).not.toHaveAccessibilityState({ expanded: true });
65+
66+
expect(getByTestId('disabled-selected')).toHaveAccessibilityState({
67+
disabled: true,
68+
selected: true,
69+
});
70+
expect(getByTestId('disabled-selected')).not.toHaveAccessibilityState({
71+
disabled: false,
72+
selected: true,
73+
});
74+
expect(getByTestId('disabled-selected')).not.toHaveAccessibilityState({
75+
disabled: true,
76+
selected: false,
77+
});
78+
});
79+
80+
test('.toHaveAccessibilityState to handle implicit state', () => {
81+
const { getByTestId } = render(<View testID="subject" />);
82+
83+
expect(getByTestId('subject')).toHaveAccessibilityState({ disabled: false });
84+
expect(getByTestId('subject')).toHaveAccessibilityState({ selected: false });
85+
expect(getByTestId('subject')).toHaveAccessibilityState({ busy: false });
86+
87+
expect(getByTestId('subject')).not.toHaveAccessibilityState({ checked: false });
88+
expect(getByTestId('subject')).not.toHaveAccessibilityState({ expanded: false });
89+
});

src/extend-expect.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { toHaveProp } from './to-have-prop';
55
import { toHaveStyle } from './to-have-style';
66
import { toHaveTextContent } from './to-have-text-content';
77
import { toBeVisible } from './to-be-visible';
8+
import { toHaveAccessibilityState } from './to-have-accessibility-state';
89

910
expect.extend({
1011
toBeDisabled,
@@ -16,4 +17,5 @@ expect.extend({
1617
toHaveStyle,
1718
toHaveTextContent,
1819
toBeVisible,
20+
toHaveAccessibilityState,
1921
});

src/to-have-accessibility-state.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { AccessibilityState } from 'react-native';
2+
import type { ReactTestInstance } from 'react-test-renderer';
3+
import { matcherHint, stringify } from 'jest-matcher-utils';
4+
import { checkReactElement, getMessage } from './utils';
5+
6+
export function toHaveAccessibilityState(
7+
this: jest.MatcherContext,
8+
element: ReactTestInstance,
9+
expectedState: AccessibilityState,
10+
) {
11+
checkReactElement(element, toHaveAccessibilityState, this);
12+
13+
const impliedState = getAccessibilityState(element);
14+
return {
15+
pass: matchAccessibilityState(element, expectedState),
16+
message: () => {
17+
const matcher = matcherHint(
18+
`${this.isNot ? '.not' : ''}.toHaveAccessibilityState`,
19+
'element',
20+
stringify(expectedState),
21+
);
22+
return getMessage(
23+
matcher,
24+
`Expected the element ${this.isNot ? 'not to' : 'to'} have accessibility state`,
25+
stringify(expectedState),
26+
'Received element with implied accessibility state',
27+
stringify(impliedState),
28+
);
29+
},
30+
};
31+
}
32+
33+
/**
34+
* Default accessibility state values based on experiments using accessibility
35+
* inspector/screen reader on iOS and Android.
36+
*
37+
* @see https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State
38+
*/
39+
const defaultState: AccessibilityState = {
40+
disabled: false,
41+
selected: false,
42+
busy: false,
43+
};
44+
45+
const getAccessibilityState = (element: ReactTestInstance) => {
46+
return {
47+
...defaultState,
48+
...element.props.accessibilityState,
49+
};
50+
};
51+
52+
const accessibilityStateKeys: (keyof AccessibilityState)[] = [
53+
'disabled',
54+
'selected',
55+
'checked',
56+
'busy',
57+
'expanded',
58+
];
59+
60+
function matchAccessibilityState(element: ReactTestInstance, matcher: AccessibilityState) {
61+
const state = getAccessibilityState(element);
62+
return accessibilityStateKeys.every((key) => matchStateEntry(state, matcher, key));
63+
}
64+
65+
function matchStateEntry(
66+
state: AccessibilityState,
67+
matcher: AccessibilityState,
68+
key: keyof AccessibilityState,
69+
) {
70+
return matcher[key] === undefined || matcher[key] === state[key];
71+
}

0 commit comments

Comments
 (0)