Skip to content

fix: *ByRole queries to match their own text text content #1139

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

Merged
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/__tests__/host-text-nesting.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as React from 'react';
import { Text, Pressable, View } from 'react-native';
import { render, within } from '../pure';

/**
* Our queries interact differently with composite and host elements, and some specific cases require us
* to crawl up the tree to a Text composite element to be able to traverse it down again. Going up the tree
* is a dangerous behaviour because we could take the risk of then traversing a sibling node to the original one.
* This test suite is designed to be able to test as many different combinations, as a safety net.
* Specific cases should still be tested within the relevant file (for instance an edge case with `within` should have
* an explicit test in the within test suite)
*/
describe('nested text handling', () => {
test('within same node', () => {
const view = render(<Text testID="subject">Hello</Text>);
expect(within(view.getByTestId('subject')).getByText('Hello')).toBeTruthy();
});

test('role with direct text children', () => {
const view = render(<Text accessibilityRole="header">About</Text>);

expect(view.getByRole('header', { name: 'About' })).toBeTruthy();
});

test('nested text with child with role', () => {
const view = render(
<Text>
<Text testID="child" accessibilityRole="header">
About
</Text>
</Text>
);

expect(view.getByRole('header', { name: 'About' }).props.testID).toBe(
'child'
);
});

test('pressable within View, with text child', () => {
const view = render(
<View>
<Pressable testID="pressable" accessibilityRole="button">
<Text>Save</Text>
</Pressable>
</View>
);

expect(view.getByRole('button', { name: 'Save' }).props.testID).toBe(
'pressable'
);
});

test('pressable within View, with text child within view', () => {
const view = render(
<View>
<Pressable testID="pressable" accessibilityRole="button">
<View>
<Text>Save</Text>
</View>
</Pressable>
</View>
);

expect(view.getByRole('button', { name: 'Save' }).props.testID).toBe(
'pressable'
);
});

test('Text within pressable', () => {
const view = render(
<Pressable testID="pressable" accessibilityRole="button">
<Text testID="text">Save</Text>
</Pressable>
);

expect(view.getByText('Save').props.testID).toBe('text');
});

test('Text within view within pressable', () => {
const view = render(
<Pressable testID="pressable" accessibilityRole="button">
<View>
<Text testID="text">Save</Text>
</View>
</Pressable>
);

expect(view.getByText('Save').props.testID).toBe('text');
});
});
8 changes: 4 additions & 4 deletions src/__tests__/jest-native.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ test('jest-native matchers work correctly', () => {
expect(getByText('Disabled Button')).toBeDisabled();
expect(getByText('Enabled Button')).not.toBeDisabled();

expect(getByA11yHint('Empty Text')).toBeEmpty();
expect(getByA11yHint('Empty View')).toBeEmpty();
expect(getByA11yHint('Not Empty Text')).not.toBeEmpty();
expect(getByA11yHint('Not Empty View')).not.toBeEmpty();
expect(getByA11yHint('Empty Text')).toBeEmptyElement();
expect(getByA11yHint('Empty View')).toBeEmptyElement();
expect(getByA11yHint('Not Empty Text')).not.toBeEmptyElement();
expect(getByA11yHint('Not Empty View')).not.toBeEmptyElement();

expect(getByA11yHint('Container View')).toContainElement(
// $FlowFixMe - TODO: fix @testing-library/jest-native flow typings
Expand Down
8 changes: 8 additions & 0 deletions src/__tests__/within.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,11 @@ test('within() exposes a11y queries', async () => {
test('getQueriesForElement is alias to within', () => {
expect(getQueriesForElement).toBe(within);
});

test('within allows searching for text within a composite component', () => {
const view = render(<Text testID="subject">Hello</Text>);
// view.getByTestId('subject') returns a host component, contrary to text queries returning a composite component
// we want to be sure that this doesn't interfere with the way text is searched
const hostTextQueries = within(view.getByTestId('subject'));
expect(hostTextQueries.getByText('Hello')).toBeTruthy();
});
2 changes: 1 addition & 1 deletion src/fireEvent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ReactTestInstance } from 'react-test-renderer';
import { TextInput } from 'react-native';
import act from './act';
import { isHostElement } from './helpers/component-tree';
import { filterNodeByType } from './helpers/filterNodeByType';
Expand All @@ -10,7 +11,6 @@ const isTextInput = (element?: ReactTestInstance) => {
return false;
}

const { TextInput } = require('react-native');
// We have to test if the element type is either the TextInput component
// (which would if it is a composite component) or the string
// TextInput (which would be true if it is a host component)
Expand Down
37 changes: 37 additions & 0 deletions src/helpers/__tests__/component-tree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
getHostParent,
getHostSelves,
getHostSiblings,
getCompositeParentOfType,
isHostElementForType,
} from '../component-tree';

function MultipleHostChildren() {
Expand Down Expand Up @@ -200,3 +202,38 @@ test('returns host siblings for composite component', () => {
view.getByTestId('siblingAfter'),
]);
});

test('getCompositeParentOfType', () => {
const root = render(
<View testID="view">
<Text testID="text" />
</View>
);
const hostView = root.getByTestId('view');
const hostText = root.getByTestId('text');

const compositeView = getCompositeParentOfType(hostView, View);
// We get the corresponding composite component (same testID), but not the host
expect(compositeView?.type).toBe(View);
expect(compositeView?.props.testID).toBe('view');
const compositeText = getCompositeParentOfType(hostText, Text);
expect(compositeText?.type).toBe(Text);
expect(compositeText?.props.testID).toBe('text');

// Checks parent type
expect(getCompositeParentOfType(hostText, View)).toBeNull();
expect(getCompositeParentOfType(hostView, Text)).toBeNull();

// Ignores itself, stops if ancestor is host
expect(getCompositeParentOfType(compositeText!, Text)).toBeNull();
expect(getCompositeParentOfType(compositeView!, View)).toBeNull();
});

test('isHostElementForType', () => {
const view = render(<View testID="test" />);
const hostComponent = view.getByTestId('test');
const compositeComponent = getCompositeParentOfType(hostComponent, View);
expect(isHostElementForType(hostComponent, View)).toBe(true);
expect(isHostElementForType(hostComponent, Text)).toBe(false);
expect(isHostElementForType(compositeComponent!, View)).toBe(false);
});
34 changes: 34 additions & 0 deletions src/helpers/component-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,37 @@ export function getHostSiblings(
(sibling) => !hostSelves.includes(sibling)
);
}

export function getCompositeParentOfType(
element: ReactTestInstance,
type: React.ComponentType
) {
let current = element.parent;

while (!isHostElement(current)) {
// We're at the root of the tree
if (!current) {
return null;
}

if (current.type === type) {
return current;
}
current = current.parent;
}

return null;
}

/**
* Note: this function should be generally used for core React Native types like `View`, `Text`, `TextInput`, etc.
*/
export function isHostElementForType(
element: ReactTestInstance,
type: React.ComponentType
) {
// Not a host element
if (!isHostElement(element)) return false;

return getCompositeParentOfType(element, type) !== null;
}
46 changes: 46 additions & 0 deletions src/queries/__tests__/role.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,50 @@ describe('supports name option', () => {
'target-button'
);
});

