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: toHaveAccessibleName matcher #1509

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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