Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: toHaveAccessibilityState() #124

Merged
merged 14 commits into from
Nov 10, 2022
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.
mdjastrzebski marked this conversation as resolved.
Show resolved Hide resolved

#### 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 });
Comment on lines +493 to +494
Copy link

@MattAgn MattAgn Nov 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these 2 last examples confused me for a bit because we also have

expect(screen.getByTestId('view')).not.toHaveAccessibilityState({ checked: true });
expect(screen.getByTestId('view')).not.toHaveAccessibilityState({ expanded: true });

did you check for false here to contrast with the other example below?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. I will add additional docs to explain it. Basically for checked & expanded having false value is considered something different than not having value. While for disabled, selected and busy not having value equal to having false value.

Part of value proposition of this matcher is to encapsulate that behaviour.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've rewritten the docs, pls check if under current docs that makes more sense.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much clearer to me :)

```

## 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];
mdjastrzebski marked this conversation as resolved.
Show resolved Hide resolved
}