test('returns an element when the direct child is text', () => {
const { getByRole, getByTestId } = render(
<Text accessibilityRole="header" testID="target-header">
About
</Text>
);

// assert on the testId to be sure that the returned element is the one with the accessibilityRole
expect(getByRole('header', { name: 'About' })).toBe(
getByTestId('target-header')
);
expect(getByRole('header', { name: 'About' }).props.testID).toBe(
'target-header'
);
});

test('returns an element with nested Text as children', () => {
const { getByRole, getByTestId } = render(
<Text accessibilityRole="header" testID="parent">
<Text testID="child">About</Text>
</Text>
);

// assert on the testId to be sure that the returned element is the one with the accessibilityRole
expect(getByRole('header', { name: 'About' })).toBe(getByTestId('parent'));
expect(getByRole('header', { name: 'About' }).props.testID).toBe('parent');
});

test('returns a header with an accessibilityLabel', () => {
const { getByRole, getByTestId } = render(
<Text
accessibilityRole="header"
testID="target-header"
accessibilityLabel="About"
/>
);

// assert on the testId to be sure that the returned element is the one with the accessibilityRole
expect(getByRole('header', { name: 'About' })).toBe(
getByTestId('target-header')
);
expect(getByRole('header', { name: 'About' }).props.testID).toBe(
'target-header'
);
});
});
14 changes: 13 additions & 1 deletion src/queries/__tests__/text.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
Button,
TextInput,
} from 'react-native';
import { render, getDefaultNormalizer } from '../..';
import { render, getDefaultNormalizer, within } from '../..';

