diff --git a/src/helpers/__tests__/accessiblity.test.tsx b/src/helpers/__tests__/accessiblity.test.tsx index a58c96658..2405be1dd 100644 --- a/src/helpers/__tests__/accessiblity.test.tsx +++ b/src/helpers/__tests__/accessiblity.test.tsx @@ -22,7 +22,7 @@ test('returns false for accessible elements', () => { ).toBe(false); }); -test('returns true for hidden elements', () => { +test('returns true for null elements', () => { expect(isHiddenFromAccessibility(null)).toBe(true); }); diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts index 15c6ef4e2..8d74db91a 100644 --- a/src/helpers/accessiblity.ts +++ b/src/helpers/accessiblity.ts @@ -1,4 +1,8 @@ -import { AccessibilityState, StyleSheet } from 'react-native'; +import { + AccessibilityState, + AccessibilityValue, + StyleSheet, +} from 'react-native'; import { ReactTestInstance } from 'react-test-renderer'; import { getHostSiblings } from './component-tree'; @@ -6,9 +10,7 @@ type IsInaccessibleOptions = { cache?: WeakMap; }; -export type AccessibilityStateKey = keyof AccessibilityState; - -export const accessibilityStateKeys: AccessibilityStateKey[] = [ +export const accessibilityStateKeys: (keyof AccessibilityState)[] = [ 'disabled', 'selected', 'checked', @@ -16,6 +18,13 @@ export const accessibilityStateKeys: AccessibilityStateKey[] = [ 'expanded', ]; +export const accessiblityValueKeys: (keyof AccessibilityValue)[] = [ + 'min', + 'max', + 'now', + 'text', +]; + export function isHiddenFromAccessibility( element: ReactTestInstance | null, { cache }: IsInaccessibleOptions = {} diff --git a/src/helpers/matchers/accessibilityValue.ts b/src/helpers/matchers/accessibilityValue.ts new file mode 100644 index 000000000..ef72c1cdb --- /dev/null +++ b/src/helpers/matchers/accessibilityValue.ts @@ -0,0 +1,24 @@ +import { AccessibilityValue } from 'react-native'; +import { ReactTestInstance } from 'react-test-renderer'; +import { TextMatch } from '../../matches'; +import { matchStringProp } from './matchStringProp'; + +export interface AccessibilityValueMatcher { + min?: number; + max?: number; + now?: number; + text?: TextMatch; +} + +export function matchAccessibilityValue( + node: ReactTestInstance, + matcher: AccessibilityValueMatcher +): boolean { + const value: AccessibilityValue = node.props.accessibilityValue ?? {}; + return ( + (matcher.min === undefined || matcher.min === value.min) && + (matcher.max === undefined || matcher.max === value.max) && + (matcher.now === undefined || matcher.now === value.now) && + (matcher.text === undefined || matchStringProp(value.text, matcher.text)) + ); +} diff --git a/src/queries/__tests__/a11yValue.test.tsx b/src/queries/__tests__/a11yValue.test.tsx index dc15c9383..d77b9aa4e 100644 --- a/src/queries/__tests__/a11yValue.test.tsx +++ b/src/queries/__tests__/a11yValue.test.tsx @@ -1,17 +1,9 @@ import * as React from 'react'; -import { TouchableOpacity, Text } from 'react-native'; +import { View, Text, TouchableOpacity } from 'react-native'; import { render } from '../..'; const TEXT_LABEL = 'cool text'; -const getMultipleInstancesFoundMessage = (value: string) => { - return `Found multiple elements with accessibilityValue: ${value}`; -}; - -const getNoInstancesFoundMessage = (value: string) => { - return `Unable to find an element with accessibilityValue: ${value}`; -}; - const Typography = ({ children, ...rest }: any) => { return {children}; }; @@ -46,15 +38,15 @@ test('getByA11yValue, queryByA11yValue, findByA11yValue', async () => { }); expect(() => getByA11yValue({ min: 50 })).toThrow( - getNoInstancesFoundMessage('{"min":50}') + 'Unable to find an element with min value: 50' ); expect(queryByA11yValue({ min: 50 })).toEqual(null); expect(() => getByA11yValue({ max: 60 })).toThrow( - getMultipleInstancesFoundMessage('{"max":60}') + 'Found multiple elements with max value: 60' ); expect(() => queryByA11yValue({ max: 60 })).toThrow( - getMultipleInstancesFoundMessage('{"max":60}') + 'Found multiple elements with max value: 60' ); const asyncElement = await findByA11yValue({ min: 40 }); @@ -63,10 +55,10 @@ test('getByA11yValue, queryByA11yValue, findByA11yValue', async () => { max: 60, }); await expect(findByA11yValue({ min: 50 })).rejects.toThrow( - getNoInstancesFoundMessage('{"min":50}') + 'Unable to find an element with min value: 50' ); await expect(findByA11yValue({ max: 60 })).rejects.toThrow( - getMultipleInstancesFoundMessage('{"max":60}') + 'Found multiple elements with max value: 60' ); }); @@ -79,7 +71,7 @@ test('getAllByA11yValue, queryAllByA11yValue, findAllByA11yValue', async () => { expect(queryAllByA11yValue({ min: 40 })).toHaveLength(1); expect(() => getAllByA11yValue({ min: 50 })).toThrow( - getNoInstancesFoundMessage('{"min":50}') + 'Unable to find an element with min value: 50' ); expect(queryAllByA11yValue({ min: 50 })).toEqual([]); @@ -88,7 +80,7 @@ test('getAllByA11yValue, queryAllByA11yValue, findAllByA11yValue', async () => { await expect(findAllByA11yValue({ min: 40 })).resolves.toHaveLength(1); await expect(findAllByA11yValue({ min: 50 })).rejects.toThrow( - getNoInstancesFoundMessage('{"min":50}') + 'Unable to find an element with min value: 50' ); await expect(findAllByA11yValue({ max: 60 })).resolves.toHaveLength(2); }); @@ -111,6 +103,30 @@ test('byA11yValue queries support hidden option', () => { expect(() => getByA11yValue({ max: 10 }, { includeHiddenElements: false }) ).toThrowErrorMatchingInlineSnapshot( - `"Unable to find an element with accessibilityValue: {"max":10}"` + `"Unable to find an element with max value: 10"` + ); +}); + +test('byA11yValue error messages', () => { + const { getByA11yValue } = render(); + expect(() => + getByA11yValue({ min: 10, max: 10 }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with min value: 10, max value: 10"` + ); + expect(() => + getByA11yValue({ max: 20, now: 5 }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with max value: 20, now value: 5"` + ); + expect(() => + getByA11yValue({ min: 1, max: 2, now: 3 }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with min value: 1, max value: 2, now value: 3"` + ); + expect(() => + getByA11yValue({ min: 1, max: 2, now: 3, text: /foo/i }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with min value: 1, max value: 2, now value: 3, text value: /foo/i"` ); }); diff --git a/src/queries/__tests__/role-value.test.tsx b/src/queries/__tests__/role-value.test.tsx new file mode 100644 index 000000000..e9b03e9c8 --- /dev/null +++ b/src/queries/__tests__/role-value.test.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import { View, Text } from 'react-native'; +import { render } from '../..'; + +describe('accessibility value', () => { + test('matches using all value props', () => { + const { getByRole, queryByRole } = render( + + ); + + expect( + getByRole('adjustable', { + value: { min: 0, max: 100, now: 50, text: '50%' }, + }) + ).toBeTruthy(); + expect( + queryByRole('adjustable', { + value: { min: 1, max: 100, now: 50, text: '50%' }, + }) + ).toBeFalsy(); + expect( + queryByRole('adjustable', { + value: { min: 0, max: 99, now: 50, text: '50%' }, + }) + ).toBeFalsy(); + expect( + queryByRole('adjustable', { + value: { min: 0, max: 100, now: 45, text: '50%' }, + }) + ).toBeFalsy(); + expect( + queryByRole('adjustable', { + value: { min: 0, max: 100, now: 50, text: '55%' }, + }) + ).toBeFalsy(); + }); + + test('matches using single value', () => { + const { getByRole, queryByRole } = render( + + ); + + expect(getByRole('adjustable', { value: { min: 10 } })).toBeTruthy(); + expect(getByRole('adjustable', { value: { max: 20 } })).toBeTruthy(); + expect(getByRole('adjustable', { value: { now: 12 } })).toBeTruthy(); + expect(getByRole('adjustable', { value: { text: 'Hello' } })).toBeTruthy(); + expect(getByRole('adjustable', { value: { text: /hello/i } })).toBeTruthy(); + + expect(queryByRole('adjustable', { value: { min: 11 } })).toBeFalsy(); + expect(queryByRole('adjustable', { value: { max: 19 } })).toBeFalsy(); + expect(queryByRole('adjustable', { value: { now: 15 } })).toBeFalsy(); + expect(queryByRole('adjustable', { value: { text: 'No' } })).toBeFalsy(); + expect(queryByRole('adjustable', { value: { text: /no/ } })).toBeFalsy(); + }); + + test('matches using single value and other options', () => { + const { getByRole } = render( + + Hello + + ); + + expect( + getByRole('adjustable', { name: 'Hello', value: { min: 10 } }) + ).toBeTruthy(); + expect( + getByRole('adjustable', { disabled: true, value: { min: 10 } }) + ).toBeTruthy(); + + expect(() => + getByRole('adjustable', { name: 'Hello', value: { min: 5 } }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with role: "adjustable", name: "Hello", min value: 5"` + ); + expect(() => + getByRole('adjustable', { name: 'World', value: { min: 10 } }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with role: "adjustable", name: "World", min value: 10"` + ); + expect(() => + getByRole('adjustable', { name: 'Hello', value: { min: 5 } }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with role: "adjustable", name: "Hello", min value: 5"` + ); + expect(() => + getByRole('adjustable', { selected: true, value: { min: 10 } }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with role: "adjustable", selected state: true, min value: 10"` + ); + }); +}); diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx index 211b0637d..e93dde7d8 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -696,6 +696,24 @@ describe('error messages', () => { `"Unable to find an element with role: "button", disabled state: true"` ); }); + + test('gives a descriptive error message when querying with a role and an accessibility value', () => { + const { getByRole } = render(); + + expect(() => + getByRole('adjustable', { value: { min: 1 } }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with role: "adjustable", min value: 1"` + ); + + expect(() => + getByRole('adjustable', { + value: { min: 1, max: 2, now: 1, text: /hello/ }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Unable to find an element with role: "adjustable", min value: 1, max value: 2, now value: 1, text value: /hello/"` + ); + }); }); test('byRole queries support hidden option', () => { diff --git a/src/queries/a11yValue.ts b/src/queries/a11yValue.ts index 0c1f680b6..a7273094a 100644 --- a/src/queries/a11yValue.ts +++ b/src/queries/a11yValue.ts @@ -1,6 +1,10 @@ import type { ReactTestInstance } from 'react-test-renderer'; +import { accessiblityValueKeys } from '../helpers/accessiblity'; import { findAll } from '../helpers/findAll'; -import { matchObjectProp } from '../helpers/matchers/matchObjectProp'; +import { + AccessibilityValueMatcher, + matchAccessibilityValue, +} from '../helpers/matchers/accessibilityValue'; import { makeQueries } from './makeQueries'; import type { FindAllByQuery, @@ -12,33 +16,38 @@ import type { } from './makeQueries'; import { CommonQueryOptions } from './options'; -type A11yValue = { - min?: number; - max?: number; - now?: number; - text?: string; -}; - const queryAllByA11yValue = ( instance: ReactTestInstance ): (( - value: A11yValue, + value: AccessibilityValueMatcher, queryOptions?: CommonQueryOptions ) => Array) => function queryAllByA11yValueFn(value, queryOptions) { return findAll( instance, (node) => - typeof node.type === 'string' && - matchObjectProp(node.props.accessibilityValue, value), + typeof node.type === 'string' && matchAccessibilityValue(node, value), queryOptions ); }; -const getMultipleError = (value: A11yValue) => - `Found multiple elements with accessibilityValue: ${JSON.stringify(value)} `; -const getMissingError = (value: A11yValue) => - `Unable to find an element with accessibilityValue: ${JSON.stringify(value)}`; +const formatQueryParams = (matcher: AccessibilityValueMatcher) => { + const params: string[] = []; + + accessiblityValueKeys.forEach((valueKey) => { + if (matcher[valueKey] !== undefined) { + params.push(`${valueKey} value: ${matcher[valueKey]}`); + } + }); + + return params.join(', '); +}; + +const getMultipleError = (matcher: AccessibilityValueMatcher) => + `Found multiple elements with ${formatQueryParams(matcher)}`; + +const getMissingError = (matcher: AccessibilityValueMatcher) => + `Unable to find an element with ${formatQueryParams(matcher)}`; const { getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy } = makeQueries( queryAllByA11yValue, @@ -47,19 +56,46 @@ const { getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy } = makeQueries( ); export type ByA11yValueQueries = { - getByA11yValue: GetByQuery; - getAllByA11yValue: GetAllByQuery; - queryByA11yValue: QueryByQuery; - queryAllByA11yValue: QueryAllByQuery; - findByA11yValue: FindByQuery; - findAllByA11yValue: FindAllByQuery; + getByA11yValue: GetByQuery; + getAllByA11yValue: GetAllByQuery< + AccessibilityValueMatcher, + CommonQueryOptions + >; + queryByA11yValue: QueryByQuery; + queryAllByA11yValue: QueryAllByQuery< + AccessibilityValueMatcher, + CommonQueryOptions + >; + findByA11yValue: FindByQuery; + findAllByA11yValue: FindAllByQuery< + AccessibilityValueMatcher, + CommonQueryOptions + >; - getByAccessibilityValue: GetByQuery; - getAllByAccessibilityValue: GetAllByQuery; - queryByAccessibilityValue: QueryByQuery; - queryAllByAccessibilityValue: QueryAllByQuery; - findByAccessibilityValue: FindByQuery; - findAllByAccessibilityValue: FindAllByQuery; + getByAccessibilityValue: GetByQuery< + AccessibilityValueMatcher, + CommonQueryOptions + >; + getAllByAccessibilityValue: GetAllByQuery< + AccessibilityValueMatcher, + CommonQueryOptions + >; + queryByAccessibilityValue: QueryByQuery< + AccessibilityValueMatcher, + CommonQueryOptions + >; + queryAllByAccessibilityValue: QueryAllByQuery< + AccessibilityValueMatcher, + CommonQueryOptions + >; + findByAccessibilityValue: FindByQuery< + AccessibilityValueMatcher, + CommonQueryOptions + >; + findAllByAccessibilityValue: FindAllByQuery< + AccessibilityValueMatcher, + CommonQueryOptions + >; }; export const bindByA11yValueQueries = ( diff --git a/src/queries/role.ts b/src/queries/role.ts index be13d474d..641855b92 100644 --- a/src/queries/role.ts +++ b/src/queries/role.ts @@ -1,8 +1,15 @@ -import { type AccessibilityState } from 'react-native'; +import type { AccessibilityState } from 'react-native'; import type { ReactTestInstance } from 'react-test-renderer'; -import { accessibilityStateKeys } from '../helpers/accessiblity'; +import { + accessibilityStateKeys, + accessiblityValueKeys, +} from '../helpers/accessiblity'; import { findAll } from '../helpers/findAll'; import { matchAccessibilityState } from '../helpers/matchers/accessibilityState'; +import { + AccessibilityValueMatcher, + matchAccessibilityValue, +} from '../helpers/matchers/accessibilityValue'; import { matchStringProp } from '../helpers/matchers/matchStringProp'; import type { TextMatch } from '../matches'; import { getQueriesForElement } from '../within'; @@ -20,6 +27,7 @@ import { CommonQueryOptions } from './options'; type ByRoleOptions = CommonQueryOptions & AccessibilityState & { name?: TextMatch; + value?: AccessibilityValueMatcher; }; const matchAccessibleNameIfNeeded = ( @@ -41,6 +49,13 @@ const matchAccessibleStateIfNeeded = ( return options != null ? matchAccessibilityState(node, options) : true; }; +const matchAccessibilityValueIfNeeded = ( + node: ReactTestInstance, + value?: AccessibilityValueMatcher +) => { + return value != null ? matchAccessibilityValue(node, value) : true; +}; + const queryAllByRole = ( instance: ReactTestInstance ): ((role: TextMatch, options?: ByRoleOptions) => Array) => @@ -49,36 +64,42 @@ const queryAllByRole = ( instance, (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) && + matchAccessibilityValueIfNeeded(node, options?.value) && matchAccessibleNameIfNeeded(node, options?.name), options ); }; -const buildErrorMessage = (role: TextMatch, options: ByRoleOptions = {}) => { - const errors = [`role: "${String(role)}"`]; +const formatQueryParams = (role: TextMatch, options: ByRoleOptions = {}) => { + const params = [`role: "${String(role)}"`]; if (options.name) { - errors.push(`name: "${String(options.name)}"`); + params.push(`name: "${String(options.name)}"`); } accessibilityStateKeys.forEach((stateKey) => { if (options[stateKey] !== undefined) { - errors.push(`${stateKey} state: ${options[stateKey]}`); + params.push(`${stateKey} state: ${options[stateKey]}`); + } + }); + + accessiblityValueKeys.forEach((valueKey) => { + if (options?.value?.[valueKey] !== undefined) { + params.push(`${valueKey} value: ${options?.value?.[valueKey]}`); } }); - return errors.join(', '); + return params.join(', '); }; const getMultipleError = (role: TextMatch, options?: ByRoleOptions) => - `Found multiple elements with ${buildErrorMessage(role, options)}`; + `Found multiple elements with ${formatQueryParams(role, options)}`; const getMissingError = (role: TextMatch, options?: ByRoleOptions) => - `Unable to find an element with ${buildErrorMessage(role, options)}`; + `Unable to find an element with ${formatQueryParams(role, options)}`; const { getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy } = makeQueries( queryAllByRole, diff --git a/typings/index.flow.js b/typings/index.flow.js index 1afeddc0f..2bbf303c7 100644 --- a/typings/index.flow.js +++ b/typings/index.flow.js @@ -64,7 +64,7 @@ declare type A11yValue = { min?: number, max?: number, now?: number, - text?: string, + text?: TextMatch, }; type WaitForOptions = { @@ -224,6 +224,7 @@ interface UnsafeByPropsQueries { type ByRoleOptions = CommonQueryOptions & { ...A11yState, name?: string, + value?: A11yValue, }; type ByLabelTextOptions = CommonQueryOptions & TextMatchOptions; diff --git a/website/docs/Queries.md b/website/docs/Queries.md index 7e8636424..4e6bf3fcd 100644 --- a/website/docs/Queries.md +++ b/website/docs/Queries.md @@ -261,6 +261,12 @@ getByRole( checked?: boolean | 'mixed', busy?: boolean, expanded?: boolean, + value: { + min?: number; + max?: number; + now?: number; + text?: TextMatch; + }, hidden?: boolean; } ): ReactTestInstance; @@ -271,8 +277,14 @@ Returns a `ReactTestInstance` with matching `accessibilityRole` prop. ```jsx import { render, screen } from '@testing-library/react-native'; -render(); +render( + + Hello + +); const element = screen.getByRole('button'); +const element2 = screen.getByRole('button', { name: "Hello" }); +const element3 = screen.getByRole('button', { name: "Hello", disabled: true }); ``` #### Options @@ -289,6 +301,8 @@ const element = screen.getByRole('button'); `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. +`value`: Filter elements by their accessibility, available value entries include numeric `min`, `max` & `now`, as well as string or regex `text` key. See React Native [accessibilityValue](https://reactnative.dev/docs/accessibility#accessibilityvalue) docs to learn more about this prop. + ### `ByA11yState`, `ByAccessibilityState` > getByA11yState, getAllByA11yState, queryByA11yState, queryAllByA11yState, findByA11yState, findAllByA11yState @@ -357,7 +371,7 @@ getByA11yValue( min?: number; max?: number; now?: number; - text?: string; + text?: TextMatch; }, options?: { hidden?: boolean; @@ -365,13 +379,16 @@ getByA11yValue( ): ReactTestInstance; ``` -Returns a `ReactTestInstance` with matching `accessibilityValue` prop. +Returns a host element with matching `accessibilityValue` prop entries. Only entires provided to the query will be used to match elements. Element might have additional accessibility value entries and still be matched. + +When querying by `text` entry a string or regex might be used. ```jsx import { render, screen } from '@testing-library/react-native'; -render(); -const element = screen.getByA11yValue({ min: 40 }); +render(); +const element = screen.getByA11yValue({ now: 25 }); +const element2 = screen.getByA11yValue({ text: /25/ }); ``` ## Common options