Skip to content

Commit

Permalink
feat: toHaveAccessibleName matcher (#1509)
Browse files Browse the repository at this point in the history
* feat: toHaveAccessibleName matcher

* feat: toHaveAccessibleName matcher

* feat: toHaveAccessibleName matcher

* refactor: check logic

* chore: fix lint

* refactor: tests

* chore: test tweaks

---------

Co-authored-by: Maciej Jastrzębski <[email protected]>
  • Loading branch information
anishamalde and mdjastrzebski authored Nov 8, 2023
1 parent b019479 commit 2ada536
Show file tree
Hide file tree
Showing 7 changed files with 214 additions and 2 deletions.
23 changes: 22 additions & 1 deletion src/helpers/accessiblity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
StyleSheet,
} from 'react-native';
import { ReactTestInstance } from 'react-test-renderer';
import { getHostSiblings } from './component-tree';
import { getTextContent } from './text-content';
import { getHostSiblings, getUnsafeRootElement } from './component-tree';
import { getHostComponentNames } from './host-component-names';

type IsInaccessibleOptions = {
Expand Down Expand Up @@ -233,3 +234,23 @@ export function isElementSelected(
const { accessibilityState, 'aria-selected': ariaSelected } = element.props;
return ariaSelected ?? accessibilityState?.selected ?? false;
}

export function getAccessibleName(
element: ReactTestInstance
): string | undefined {
const label = getAccessibilityLabel(element);
if (label) {
return label;
}

const labelElementId = getAccessibilityLabelledBy(element);
if (labelElementId) {
const rootElement = getUnsafeRootElement(element);
const labelElement = rootElement?.findByProps({ nativeID: labelElementId });
if (labelElement) {
return getTextContent(labelElement);
}
}

return getTextContent(element);
}
134 changes: 134 additions & 0 deletions src/matchers/__tests__/to-have-accessible-name.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import * as React from 'react';
import { View, Text, TextInput } from 'react-native';
import { render, screen } from '../..';
import '../extend-expect';

test('toHaveAccessibleName() handles view with "accessibilityLabel" prop', () => {
render(<View testID="view" accessibilityLabel="Test label" />);
const element = screen.getByTestId('view');
expect(element).toHaveAccessibleName('Test label');
expect(element).not.toHaveAccessibleName('Other label');
});

test('toHaveAccessibleName() handles view with "aria-label" prop', () => {
render(<View testID="view" aria-label="Test label" />);
const element = screen.getByTestId('view');
expect(element).toHaveAccessibleName('Test label');
expect(element).not.toHaveAccessibleName('Other label');
});

test('toHaveAccessibleName() handles view with "accessibilityLabelledBy" prop', async () => {
render(
<View>
<Text nativeID="label">External label</Text>
<TextInput testID="input" accessibilityLabelledBy="label" />
</View>
);

const element = screen.getByTestId('input');
expect(element).toHaveAccessibleName('External label');
expect(element).not.toHaveAccessibleName('Other label');
});

test('toHaveAccessibleName() handles nested "accessibilityLabelledBy"', async () => {
render(
<>
<View nativeID="label">
<Text>External label</Text>
</View>
<TextInput testID="input" accessibilityLabelledBy="label" />
</>
);

const element = screen.getByTestId('input');
expect(element).toHaveAccessibleName('External label');
expect(element).not.toHaveAccessibleName('Other label');
});

test('toHaveAccessibleName() handles view with nested "accessibilityLabelledBy" with no text', async () => {
render(
<>
<View nativeID="label">
<View />
</View>
<TextInput testID="text-input" accessibilityLabelledBy="label" />
</>
);

const element = screen.getByTestId('text-input');
expect(element).not.toHaveAccessibleName();
});

test('toHaveAccessibleName() handles view with "aria-labelledby" prop', async () => {
render(
<View>
<Text nativeID="label">External label</Text>
<TextInput testID="input" aria-labelledby="label" />
</View>
);

const element = screen.getByTestId('input');
expect(element).toHaveAccessibleName('External label');
expect(element).not.toHaveAccessibleName('Other label');
});

test('toHaveAccessibleName() handles view with implicit accessible name', () => {
render(<Text testID="view">Text</Text>);
const element = screen.getByTestId('view');
expect(element).toHaveAccessibleName('Text');
expect(element).not.toHaveAccessibleName('Other text');
});

test('toHaveAccessibleName() supports calling without expected name', () => {
render(<View testID="view" accessibilityLabel="Test label" />);
const element = screen.getByTestId('view');

expect(element).toHaveAccessibleName();
expect(() => expect(element).not.toHaveAccessibleName())
.toThrowErrorMatchingInlineSnapshot(`
"expect(element).not.toHaveAccessibleName()
Expected element not to have accessible name:
undefined
Received:
Test label"
`);
});

test('toHaveAccessibleName() handles a view without name when called without expected name', () => {
render(<View testID="view" />);
const element = screen.getByTestId('view');

expect(element).not.toHaveAccessibleName();
expect(() => expect(element).toHaveAccessibleName())
.toThrowErrorMatchingInlineSnapshot(`
"expect(element).toHaveAccessibleName()
Expected element to have accessible name:
undefined
Received:
"
`);
});

it('toHaveAccessibleName() rejects non-host element', () => {
const nonElement = 'This is not a ReactTestInstance';

expect(() => expect(nonElement).toHaveAccessibleName())
.toThrowErrorMatchingInlineSnapshot(`
"expect(received).toHaveAccessibleName()
received value must be a host element.
Received has type: string
Received has value: "This is not a ReactTestInstance""
`);

expect(() => expect(nonElement).not.toHaveAccessibleName())
.toThrowErrorMatchingInlineSnapshot(`
"expect(received).not.toHaveAccessibleName()
received value must be a host element.
Received has type: string
Received has value: "This is not a ReactTestInstance""
`);
});
1 change: 1 addition & 0 deletions src/matchers/extend-expect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface JestNativeMatchers<R> {
toBeVisible(): R;
toContainElement(element: ReactTestInstance | null): R;
toHaveAccessibilityValue(expectedValue: AccessibilityValueMatcher): R;
toHaveAccessibleName(expectedName?: TextMatch, options?: TextMatchOptions): R;
toHaveDisplayValue(expectedValue: TextMatch, options?: TextMatchOptions): R;
toHaveProp(name: string, expectedValue?: unknown): R;
toHaveStyle(style: StyleProp<Style>): R;
Expand Down
2 changes: 2 additions & 0 deletions src/matchers/extend-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { toBeSelected } from './to-be-selected';
import { toBeVisible } from './to-be-visible';
import { toContainElement } from './to-contain-element';
import { toHaveAccessibilityValue } from './to-have-accessibility-value';
import { toHaveAccessibleName } from './to-have-accessible-name';
import { toHaveDisplayValue } from './to-have-display-value';
import { toHaveProp } from './to-have-prop';
import { toHaveStyle } from './to-have-style';
Expand All @@ -31,6 +32,7 @@ expect.extend({
toBeVisible,
toContainElement,
toHaveAccessibilityValue,
toHaveAccessibleName,
toHaveDisplayValue,
toHaveProp,
toHaveStyle,
Expand Down
1 change: 1 addition & 0 deletions src/matchers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export { toBeSelected } from './to-be-selected';
export { toBeVisible } from './to-be-visible';
export { toContainElement } from './to-contain-element';
export { toHaveAccessibilityValue } from './to-have-accessibility-value';
export { toHaveAccessibleName } from './to-have-accessible-name';
export { toHaveDisplayValue } from './to-have-display-value';
export { toHaveProp } from './to-have-prop';
export { toHaveStyle } from './to-have-style';
Expand Down
53 changes: 53 additions & 0 deletions src/matchers/to-have-accessible-name.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ReactTestInstance } from 'react-test-renderer';
import { matcherHint } from 'jest-matcher-utils';
import { getAccessibleName } from '../helpers/accessiblity';
import { TextMatch, TextMatchOptions, matches } from '../matches';
import { checkHostElement, formatMessage } from './utils';

export function toHaveAccessibleName(
this: jest.MatcherContext,
element: ReactTestInstance,
expectedName?: TextMatch,
options?: TextMatchOptions
) {
checkHostElement(element, toHaveAccessibleName, this);

const receivedName = getAccessibleName(element);
const missingExpectedValue = arguments.length === 1;

let pass = false;
if (missingExpectedValue) {
pass = receivedName !== '';
} else {
pass =
expectedName != null
? matches(
expectedName,
receivedName,
options?.normalizer,
options?.exact
)
: false;
}

return {
pass,
message: () => {
return [
formatMessage(
matcherHint(
`${this.isNot ? '.not' : ''}.toHaveAccessibleName`,
'element',
''
),
`Expected element ${
this.isNot ? 'not to' : 'to'
} have accessible name`,
expectedName,
'Received',
receivedName
),
].join('\n');
},
};
}
2 changes: 1 addition & 1 deletion src/matchers/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export function formatElementArray(elements: ReactTestInstance[]) {
export function formatMessage(
matcher: string,
expectedLabel: string,
expectedValue: string | RegExp,
expectedValue: string | RegExp | null | undefined,
receivedLabel: string,
receivedValue: string | null | undefined
) {
Expand Down

0 comments on commit 2ada536

Please sign in to comment.