Skip to content

Commit 1b65ce3

Browse files
authored
feat: add toBeVisible matcher (#73)
1 parent beaf547 commit 1b65ce3

File tree

6 files changed

+332
-0
lines changed

6 files changed

+332
-0
lines changed

README.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
- [`toHaveProp`](#tohaveprop)
4848
- [`toHaveTextContent`](#tohavetextcontent)
4949
- [`toHaveStyle`](#tohavestyle)
50+
- [`toBeVisible`](#tobevisible)
5051
- [Inspiration](#inspiration)
5152
- [Other solutions](#other-solutions)
5253
- [Contributors](#contributors)
@@ -311,6 +312,123 @@ expect(getByText('Hello World')).not.toHaveStyle({
311312
});
312313
```
313314

315+
### `toBeVisible`
316+
317+
```typescript
318+
toBeVisible();
319+
```
320+
321+
Check that the given element is visible to the user.
322+
323+
An element is visible if **all** the following conditions are met:
324+
325+
- it does not have its style property `display` set to `none`.
326+
- it does not have its style property `opacity` set to `0`.
327+
- it is not a `Modal` component or it does not have the prop `visible` set to `false`.
328+
- it is not hidden from accessibility as checked by [`isInaccessible`](https://callstack.github.io/react-native-testing-library/docs/api#isinaccessible) function from React Native Testing Library
329+
- its ancestor elements are also visible.
330+
331+
#### Examples
332+
333+
```javascript
334+
const { getByTestId } = render(<View testID="empty-view" />);
335+
336+
expect(getByTestId('empty-view')).toBeVisible();
337+
```
338+
339+
```javascript
340+
const { getByTestId } = render(<View testID="view-with-opacity" style={{ opacity: 0.2 }} />);
341+
342+
expect(getByTestId('view-with-opacity')).toBeVisible();
343+
```
344+
345+
```javascript
346+
const { getByTestId } = render(<Modal testID="empty-modal" />);
347+
348+
expect(getByTestId('empty-modal')).toBeVisible();
349+
```
350+
351+
```javascript
352+
const { getByTestId } = render(
353+
<Modal>
354+
<View>
355+
<View testID="view-within-modal" />
356+
</View>
357+
</Modal>,
358+
);
359+
360+
expect(getByTestId('view-within-modal')).toBeVisible();
361+
```
362+
363+
```javascript
364+
const { getByTestId } = render(<View testID="invisible-view" style={{ opacity: 0 }} />);
365+
366+
expect(getByTestId('invisible-view')).not.toBeVisible();
367+
```
368+
369+
```javascript
370+
const { getByTestId } = render(<View testID="display-none-view" style={{ display: 'none' }} />);
371+
372+
expect(getByTestId('display-none-view')).not.toBeVisible();
373+
```
374+
375+
```javascript
376+
const { getByTestId } = render(
377+
<View style={{ opacity: 0 }}>
378+
<View>
379+
<View testID="view-within-invisible-view" />
380+
</View>
381+
</View>,
382+
);
383+
384+
expect(getByTestId('view-within-invisible-view')).not.toBeVisible();
385+
```
386+
387+
```javascript
388+
const { getByTestId } = render(
389+
<View style={{ display: 'none' }}>
390+
<View>
391+
<View testID="view-within-display-none-view" />
392+
</View>
393+
</View>,
394+
);
395+
396+
expect(getByTestId('view-within-display-none-view')).not.toBeVisible();
397+
```
398+
399+
```javascript
400+
const { getByTestId } = render(
401+
<Modal visible={false}>
402+
<View>
403+
<View testID="view-within-not-visible-modal" />
404+
</View>
405+
</Modal>,
406+
);
407+
408+
// Children elements of not visible modals are not rendered.
409+
expect(queryByTestId('view-within-modal')).toBeNull();
410+
```
411+
412+
```javascript
413+
const { getByTestId } = render(<Modal testID="not-visible-modal" visible={false} />);
414+
415+
expect(getByTestId('not-visible-modal')).not.toBeVisible();
416+
```
417+
418+
```javascript
419+
const { getByTestId } = render(<View testID="test" accessibilityElementsHidden />);
420+
421+
expect(getByTestId('test')).not.toBeVisible();
422+
```
423+
424+
```javascript
425+
const { getByTestId } = render(
426+
<View testID="test" importantForAccessibility="no-hide-descendants" />,
427+
);
428+
429+
expect(getByTestId('test')).not.toBeVisible();
430+
```
431+
314432
## Inspiration
315433

316434
This library was made to be a companion for

extend-expect.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ declare global {
1515

1616
/** @deprecated This function has been renamed to `toBeEmptyElement`. */
1717
toBeEmpty(): R;
18+
toBeVisible(): R;
1819
}
1920
}
2021
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`.toBeVisible throws an error when expectation is not matched 1`] = `
4+
"expect(element).not.toBeVisible()
5+
6+
Received element is visible:
7+
Object {
8+
"props": Object {
9+
"children": undefined,
10+
"testID": "test",
11+
},
12+
}"
13+
`;
14+
15+
exports[`.toBeVisible throws an error when expectation is not matched 2`] = `
16+
"expect(element).toBeVisible()
17+
18+
Received element is not visible:
19+
Object {
20+
"props": Object {
21+
"children": undefined,
22+
"style": Object {
23+
"opacity": 0,
24+
},
25+
"testID": "test",
26+
},
27+
}"
28+
`;

src/__tests__/to-be-visible.tsx

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import React from 'react';
2+
import { Modal, View } from 'react-native';
3+
import { render } from '@testing-library/react-native';
4+
5+
describe('.toBeVisible', () => {
6+
test('handles empty view', () => {
7+
const { getByTestId } = render(<View testID="test" />);
8+
expect(getByTestId('test')).toBeVisible();
9+
});
10+
11+
test('handles view with opacity', () => {
12+
const { getByTestId } = render(<View testID="test" style={{ opacity: 0.2 }} />);
13+
expect(getByTestId('test')).toBeVisible();
14+
});
15+
16+
test('handles view with 0 opacity', () => {
17+
const { getByTestId } = render(<View testID="test" style={{ opacity: 0 }} />);
18+
expect(getByTestId('test')).not.toBeVisible();
19+
});
20+
21+
test('handles view with display "none"', () => {
22+
const { getByTestId } = render(<View testID="test" style={{ display: 'none' }} />);
23+
expect(getByTestId('test')).not.toBeVisible();
24+
});
25+
26+
test('handles ancestor view with 0 opacity', () => {
27+
const { getByTestId } = render(
28+
<View style={{ opacity: 0 }}>
29+
<View>
30+
<View testID="test" />
31+
</View>
32+
</View>,
33+
);
34+
expect(getByTestId('test')).not.toBeVisible();
35+
});
36+
37+
test('handles ancestor view with display "none"', () => {
38+
const { getByTestId } = render(
39+
<View style={{ display: 'none' }}>
40+
<View>
41+
<View testID="test" />
42+
</View>
43+
</View>,
44+
);
45+
expect(getByTestId('test')).not.toBeVisible();
46+
});
47+
48+
test('handles empty modal', () => {
49+
const { getByTestId } = render(<Modal testID="test" />);
50+
expect(getByTestId('test')).toBeVisible();
51+
});
52+
53+
test('handles view within modal', () => {
54+
const { getByTestId } = render(
55+
<Modal>
56+
<View>
57+
<View testID="view-within-modal" />
58+
</View>
59+
</Modal>,
60+
);
61+
expect(getByTestId('view-within-modal')).toBeVisible();
62+
});
63+
64+
test('handles view within not visible modal', () => {
65+
const { getByTestId, queryByTestId } = render(
66+
<Modal testID="test" visible={false}>
67+
<View>
68+
<View testID="view-within-modal" />
69+
</View>
70+
</Modal>,
71+
);
72+
expect(getByTestId('test')).not.toBeVisible();
73+
// Children elements of not visible modals are not rendered.
74+
expect(() => expect(getByTestId('view-within-modal')).not.toBeVisible()).toThrow();
75+
expect(queryByTestId('view-within-modal')).toBeNull();
76+
});
77+
78+
test('handles not visible modal', () => {
79+
const { getByTestId } = render(<Modal testID="test" visible={false} />);
80+
expect(getByTestId('test')).not.toBeVisible();
81+
});
82+
83+
test('handles inaccessible view (iOS)', () => {
84+
const { getByTestId, update } = render(<View testID="test" accessibilityElementsHidden />);
85+
expect(getByTestId('test')).not.toBeVisible();
86+
87+
update(<View testID="test" accessibilityElementsHidden={false} />);
88+
expect(getByTestId('test')).toBeVisible();
89+
});
90+
91+
test('handles view within inaccessible view (iOS)', () => {
92+
const { getByTestId } = render(
93+
<View accessibilityElementsHidden>
94+
<View>
95+
<View testID="test" />
96+
</View>
97+
</View>,
98+
);
99+
expect(getByTestId('test')).not.toBeVisible();
100+
});
101+
102+
test('handles inaccessible view (Android)', () => {
103+
const { getByTestId, update } = render(
104+
<View testID="test" importantForAccessibility="no-hide-descendants" />,
105+
);
106+
expect(getByTestId('test')).not.toBeVisible();
107+
108+
update(<View testID="test" importantForAccessibility="auto" />);
109+
expect(getByTestId('test')).toBeVisible();
110+
});
111+
112+
test('handles view within inaccessible view (Android)', () => {
113+
const { getByTestId } = render(
114+
<View importantForAccessibility="no-hide-descendants">
115+
<View>
116+
<View testID="test" />
117+
</View>
118+
</View>,
119+
);
120+
expect(getByTestId('test')).not.toBeVisible();
121+
});
122+
123+
it('handles non-React elements', () => {
124+
expect(() => expect({ name: 'Non-React element' }).not.toBeVisible()).toThrow();
125+
expect(() => expect(true).not.toBeVisible()).toThrow();
126+
});
127+
128+
it('throws an error when expectation is not matched', () => {
129+
const { getByTestId, update } = render(<View testID="test" />);
130+
expect(() => expect(getByTestId('test')).not.toBeVisible()).toThrowErrorMatchingSnapshot();
131+
132+
update(<View testID="test" style={{ opacity: 0 }} />);
133+
expect(() => expect(getByTestId('test')).toBeVisible()).toThrowErrorMatchingSnapshot();
134+
});
135+
});

src/extend-expect.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { toContainElement } from './to-contain-element';
44
import { toHaveProp } from './to-have-prop';
55
import { toHaveStyle } from './to-have-style';
66
import { toHaveTextContent } from './to-have-text-content';
7+
import { toBeVisible } from './to-be-visible';
78

89
expect.extend({
910
toBeDisabled,
@@ -14,4 +15,5 @@ expect.extend({
1415
toHaveProp,
1516
toHaveStyle,
1617
toHaveTextContent,
18+
toBeVisible,
1719
});

src/to-be-visible.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Modal, StyleSheet } from 'react-native';
2+
import { matcherHint } from 'jest-matcher-utils';
3+
import type { ReactTestInstance } from 'react-test-renderer';
4+
5+
import { checkReactElement, printElement } from './utils';
6+
7+
function isStyleVisible(element: ReactTestInstance) {
8+
const style = element.props.style || {};
9+
const { display, opacity } = StyleSheet.flatten(style);
10+
return display !== 'none' && opacity !== 0;
11+
}
12+
13+
function isAttributeVisible(element: ReactTestInstance) {
14+
return element.type !== Modal || element.props.visible !== false;
15+
}
16+
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;
22+
}
23+
24+
function isElementVisible(element: ReactTestInstance): boolean {
25+
return (
26+
isStyleVisible(element) &&
27+
isAttributeVisible(element) &&
28+
isVisibleForAccessibility(element) &&
29+
(!element.parent || isElementVisible(element.parent))
30+
);
31+
}
32+
33+
export function toBeVisible(this: jest.MatcherContext, element: ReactTestInstance) {
34+
checkReactElement(element, toBeVisible, this);
35+
const isVisible = isElementVisible(element);
36+
return {
37+
pass: isVisible,
38+
message: () => {
39+
const is = isVisible ? 'is' : 'is not';
40+
return [
41+
matcherHint(`${this.isNot ? '.not' : ''}.toBeVisible`, 'element', ''),
42+
'',
43+
`Received element ${is} visible:`,
44+
printElement(element),
45+
].join('\n');
46+
},
47+
};
48+
}

0 commit comments

Comments
 (0)