Skip to content

Commit d65e9f2

Browse files
authored
fix: .toBeVisible error with Pressable function style (#134)
1 parent 5883ed7 commit d65e9f2

File tree

5 files changed

+139
-18
lines changed

5 files changed

+139
-18
lines changed

src/__tests__/component-tree.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react';
2+
import { View } from 'react-native';
3+
import { render } from '@testing-library/react-native';
4+
import { getParentElement } from '../component-tree';
5+
6+
function MultipleHostChildren() {
7+
return (
8+
<>
9+
<View testID="child1" />
10+
<View testID="child2" />
11+
<View testID="child3" />
12+
</>
13+
);
14+
}
15+
16+
describe('getParentElement()', () => {
17+
it('returns host parent for host component', () => {
18+
const view = render(
19+
<View testID="grandparent">
20+
<View testID="parent">
21+
<View testID="subject" />
22+
<View testID="sibling" />
23+
</View>
24+
</View>,
25+
);
26+
27+
const hostParent = getParentElement(view.getByTestId('subject'));
28+
expect(hostParent).toBe(view.getByTestId('parent'));
29+
30+
const hostGrandparent = getParentElement(hostParent);
31+
expect(hostGrandparent).toBe(view.getByTestId('grandparent'));
32+
33+
expect(getParentElement(hostGrandparent)).toBe(null);
34+
});
35+
36+
it('returns host parent for null', () => {
37+
expect(getParentElement(null)).toBe(null);
38+
});
39+
40+
it('returns host parent for composite component', () => {
41+
const view = render(
42+
<View testID="parent">
43+
<MultipleHostChildren />
44+
<View testID="subject" />
45+
</View>,
46+
);
47+
48+
const compositeComponent = view.UNSAFE_getByType(MultipleHostChildren);
49+
const hostParent = getParentElement(compositeComponent);
50+
expect(hostParent).toBe(view.getByTestId('parent'));
51+
});
52+
});

src/__tests__/to-be-visible.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { Modal, View } from 'react-native';
2+
import { View, Pressable, Modal } from 'react-native';
33
import { render } from '@testing-library/react-native';
44

55
describe('.toBeVisible', () => {
@@ -120,16 +120,32 @@ describe('.toBeVisible', () => {
120120
expect(getByTestId('test')).not.toBeVisible();
121121
});
122122

123-
it('handles non-React elements', () => {
123+
test('handles null elements', () => {
124+
expect(() => expect(null).toBeVisible()).toThrowErrorMatchingInlineSnapshot(`
125+
"expect(received).toBeVisible()
126+
127+
received value must be a React Element.
128+
Received has value: null"
129+
`);
130+
});
131+
132+
test('handles non-React elements', () => {
124133
expect(() => expect({ name: 'Non-React element' }).not.toBeVisible()).toThrow();
125134
expect(() => expect(true).not.toBeVisible()).toThrow();
126135
});
127136

128-
it('throws an error when expectation is not matched', () => {
137+
test('throws an error when expectation is not matched', () => {
129138
const { getByTestId, update } = render(<View testID="test" />);
130139
expect(() => expect(getByTestId('test')).not.toBeVisible()).toThrowErrorMatchingSnapshot();
131140

132141
update(<View testID="test" style={{ opacity: 0 }} />);
133142
expect(() => expect(getByTestId('test')).toBeVisible()).toThrowErrorMatchingSnapshot();
134143
});
144+
145+
test('handles Pressable with function style prop', () => {
146+
const { getByTestId } = render(
147+
<Pressable testID="test" style={() => ({ backgroundColor: 'blue' })} />,
148+
);
149+
expect(getByTestId('test')).toBeVisible();
150+
});
135151
});

src/__tests__/to-have-style.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { StyleSheet, View, Text } from 'react-native';
2+
import { StyleSheet, View, Text, Pressable } from 'react-native';
33
import { render } from '@testing-library/react-native';
44

55
describe('.toHaveStyle', () => {
@@ -90,4 +90,11 @@ describe('.toHaveStyle', () => {
9090
expect(container).toHaveStyle({ transform: [{ scale: 1 }] }),
9191
).toThrowErrorMatchingSnapshot();
9292
});
93+
94+
test('handles Pressable with function style prop', () => {
95+
const { getByTestId } = render(
96+
<Pressable testID="test" style={() => ({ backgroundColor: 'blue' })} />,
97+
);
98+
expect(getByTestId('test')).toHaveStyle({ backgroundColor: 'blue' });
99+
});
93100
});

src/component-tree.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type React from 'react';
2+
import type { ReactTestInstance } from 'react-test-renderer';
3+
4+
/**
5+
* Checks if the given element is a host element.
6+
* @param element The element to check.
7+
*/
8+
export function isHostElement(element?: ReactTestInstance | null): boolean {
9+
return typeof element?.type === 'string';
10+
}
11+
12+
/**
13+
* Returns first host ancestor for given element or first ancestor of one of
14+
* passed component types.
15+
*
16+
* @param element The element start traversing from.
17+
* @param componentTypes Additional component types to match.
18+
*/
19+
export function getParentElement(
20+
element: ReactTestInstance | null,
21+
componentTypes: React.ElementType[] = [],
22+
): ReactTestInstance | null {
23+
if (element == null) {
24+
return null;
25+
}
26+
27+
let current = element.parent;
28+
while (current) {
29+
if (isHostElement(current) || componentTypes.includes(current.type)) {
30+
return current;
31+
}
32+
33+
current = current.parent;
34+
}
35+
36+
return null;
37+
}

src/to-be-visible.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,40 @@ import { matcherHint } from 'jest-matcher-utils';
33
import type { ReactTestInstance } from 'react-test-renderer';
44

55
import { checkReactElement, printElement } from './utils';
6+
import { getParentElement } from './component-tree';
67

7-
function isStyleVisible(element: ReactTestInstance) {
8+
function isVisibleForStyles(element: ReactTestInstance) {
89
const style = element.props.style || {};
910
const { display, opacity } = StyleSheet.flatten(style);
1011
return display !== 'none' && opacity !== 0;
1112
}
1213

13-
function isAttributeVisible(element: ReactTestInstance) {
14-
return element.type !== Modal || element.props.visible !== false;
14+
function isVisibleForAccessibility(element: ReactTestInstance) {
15+
return (
16+
!element.props.accessibilityElementsHidden &&
17+
element.props.importantForAccessibility !== 'no-hide-descendants'
18+
);
1519
}
1620

17-
function isVisibleForAccessibility(element: ReactTestInstance) {
18-
const visibleForiOSVoiceOver = !element.props.accessibilityElementsHidden;
19-
const visibleForAndroidTalkBack =
20-
element.props.importantForAccessibility !== 'no-hide-descendants';
21-
return visibleForiOSVoiceOver && visibleForAndroidTalkBack;
21+
function isModalVisible(element: ReactTestInstance) {
22+
return element.type !== Modal || element.props.visible !== false;
2223
}
2324

2425
function isElementVisible(element: ReactTestInstance): boolean {
25-
return (
26-
isStyleVisible(element) &&
27-
isAttributeVisible(element) &&
28-
isVisibleForAccessibility(element) &&
29-
(!element.parent || isElementVisible(element.parent))
30-
);
26+
let current: ReactTestInstance | null = element;
27+
while (current) {
28+
if (
29+
!isVisibleForStyles(current) ||
30+
!isVisibleForAccessibility(current) ||
31+
!isModalVisible(current)
32+
) {
33+
return false;
34+
}
35+
36+
current = getParentElement(current, [Modal]);
37+
}
38+
39+
return true;
3140
}
3241

3342
export function toBeVisible(this: jest.MatcherContext, element: ReactTestInstance) {

0 commit comments

Comments
 (0)