type MyButtonProps = {
children: React.ReactNode;
Expand Down Expand Up @@ -454,3 +454,15 @@ test('getByText and queryByText work with tabs', () => {
expect(getByText(textWithTabs)).toBeTruthy();
expect(queryByText(textWithTabs)).toBeTruthy();
});

test('getByText searches for text within itself', () => {
const { getByText } = render(<Text testID="subject">Hello</Text>);
const textNode = within(getByText('Hello'));
expect(textNode.getByText('Hello')).toBeTruthy();
});

test('getByText searches for text within self host element', () => {
const { getByTestId } = render(<Text testID="subject">Hello</Text>);
const textNode = within(getByTestId('subject'));
expect(textNode.getByText('Hello')).toBeTruthy();
});
2 changes: 1 addition & 1 deletion src/queries/a11yState.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactTestInstance } from 'react-test-renderer';
import { AccessibilityState } from 'react-native';
import type { AccessibilityState } from 'react-native';
import { matchObjectProp } from '../helpers/matchers/matchObjectProp';
import { makeQueries } from './makeQueries';
import type {
Expand Down
23 changes: 8 additions & 15 deletions src/queries/displayValue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactTestInstance } from 'react-test-renderer';
import { createLibraryNotSupportedError } from '../helpers/errors';
import { TextInput } from 'react-native';
import { filterNodeByType } from '../helpers/filterNodeByType';
import { matches, TextMatch } from '../matches';
import { makeQueries } from './makeQueries';
Expand All @@ -18,20 +18,13 @@ const getTextInputNodeByDisplayValue = (
value: TextMatch,
options: TextMatchOptions = {}
) => {
try {
const { TextInput } = require('react-native');
const { exact, normalizer } = options;
const nodeValue =
node.props.value !== undefined
? node.props.value
: node.props.defaultValue;
return (
filterNodeByType(node, TextInput) &&
matches(value, nodeValue, normalizer, exact)
);
} catch (error) {
throw createLibraryNotSupportedError(error);
}
const { exact, normalizer } = options;
const nodeValue =
node.props.value !== undefined ? node.props.value : node.props.defaultValue;
return (
filterNodeByType(node, TextInput) &&
matches(value, nodeValue, normalizer, exact)
);
};

const queryAllByDisplayValue = (
Expand Down
17 changes: 6 additions & 11 deletions src/queries/placeholderText.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactTestInstance } from 'react-test-renderer';
import { createLibraryNotSupportedError } from '../helpers/errors';
import { TextInput } from 'react-native';
import { filterNodeByType } from '../helpers/filterNodeByType';
import { matches, TextMatch } from '../matches';
import { makeQueries } from './makeQueries';
Expand All @@ -18,16 +18,11 @@ const getTextInputNodeByPlaceholderText = (
placeholder: TextMatch,
options: TextMatchOptions = {}
) => {
try {
const { TextInput } = require('react-native');
const { exact, normalizer } = options;
return (
filterNodeByType(node, TextInput) &&
matches(placeholder, node.props.placeholder, normalizer, exact)
);
} catch (error) {
throw createLibraryNotSupportedError(error);
}
const { exact, normalizer } = options;
return (
filterNodeByType(node, TextInput) &&
matches(placeholder, node.props.placeholder, normalizer, exact)
);
};

const queryAllByPlaceholderText = (
Expand Down
Loading