Skip to content

Commit

Permalink
feat: toHaveAccessibilityState() (#124)
Browse files Browse the repository at this point in the history
  • Loading branch information
mdjastrzebski authored Nov 10, 2022
1 parent fae097a commit f050c41
Show file tree
Hide file tree
Showing 5 changed files with 230 additions and 3 deletions.
67 changes: 65 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
- [`toHaveTextContent`](#tohavetextcontent)
- [`toHaveStyle`](#tohavestyle)
- [`toBeVisible`](#tobevisible)
- [`toHaveAccessibilityState`](#tohaveaccessibilitystate)
- [Inspiration](#inspiration)
- [Other solutions](#other-solutions)
- [Contributors](#contributors)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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(<View testID="view" accessibilityState={{ expanded: true, checked: true }} />);

// 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(<View testID="view" />);

// 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
Expand Down
4 changes: 3 additions & 1 deletion extend-expect.d.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,6 +16,8 @@ declare global {
/** @deprecated This function has been renamed to `toBeEmptyElement`. */
toBeEmpty(): R;
toBeVisible(): R;

toHaveAccessibilityState(state: AccessibilityState): R;
}
}
}
89 changes: 89 additions & 0 deletions src/__tests__/to-have-accessibility-state.tsx
Original file line number Diff line number Diff line change
@@ -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(
<View>
<View testID="disabled" accessibilityState={{ disabled: true }} />
<View testID="selected" accessibilityState={{ selected: true }} />
<View testID="busy" accessibilityState={{ busy: true }} />
<View testID="checked-true" accessibilityState={{ checked: true }} />
<View testID="checked-mixed" accessibilityState={{ checked: 'mixed' }} />
<View testID="checked-false" accessibilityState={{ checked: false }} />
<View testID="expanded-true" accessibilityState={{ expanded: true }} />
<View testID="expanded-false" accessibilityState={{ expanded: false }} />

<View testID="disabled-selected" accessibilityState={{ disabled: true, selected: true }} />
</View>,
);

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(<View testID="subject" />);

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 });
});
2 changes: 2 additions & 0 deletions src/extend-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,4 +17,5 @@ expect.extend({
toHaveStyle,
toHaveTextContent,
toBeVisible,
toHaveAccessibilityState,
});
71 changes: 71 additions & 0 deletions src/to-have-accessibility-state.ts
Original file line number Diff line number Diff line change
@@ -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];
}

0 comments on commit f050c41

Please sign in to comment.