From bd5ddd7bc415f11039fdbf43dfbe25a7ad82d1f4 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Fri, 21 Oct 2022 11:06:57 +0200 Subject: [PATCH] fix: *ByA11yState default value false value for busy, disabled & selected state (#1166) --- src/helpers/accessiblity.ts | 12 +- src/helpers/matchers/accessibilityState.ts | 36 +++++ src/queries/__tests__/a11yState.test.tsx | 162 +++++++++++++++++++-- src/queries/a11yState.ts | 27 +++- src/queries/role.ts | 62 ++------ website/docs/Queries.md | 26 ++++ 6 files changed, 252 insertions(+), 73 deletions(-) create mode 100644 src/helpers/matchers/accessibilityState.ts diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts index a4dc1885f..0cccb7b6b 100644 --- a/src/helpers/accessiblity.ts +++ b/src/helpers/accessiblity.ts @@ -1,7 +1,17 @@ -import { StyleSheet } from 'react-native'; +import { AccessibilityState, StyleSheet } from 'react-native'; import { ReactTestInstance } from 'react-test-renderer'; import { getHostSiblings } from './component-tree'; +export type AccessibilityStateKey = keyof AccessibilityState; + +export const accessibilityStateKeys: AccessibilityStateKey[] = [ + 'disabled', + 'selected', + 'checked', + 'busy', + 'expanded', +]; + export function isInaccessible(element: ReactTestInstance | null): boolean { if (element == null) { return true; diff --git a/src/helpers/matchers/accessibilityState.ts b/src/helpers/matchers/accessibilityState.ts new file mode 100644 index 000000000..b068353e4 --- /dev/null +++ b/src/helpers/matchers/accessibilityState.ts @@ -0,0 +1,36 @@ +import { AccessibilityState } from 'react-native'; +import { ReactTestInstance } from 'react-test-renderer'; +import { accessibilityStateKeys } from '../accessiblity'; + +/** + * 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, + checked: undefined, + busy: false, + expanded: undefined, +}; + +export function matchAccessibilityState( + node: ReactTestInstance, + matcher: AccessibilityState +) { + const state = node.props.accessibilityState; + return accessibilityStateKeys.every((key) => matchState(state, matcher, key)); +} + +function matchState( + state: AccessibilityState, + matcher: AccessibilityState, + key: keyof AccessibilityState +) { + return ( + matcher[key] === undefined || + matcher[key] === (state?.[key] ?? defaultState[key]) + ); +} diff --git a/src/queries/__tests__/a11yState.test.tsx b/src/queries/__tests__/a11yState.test.tsx index c15c078b4..c97ad70cb 100644 --- a/src/queries/__tests__/a11yState.test.tsx +++ b/src/queries/__tests__/a11yState.test.tsx @@ -1,17 +1,9 @@ import * as React from 'react'; -import { TouchableOpacity, Text } from 'react-native'; +import { View, Text, Pressable, TouchableOpacity } from 'react-native'; import { render } from '../..'; const TEXT_LABEL = 'cool text'; -const getMultipleInstancesFoundMessage = (value: string) => { - return `Found multiple elements with accessibilityState: ${value}`; -}; - -const getNoInstancesFoundMessage = (value: string) => { - return `Unable to find an element with accessibilityState: ${value}`; -}; - const Typography = ({ children, ...rest }: any) => { return {children}; }; @@ -48,15 +40,15 @@ test('getByA11yState, queryByA11yState, findByA11yState', async () => { }); expect(() => getByA11yState({ disabled: true })).toThrow( - getNoInstancesFoundMessage('{"disabled":true}') + 'Unable to find an element with disabled state: true' ); expect(queryByA11yState({ disabled: true })).toEqual(null); expect(() => getByA11yState({ expanded: false })).toThrow( - getMultipleInstancesFoundMessage('{"expanded":false}') + 'Found multiple elements with expanded state: false' ); expect(() => queryByA11yState({ expanded: false })).toThrow( - getMultipleInstancesFoundMessage('{"expanded":false}') + 'Found multiple elements with expanded state: false' ); const asyncButton = await findByA11yState({ selected: true }); @@ -65,10 +57,10 @@ test('getByA11yState, queryByA11yState, findByA11yState', async () => { expanded: false, }); await expect(findByA11yState({ disabled: true })).rejects.toThrow( - getNoInstancesFoundMessage('{"disabled":true}') + 'Unable to find an element with disabled state: true' ); await expect(findByA11yState({ expanded: false })).rejects.toThrow( - getMultipleInstancesFoundMessage('{"expanded":false}') + 'Found multiple elements with expanded state: false' ); }); @@ -81,7 +73,7 @@ test('getAllByA11yState, queryAllByA11yState, findAllByA11yState', async () => { expect(queryAllByA11yState({ selected: true })).toHaveLength(1); expect(() => getAllByA11yState({ disabled: true })).toThrow( - getNoInstancesFoundMessage('{"disabled":true}') + 'Unable to find an element with disabled state: true' ); expect(queryAllByA11yState({ disabled: true })).toEqual([]); @@ -90,9 +82,147 @@ test('getAllByA11yState, queryAllByA11yState, findAllByA11yState', async () => { await expect(findAllByA11yState({ selected: true })).resolves.toHaveLength(1); await expect(findAllByA11yState({ disabled: true })).rejects.toThrow( - getNoInstancesFoundMessage('{"disabled":true}') + 'Unable to find an element with disabled state: true' ); await expect(findAllByA11yState({ expanded: false })).resolves.toHaveLength( 2 ); }); + +describe('checked state matching', () => { + it('handles true', () => { + const view = render(); + + expect(view.getByA11yState({ checked: true })).toBeTruthy(); + expect(view.queryByA11yState({ checked: 'mixed' })).toBeFalsy(); + expect(view.queryByA11yState({ checked: false })).toBeFalsy(); + }); + + it('handles mixed', () => { + const view = render(); + + expect(view.getByA11yState({ checked: 'mixed' })).toBeTruthy(); + expect(view.queryByA11yState({ checked: true })).toBeFalsy(); + expect(view.queryByA11yState({ checked: false })).toBeFalsy(); + }); + + it('handles false', () => { + const view = render(); + + expect(view.getByA11yState({ checked: false })).toBeTruthy(); + expect(view.queryByA11yState({ checked: true })).toBeFalsy(); + expect(view.queryByA11yState({ checked: 'mixed' })).toBeFalsy(); + }); + + it('handles default', () => { + const view = render(); + + expect(view.queryByA11yState({ checked: false })).toBeFalsy(); + expect(view.queryByA11yState({ checked: true })).toBeFalsy(); + expect(view.queryByA11yState({ checked: 'mixed' })).toBeFalsy(); + }); +}); + +describe('expanded state matching', () => { + it('handles true', () => { + const view = render(); + + expect(view.getByA11yState({ expanded: true })).toBeTruthy(); + expect(view.queryByA11yState({ expanded: false })).toBeFalsy(); + }); + + it('handles false', () => { + const view = render(); + + expect(view.getByA11yState({ expanded: false })).toBeTruthy(); + expect(view.queryByA11yState({ expanded: true })).toBeFalsy(); + }); + + it('handles default', () => { + const view = render(); + + expect(view.queryByA11yState({ expanded: false })).toBeFalsy(); + expect(view.queryByA11yState({ expanded: true })).toBeFalsy(); + }); +}); + +describe('disabled state matching', () => { + it('handles true', () => { + const view = render(); + + expect(view.getByA11yState({ disabled: true })).toBeTruthy(); + expect(view.queryByA11yState({ disabled: false })).toBeFalsy(); + }); + + it('handles false', () => { + const view = render(); + + expect(view.getByA11yState({ disabled: false })).toBeTruthy(); + expect(view.queryByA11yState({ disabled: true })).toBeFalsy(); + }); + + it('handles default', () => { + const view = render(); + + expect(view.getByA11yState({ disabled: false })).toBeTruthy(); + expect(view.queryByA11yState({ disabled: true })).toBeFalsy(); + }); +}); + +describe('busy state matching', () => { + it('handles true', () => { + const view = render(); + + expect(view.getByA11yState({ busy: true })).toBeTruthy(); + expect(view.queryByA11yState({ busy: false })).toBeFalsy(); + }); + + it('handles false', () => { + const view = render(); + + expect(view.getByA11yState({ busy: false })).toBeTruthy(); + expect(view.queryByA11yState({ busy: true })).toBeFalsy(); + }); + + it('handles default', () => { + const view = render(); + + expect(view.getByA11yState({ busy: false })).toBeTruthy(); + expect(view.queryByA11yState({ busy: true })).toBeFalsy(); + }); +}); + +describe('selected state matching', () => { + it('handles true', () => { + const view = render(); + + expect(view.getByA11yState({ selected: true })).toBeTruthy(); + expect(view.queryByA11yState({ selected: false })).toBeFalsy(); + }); + + it('handles false', () => { + const view = render(); + + expect(view.getByA11yState({ selected: false })).toBeTruthy(); + expect(view.queryByA11yState({ selected: true })).toBeFalsy(); + }); + + it('handles default', () => { + const view = render(); + + expect(view.getByA11yState({ selected: false })).toBeTruthy(); + expect(view.queryByA11yState({ selected: true })).toBeFalsy(); + }); +}); + +test('*ByA11yState on Pressable with "disabled" prop', () => { + const view = render(); + expect(view.getByA11yState({ disabled: true })).toBeTruthy(); + expect(view.queryByA11yState({ disabled: false })).toBeFalsy(); +}); + +test('*ByA11yState on TouchableOpacity with "disabled" prop', () => { + const view = render(); + expect(view.getByA11yState({ disabled: true })).toBeTruthy(); + expect(view.queryByA11yState({ disabled: false })).toBeFalsy(); +}); diff --git a/src/queries/a11yState.ts b/src/queries/a11yState.ts index 4d740367d..7bbb415f7 100644 --- a/src/queries/a11yState.ts +++ b/src/queries/a11yState.ts @@ -1,6 +1,7 @@ import type { ReactTestInstance } from 'react-test-renderer'; import type { AccessibilityState } from 'react-native'; -import { matchObjectProp } from '../helpers/matchers/matchObjectProp'; +import { accessibilityStateKeys } from '../helpers/accessiblity'; +import { matchAccessibilityState } from '../helpers/matchers/accessibilityState'; import { makeQueries } from './makeQueries'; import type { FindAllByQuery, @@ -13,19 +14,31 @@ import type { const queryAllByA11yState = ( instance: ReactTestInstance -): ((state: AccessibilityState) => Array) => - function queryAllByA11yStateFn(state) { +): ((matcher: AccessibilityState) => Array) => + function queryAllByA11yStateFn(matcher) { return instance.findAll( (node) => - typeof node.type === 'string' && - matchObjectProp(node.props.accessibilityState, state) + typeof node.type === 'string' && matchAccessibilityState(node, matcher) ); }; +const buildErrorMessage = (state: AccessibilityState = {}) => { + const errors: string[] = []; + + accessibilityStateKeys.forEach((stateKey) => { + if (state[stateKey] !== undefined) { + errors.push(`${stateKey} state: ${state[stateKey]}`); + } + }); + + return errors.join(', '); +}; + const getMultipleError = (state: AccessibilityState) => - `Found multiple elements with accessibilityState: ${JSON.stringify(state)}`; + `Found multiple elements with ${buildErrorMessage(state)}`; + const getMissingError = (state: AccessibilityState) => - `Unable to find an element with accessibilityState: ${JSON.stringify(state)}`; + `Unable to find an element with ${buildErrorMessage(state)}`; const { getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy } = makeQueries( queryAllByA11yState, diff --git a/src/queries/role.ts b/src/queries/role.ts index cb77a22aa..00d42b5e0 100644 --- a/src/queries/role.ts +++ b/src/queries/role.ts @@ -1,7 +1,9 @@ import { type AccessibilityState } from 'react-native'; import type { ReactTestInstance } from 'react-test-renderer'; +import { accessibilityStateKeys } from '../helpers/accessiblity'; +import { matchAccessibilityState } from '../helpers/matchers/accessibilityState'; import { matchStringProp } from '../helpers/matchers/matchStringProp'; -import { TextMatch } from '../matches'; +import type { TextMatch } from '../matches'; import { getQueriesForElement } from '../within'; import { makeQueries } from './makeQueries'; import type { @@ -17,8 +19,6 @@ type ByRoleOptions = { name?: TextMatch; } & AccessibilityState; -type AccessibilityStateKey = keyof AccessibilityState; - const matchAccessibleNameIfNeeded = ( node: ReactTestInstance, name?: TextMatch @@ -31,42 +31,12 @@ 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', -]; +) => { + return options != null ? matchAccessibilityState(node, options) : true; +}; const queryAllByRole = ( instance: ReactTestInstance @@ -90,24 +60,18 @@ const buildErrorMessage = (role: TextMatch, options: ByRoleOptions = {}) => { 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]}` - ); - } - }); - } + accessibilityStateKeys.forEach((stateKey) => { + if (options[stateKey] !== undefined) { + errors.push(`${stateKey} state: ${options[stateKey]}`); + } + }); 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)}`; diff --git a/website/docs/Queries.md b/website/docs/Queries.md index cc9c4b2a6..2b39059af 100644 --- a/website/docs/Queries.md +++ b/website/docs/Queries.md @@ -23,6 +23,8 @@ title: Queries - [`ByRole`](#byrole) - [Options](#options-1) - [`ByA11yState`, `ByAccessibilityState`](#bya11ystate-byaccessibilitystate) + - [Default state for: `disabled`, `selected`, and `busy` keys](#default-state-for-disabled-selected-and-busy-keys) + - [Default state for: `checked` and `expanded` keys](#default-state-for-checked-and-expanded-keys) - [`ByA11Value`, `ByAccessibilityValue`](#bya11value-byaccessibilityvalue) - [TextMatch](#textmatch) - [Examples](#examples) @@ -296,6 +298,30 @@ render(); const element = screen.getByA11yState({ disabled: true }); ``` +:::note + +#### Default state for: `disabled`, `selected`, and `busy` keys + +Passing `false` matcher value will match both elements with explicit `false` state value and without explicit state value. + +For instance, `getByA11yState({ disabled: false })` will match elements with following props: +* `accessibilityState={{ disabled: false, ... }}` +* no `disabled` key under `accessibilityState` prop, e.g. `accessibilityState={{}}` +* no `accessibilityState` prop at all + +#### Default state for: `checked` and `expanded` keys +Passing `false` matcher value will only match elements with explicit `false` state value. + +For instance, `getByA11yState({ checked: false })` will only match elements with: +* `accessibilityState={{ checked: false, ... }}` + +but will not match elements with following props: +* no `checked` key under `accessibilityState` prop, e.g. `accessibilityState={{}}` +* no `accessibilityState` prop at all + +The difference in handling default values is made to reflect observed accessibility behaviour on iOS and Android platforms. +::: + ### `ByA11Value`, `ByAccessibilityValue` > getByA11yValue, getAllByA11yValue, queryByA11yValue, queryAllByA11yValue, findByA11yValue, findAllByA11yValue