diff --git a/README.md b/README.md index 8ba1662..3e9b3ea 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ - [`toHaveTextContent`](#tohavetextcontent) - [`toHaveStyle`](#tohavestyle) - [`toBeVisible`](#tobevisible) + - [`toHaveAccessibilityState`](#tohaveaccessibilitystate) - [Inspiration](#inspiration) - [Other solutions](#other-solutions) - [Contributors](#contributors) @@ -226,7 +227,7 @@ expect(parent).not.toContainElement(grandparent); toHaveProp(prop: string, value?: any); ``` -Check that an element has a given prop. +Check that the element has a given prop. You can optionally check that the attribute has a specific expected value. @@ -431,10 +432,72 @@ const { getByTestId } = render( expect(getByTestId('test')).not.toBeVisible(); ``` +### `toHaveAccessibilityState` + +```ts +toHaveAccessibilityState(state: { + disabled?: boolean; + selected?: boolean; + checked?: boolean | 'mixed'; + busy?: boolean; + expanded?: boolean; +}); +``` + +Check that the element has given accessibility state entries. + +This check is based on `accessibilityState` prop but also takes into account the default entries +which have been found by experimenting with accessibility inspector and screen readers on both iOS +and Android. + +Some state entries behave as if explicit `false` value is the same as not having given state entry, +so their default value is `false`: + +- `disabled` +- `selected` +- `busy` + +The remaining state entries behave as if explicit `false` value is different than not having given +state entry, so their default value is `undefined`: + +- `checked` +- `expanded` + +This matcher is compatible with `*ByRole` and `*ByA11State` queries from React Native Testing +Library. + +#### Examples + +```js +render(); + +// Single value match +expect(screen.getByTestId('view')).toHaveAccessibilityState({ expanded: true }); +expect(screen.getByTestId('view')).toHaveAccessibilityState({ checked: true }); + +// Can match multiple entries +expect(screen.getByTestId('view')).toHaveAccessibilityState({ expanded: true, checked: true }); +``` + +Default values handling: + +```js +render(); + +// Matching states where default value is `false` +expect(screen.getByTestId('view')).toHaveAccessibilityState({ disabled: false }); +expect(screen.getByTestId('view')).toHaveAccessibilityState({ selected: false }); +expect(screen.getByTestId('view')).toHaveAccessibilityState({ busy: false }); + +// Matching states where default value is `undefined` +expect(screen.getByTestId('view')).not.toHaveAccessibilityState({ checked: false }); +expect(screen.getByTestId('view')).not.toHaveAccessibilityState({ expanded: false }); +``` + ## Inspiration This library was made to be a companion for -[RNTL](https://github.com/callstack/react-native-testing-library). +[React Native Testing Library](https://github.com/callstack/react-native-testing-library). It was inspired by [jest-dom](https://github.com/gnapse/jest-dom/), the companion library for [DTL](https://github.com/kentcdodds/dom-testing-library/). We emulated as many of those helpers as diff --git a/extend-expect.d.ts b/extend-expect.d.ts index d75154a..17d3c0b 100644 --- a/extend-expect.d.ts +++ b/extend-expect.d.ts @@ -1,4 +1,4 @@ -import type { ImageStyle, StyleProp, TextStyle, ViewStyle } from 'react-native'; +import type { AccessibilityState, ImageStyle, StyleProp, TextStyle, ViewStyle } from 'react-native'; import type { ReactTestInstance } from 'react-test-renderer'; declare global { @@ -16,6 +16,8 @@ declare global { /** @deprecated This function has been renamed to `toBeEmptyElement`. */ toBeEmpty(): R; toBeVisible(): R; + + toHaveAccessibilityState(state: AccessibilityState): R; } } } diff --git a/src/__tests__/to-have-accessibility-state.tsx b/src/__tests__/to-have-accessibility-state.tsx new file mode 100644 index 0000000..868c2b0 --- /dev/null +++ b/src/__tests__/to-have-accessibility-state.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { View } from 'react-native'; +import { render } from '@testing-library/react-native'; + +test('.toHaveAccessibilityState to handle explicit state', () => { + const { getByTestId } = render( + + + + + + + + + + + + , + ); + + expect(getByTestId('disabled')).toHaveAccessibilityState({ disabled: true }); + expect(getByTestId('disabled')).not.toHaveAccessibilityState({ disabled: false }); + expect(() => expect(getByTestId('disabled')).toHaveAccessibilityState({ disabled: false })) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).toHaveAccessibilityState({"disabled": false}) + + Expected the element to have accessibility state: + {"disabled": false} + Received element with implied accessibility state: + {"busy": false, "disabled": true, "selected": false}" + `); + + expect(getByTestId('selected')).toHaveAccessibilityState({ selected: true }); + expect(getByTestId('selected')).not.toHaveAccessibilityState({ selected: false }); + expect(() => expect(getByTestId('selected')).not.toHaveAccessibilityState({ selected: true })) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toHaveAccessibilityState({"selected": true}) + + Expected the element not to have accessibility state: + {"selected": true} + Received element with implied accessibility state: + {"busy": false, "disabled": false, "selected": true}" + `); + + expect(getByTestId('busy')).toHaveAccessibilityState({ busy: true }); + expect(getByTestId('busy')).not.toHaveAccessibilityState({ busy: false }); + + expect(getByTestId('checked-true')).toHaveAccessibilityState({ checked: true }); + expect(getByTestId('checked-true')).not.toHaveAccessibilityState({ checked: 'mixed' }); + expect(getByTestId('checked-true')).not.toHaveAccessibilityState({ checked: false }); + + expect(getByTestId('checked-mixed')).toHaveAccessibilityState({ checked: 'mixed' }); + expect(getByTestId('checked-mixed')).not.toHaveAccessibilityState({ checked: true }); + expect(getByTestId('checked-mixed')).not.toHaveAccessibilityState({ checked: false }); + + expect(getByTestId('checked-false')).toHaveAccessibilityState({ checked: false }); + expect(getByTestId('checked-false')).not.toHaveAccessibilityState({ checked: true }); + expect(getByTestId('checked-false')).not.toHaveAccessibilityState({ checked: 'mixed' }); + + expect(getByTestId('expanded-true')).toHaveAccessibilityState({ expanded: true }); + expect(getByTestId('expanded-true')).not.toHaveAccessibilityState({ expanded: false }); + + expect(getByTestId('expanded-false')).toHaveAccessibilityState({ expanded: false }); + expect(getByTestId('expanded-false')).not.toHaveAccessibilityState({ expanded: true }); + + expect(getByTestId('disabled-selected')).toHaveAccessibilityState({ + disabled: true, + selected: true, + }); + expect(getByTestId('disabled-selected')).not.toHaveAccessibilityState({ + disabled: false, + selected: true, + }); + expect(getByTestId('disabled-selected')).not.toHaveAccessibilityState({ + disabled: true, + selected: false, + }); +}); + +test('.toHaveAccessibilityState to handle implicit state', () => { + const { getByTestId } = render(); + + expect(getByTestId('subject')).toHaveAccessibilityState({ disabled: false }); + expect(getByTestId('subject')).toHaveAccessibilityState({ selected: false }); + expect(getByTestId('subject')).toHaveAccessibilityState({ busy: false }); + + expect(getByTestId('subject')).not.toHaveAccessibilityState({ checked: false }); + expect(getByTestId('subject')).not.toHaveAccessibilityState({ expanded: false }); +}); diff --git a/src/extend-expect.ts b/src/extend-expect.ts index 0a0e5be..33f1c36 100644 --- a/src/extend-expect.ts +++ b/src/extend-expect.ts @@ -5,6 +5,7 @@ import { toHaveProp } from './to-have-prop'; import { toHaveStyle } from './to-have-style'; import { toHaveTextContent } from './to-have-text-content'; import { toBeVisible } from './to-be-visible'; +import { toHaveAccessibilityState } from './to-have-accessibility-state'; expect.extend({ toBeDisabled, @@ -16,4 +17,5 @@ expect.extend({ toHaveStyle, toHaveTextContent, toBeVisible, + toHaveAccessibilityState, }); diff --git a/src/to-have-accessibility-state.ts b/src/to-have-accessibility-state.ts new file mode 100644 index 0000000..3392e2f --- /dev/null +++ b/src/to-have-accessibility-state.ts @@ -0,0 +1,71 @@ +import type { AccessibilityState } from 'react-native'; +import type { ReactTestInstance } from 'react-test-renderer'; +import { matcherHint, stringify } from 'jest-matcher-utils'; +import { checkReactElement, getMessage } from './utils'; + +export function toHaveAccessibilityState( + this: jest.MatcherContext, + element: ReactTestInstance, + expectedState: AccessibilityState, +) { + checkReactElement(element, toHaveAccessibilityState, this); + + const impliedState = getAccessibilityState(element); + return { + pass: matchAccessibilityState(element, expectedState), + message: () => { + const matcher = matcherHint( + `${this.isNot ? '.not' : ''}.toHaveAccessibilityState`, + 'element', + stringify(expectedState), + ); + return getMessage( + matcher, + `Expected the element ${this.isNot ? 'not to' : 'to'} have accessibility state`, + stringify(expectedState), + 'Received element with implied accessibility state', + stringify(impliedState), + ); + }, + }; +} + +/** + * Default accessibility state values based on experiments using accessibility + * inspector/screen reader on iOS and Android. + * + * @see https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State + */ +const defaultState: AccessibilityState = { + disabled: false, + selected: false, + busy: false, +}; + +const getAccessibilityState = (element: ReactTestInstance) => { + return { + ...defaultState, + ...element.props.accessibilityState, + }; +}; + +const accessibilityStateKeys: (keyof AccessibilityState)[] = [ + 'disabled', + 'selected', + 'checked', + 'busy', + 'expanded', +]; + +function matchAccessibilityState(element: ReactTestInstance, matcher: AccessibilityState) { + const state = getAccessibilityState(element); + return accessibilityStateKeys.every((key) => matchStateEntry(state, matcher, key)); +} + +function matchStateEntry( + state: AccessibilityState, + matcher: AccessibilityState, + key: keyof AccessibilityState, +) { + return matcher[key] === undefined || matcher[key] === state[key]; +}