From 7ae763ff65b07baf0398c0dfd899d28c8e5e17b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Thu, 20 Oct 2022 11:52:10 +0200 Subject: [PATCH] Query by role and accessibilityState and improve ByRole query errors (#1161) --- src/queries/__tests__/role.test.tsx | 522 +++++++++++++++++++++++++++- src/queries/makeQueries.ts | 15 +- src/queries/role.ts | 76 +++- typings/index.flow.js | 7 +- website/docs/Queries.md | 15 + 5 files changed, 618 insertions(+), 17 deletions(-) diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx index ea7b98de9..ce7519186 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -1,5 +1,12 @@ import * as React from 'react'; -import { TouchableOpacity, Text } from 'react-native'; +import { + TouchableOpacity, + TouchableWithoutFeedback, + Text, + View, + Pressable, + Button as RNButton, +} from 'react-native'; import { render } from '../..'; const TEXT_LABEL = 'cool text'; @@ -8,11 +15,11 @@ const TEXT_LABEL = 'cool text'; const NO_MATCHES_TEXT: any = 'not-existent-element'; const getMultipleInstancesFoundMessage = (value: string) => { - return `Found multiple elements with accessibilityRole: ${value}`; + return `Found multiple elements with role: "${value}"`; }; const getNoInstancesFoundMessage = (value: string) => { - return `Unable to find an element with accessibilityRole: ${value}`; + return `Unable to find an element with role: "${value}"`; }; const Typography = ({ children, ...rest }: any) => { @@ -181,3 +188,512 @@ describe('supports name option', () => { ); }); }); + +describe('supports accessibility states', () => { + describe('disabled', () => { + test('returns a disabled element when required', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('button', { disabled: true })).toBeTruthy(); + expect(queryByRole('button', { disabled: false })).toBe(null); + }); + + test('returns the correct element when only one matches all the requirements', () => { + const { getByRole } = render( + <> + + Save + + + Save + + + ); + + expect( + getByRole('button', { name: 'Save', disabled: true }).props.testID + ).toBe('correct'); + }); + + test('returns an implicitly enabled element', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('button', { disabled: false })).toBeTruthy(); + expect(queryByRole('button', { disabled: true })).toBe(null); + }); + + test('returns an explicitly enabled element', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('button', { disabled: false })).toBeTruthy(); + expect(queryByRole('button', { disabled: true })).toBe(null); + }); + + test('does not return disabled elements when querying for non disabled', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('button', { disabled: false })).toBe(null); + }); + + test('returns elements using the built-in disabled prop', () => { + const { getByRole } = render( + <> + + Pressable + + + + + TouchableWithoutFeedback + + + {}} title="RNButton" /> + + ); + + expect( + getByRole('button', { name: 'Pressable', disabled: true }) + ).toBeTruthy(); + + expect( + getByRole('button', { + name: 'TouchableWithoutFeedback', + disabled: true, + }) + ).toBeTruthy(); + + expect( + getByRole('button', { name: 'RNButton', disabled: true }) + ).toBeTruthy(); + }); + }); + + describe('selected', () => { + test('returns a selected element when required', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('tab', { selected: true })).toBeTruthy(); + expect(queryByRole('tab', { selected: false })).toBe(null); + }); + + test('returns the correct element when only one matches all the requirements', () => { + const { getByRole } = render( + <> + + Save + + + Save + + + ); + + expect( + getByRole('tab', { name: 'Save', selected: true }).props.testID + ).toBe('correct'); + }); + + test('returns an implicitly non selected element', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('tab', { selected: false })).toBeTruthy(); + expect(queryByRole('tab', { selected: true })).toBe(null); + }); + + test('returns an explicitly non selected element', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('tab', { selected: false })).toBeTruthy(); + expect(queryByRole('tab', { selected: true })).toBe(null); + }); + + test('does not return selected elements when querying for non selected', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('tab', { selected: false })).toBe(null); + }); + }); + + describe('checked', () => { + test('returns a checked element when required', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('checkbox', { checked: true })).toBeTruthy(); + expect(queryByRole('checkbox', { checked: false })).toBe(null); + expect(queryByRole('checkbox', { checked: 'mixed' })).toBe(null); + }); + + it('returns `mixed` checkboxes', () => { + const { queryByRole, getByRole } = render( + + ); + + expect(getByRole('checkbox', { checked: 'mixed' })).toBeTruthy(); + expect(queryByRole('checkbox', { checked: true })).toBe(null); + expect(queryByRole('checkbox', { checked: false })).toBe(null); + }); + + it('does not return mixed checkboxes when querying for checked: true', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('checkbox', { checked: false })).toBe(null); + }); + + test('returns the correct element when only one matches all the requirements', () => { + const { getByRole } = render( + <> + + Save + + + Save + + + ); + + expect( + getByRole('checkbox', { name: 'Save', checked: true }).props.testID + ).toBe('correct'); + }); + + test('does not return return as non checked an element with checked: undefined', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('checkbox', { checked: false })).toBe(null); + }); + + test('returns an explicitly non checked element', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('checkbox', { checked: false })).toBeTruthy(); + expect(queryByRole('checkbox', { checked: true })).toBe(null); + }); + + test('does not return checked elements when querying for non checked', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('checkbox', { checked: false })).toBe(null); + }); + + test('does not return mixed elements when querying for non checked', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('checkbox', { checked: false })).toBe(null); + }); + }); + + describe('busy', () => { + test('returns a busy element when required', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('button', { busy: true })).toBeTruthy(); + expect(queryByRole('button', { busy: false })).toBe(null); + }); + + test('returns the correct element when only one matches all the requirements', () => { + const { getByRole } = render( + <> + + Save + + + Save + + + ); + + expect( + getByRole('button', { name: 'Save', busy: true }).props.testID + ).toBe('correct'); + }); + + test('returns an implicitly non busy element', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('button', { busy: false })).toBeTruthy(); + expect(queryByRole('button', { busy: true })).toBe(null); + }); + + test('returns an explicitly non busy element', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('button', { busy: false })).toBeTruthy(); + expect(queryByRole('button', { busy: true })).toBe(null); + }); + + test('does not return busy elements when querying for non busy', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('button', { selected: false })).toBe(null); + }); + }); + + describe('expanded', () => { + test('returns a expanded element when required', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('button', { expanded: true })).toBeTruthy(); + expect(queryByRole('button', { expanded: false })).toBe(null); + }); + + test('returns the correct element when only one matches all the requirements', () => { + const { getByRole } = render( + <> + + Save + + + Save + + + ); + + expect( + getByRole('button', { name: 'Save', expanded: true }).props.testID + ).toBe('correct'); + }); + + test('does not return return as non expanded an element with expanded: undefined', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('button', { expanded: false })).toBe(null); + }); + + test('returns an explicitly non expanded element', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('button', { expanded: false })).toBeTruthy(); + expect(queryByRole('button', { expanded: true })).toBe(null); + }); + + test('does not return expanded elements when querying for non expanded', () => { + const { queryByRole } = render( + + ); + + expect(queryByRole('button', { expanded: false })).toBe(null); + }); + }); + + test('ignores non queried accessibilityState', () => { + const { getByRole, queryByRole } = render( + + Save + + ); + + expect( + getByRole('button', { + name: 'Save', + disabled: true, + }) + ).toBeTruthy(); + expect( + queryByRole('button', { + name: 'Save', + disabled: false, + }) + ).toBe(null); + }); + + test('matches an element combining all the options', () => { + const { getByRole } = render( + + Save + + ); + + expect( + getByRole('button', { + name: 'Save', + disabled: true, + selected: true, + checked: true, + busy: true, + expanded: true, + }) + ).toBeTruthy(); + }); +}); + +describe('error messages', () => { + test('gives a descriptive error message when querying with a role', () => { + const { getByRole } = render(); + + expect(() => getByRole('button')).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with role: "button""` + ); + }); + + test('gives a descriptive error message when querying with a role and a name', () => { + const { getByRole } = render(); + + expect(() => + getByRole('button', { name: 'Save' }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with role: "button", name: "Save""` + ); + }); + + test('gives a descriptive error message when querying with a role, a name and accessibility state', () => { + const { getByRole } = render(); + + expect(() => + getByRole('button', { name: 'Save', disabled: true }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with role: "button", name: "Save", disabled state: true"` + ); + }); + + test('gives a descriptive error message when querying with a role, a name and several accessibility state', () => { + const { getByRole } = render(); + + expect(() => + getByRole('button', { name: 'Save', disabled: true, selected: true }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with role: "button", name: "Save", disabled state: true, selected state: true"` + ); + }); + + test('gives a descriptive error message when querying with a role and an accessibility state', () => { + const { getByRole } = render(); + + expect(() => + getByRole('button', { disabled: true }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with role: "button", disabled state: true"` + ); + }); +}); diff --git a/src/queries/makeQueries.ts b/src/queries/makeQueries.ts index 68fff5299..5e02bcb88 100644 --- a/src/queries/makeQueries.ts +++ b/src/queries/makeQueries.ts @@ -48,15 +48,15 @@ export type UnboundQueries = { export function makeQueries( queryAllByQuery: UnboundQuery>, - getMissingError: (predicate: Predicate) => string, - getMultipleError: (predicate: Predicate) => string + getMissingError: (predicate: Predicate, options?: Options) => string, + getMultipleError: (predicate: Predicate, options?: Options) => string ): UnboundQueries { function getAllByQuery(instance: ReactTestInstance) { return function getAllFn(predicate: Predicate, options?: Options) { const results = queryAllByQuery(instance)(predicate, options); if (results.length === 0) { - throw new ErrorWithStack(getMissingError(predicate), getAllFn); + throw new ErrorWithStack(getMissingError(predicate, options), getAllFn); } return results; @@ -68,7 +68,10 @@ export function makeQueries( const results = queryAllByQuery(instance)(predicate, options); if (results.length > 1) { - throw new ErrorWithStack(getMultipleError(predicate), singleQueryFn); + throw new ErrorWithStack( + getMultipleError(predicate, options), + singleQueryFn + ); } if (results.length === 0) { @@ -84,11 +87,11 @@ export function makeQueries( const results = queryAllByQuery(instance)(predicate, options); if (results.length > 1) { - throw new ErrorWithStack(getMultipleError(predicate), getFn); + throw new ErrorWithStack(getMultipleError(predicate, options), getFn); } if (results.length === 0) { - throw new ErrorWithStack(getMissingError(predicate), getFn); + throw new ErrorWithStack(getMissingError(predicate, options), getFn); } return results[0]; diff --git a/src/queries/role.ts b/src/queries/role.ts index 02d3aa223..cb77a22aa 100644 --- a/src/queries/role.ts +++ b/src/queries/role.ts @@ -1,3 +1,4 @@ +import { type AccessibilityState } from 'react-native'; import type { ReactTestInstance } from 'react-test-renderer'; import { matchStringProp } from '../helpers/matchers/matchStringProp'; import { TextMatch } from '../matches'; @@ -14,7 +15,9 @@ import type { type ByRoleOptions = { name?: TextMatch; -}; +} & AccessibilityState; + +type AccessibilityStateKey = keyof AccessibilityState; const matchAccessibleNameIfNeeded = ( node: ReactTestInstance, @@ -28,22 +31,85 @@ const matchAccessibleNameIfNeeded = ( ); }; +// disabled:undefined is equivalent to disabled:false, same for selected. busy not, but it makes +// sense from a testing/voice-over perspective. checked and expanded do behave differently +const implicityFalseState: AccessibilityStateKey[] = [ + 'disabled', + 'selected', + 'busy', +]; + +const matchAccessibleStateIfNeeded = ( + node: ReactTestInstance, + options?: ByRoleOptions +) => + accessibilityStates.every((accessibilityState) => { + const queriedState = options?.[accessibilityState]; + + if (typeof queriedState !== 'undefined') { + // Some accessibilityState properties have implicit value (when not set) + const defaultState = implicityFalseState.includes(accessibilityState) + ? false + : undefined; + return ( + queriedState === + (node.props.accessibilityState?.[accessibilityState] ?? defaultState) + ); + } else { + return true; + } + }); + +const accessibilityStates: AccessibilityStateKey[] = [ + 'disabled', + 'selected', + 'checked', + 'busy', + 'expanded', +]; + const queryAllByRole = ( instance: ReactTestInstance ): ((role: TextMatch, options?: ByRoleOptions) => Array) => function queryAllByRoleFn(role, options) { return instance.findAll( (node) => + // run the cheapest checks first, and early exit too avoid unneeded computations + typeof node.type === 'string' && matchStringProp(node.props.accessibilityRole, role) && + matchAccessibleStateIfNeeded(node, options) && matchAccessibleNameIfNeeded(node, options?.name) ); }; -const getMultipleError = (role: TextMatch) => - `Found multiple elements with accessibilityRole: ${String(role)} `; -const getMissingError = (role: TextMatch) => - `Unable to find an element with accessibilityRole: ${String(role)}`; +const buildErrorMessage = (role: TextMatch, options: ByRoleOptions = {}) => { + const errors = [`role: "${String(role)}"`]; + + if (options.name) { + errors.push(`name: "${String(options.name)}"`); + } + + if ( + accessibilityStates.some( + (accessibilityState) => typeof options[accessibilityState] !== 'undefined' + ) + ) { + accessibilityStates.forEach((accessibilityState) => { + if (options[accessibilityState]) { + errors.push( + `${accessibilityState} state: ${options[accessibilityState]}` + ); + } + }); + } + + return errors.join(', '); +}; +const getMultipleError = (role: TextMatch, options?: ByRoleOptions) => + `Found multiple elements with ${buildErrorMessage(role, options)}`; +const getMissingError = (role: TextMatch, options?: ByRoleOptions) => + `Unable to find an element with ${buildErrorMessage(role, options)}`; const { getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy } = makeQueries( queryAllByRole, diff --git a/typings/index.flow.js b/typings/index.flow.js index 2c82adfdc..2c8c35710 100644 --- a/typings/index.flow.js +++ b/typings/index.flow.js @@ -203,9 +203,10 @@ interface UnsafeByPropsQueries { | []; } -interface ByRoleOptions { - name?: string; -} +type ByRoleOptions = { + ...A11yState, + name?: string, +}; interface A11yAPI { // Label diff --git a/website/docs/Queries.md b/website/docs/Queries.md index 215f5d606..cc9c4b2a6 100644 --- a/website/docs/Queries.md +++ b/website/docs/Queries.md @@ -238,6 +238,11 @@ getByRole( role: TextMatch, option?: { name?: TextMatch + disabled?: boolean, + selected?: boolean, + checked?: boolean | 'mixed', + busy?: boolean, + expanded?: boolean, } ): ReactTestInstance; ``` @@ -255,6 +260,16 @@ const element = screen.getByRole('button'); `name`: Finds an element with given `accessibilityRole` and an accessible name (equivalent to `byText` or `byLabelText` query). +`disabled`: You can filter elements by their disabled state. The possible values are `true` or `false`. Querying `disabled: false` will also match elements with `disabled: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `disabled` state. + +`selected`: You can filter elements by their selected state. The possible values are `true` or `false`. Querying `selected: false` will also match elements with `selected: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `selected` state. + +`checked`: You can filter elements by their checked state. The possible values are `true`, `false`, or `"mixed"`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `checked` state. + +`busy`: You can filter elements by their busy state. The possible values are `true` or `false`. Querying `busy: false` will also match elements with `busy: undefined` (see the [wiki](https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State) for more details). See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `busy` state. + +`expanded`: You can filter elements by their expanded state. The possible values are `true` or `false`. See [React Native's accessibilityState](https://reactnative.dev/docs/accessibility#accessibilitystate) docs to learn more about the `expanded` state. + ### `ByA11yState`, `ByAccessibilityState` > getByA11yState, getAllByA11yState, queryByA11yState, queryAllByA11yState, findByA11yState, findAllByA11yState