diff --git a/README.md b/README.md
index 348a105..1de08b1 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,7 @@
- [`toHaveProp`](#tohaveprop)
- [`toHaveTextContent`](#tohavetextcontent)
- [`toHaveStyle`](#tohavestyle)
+ - [`toBeVisible`](#tobevisible)
- [Inspiration](#inspiration)
- [Other solutions](#other-solutions)
- [Contributors](#contributors)
@@ -311,6 +312,123 @@ expect(getByText('Hello World')).not.toHaveStyle({
});
```
+### `toBeVisible`
+
+```typescript
+toBeVisible();
+```
+
+Check that the given element is visible to the user.
+
+An element is visible if **all** the following conditions are met:
+
+- it does not have its style property `display` set to `none`.
+- it does not have its style property `opacity` set to `0`.
+- it is not a `Modal` component or it does not have the prop `visible` set to `false`.
+- 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
+- its ancestor elements are also visible.
+
+#### Examples
+
+```javascript
+const { getByTestId } = render();
+
+expect(getByTestId('empty-view')).toBeVisible();
+```
+
+```javascript
+const { getByTestId } = render();
+
+expect(getByTestId('view-with-opacity')).toBeVisible();
+```
+
+```javascript
+const { getByTestId } = render();
+
+expect(getByTestId('empty-modal')).toBeVisible();
+```
+
+```javascript
+const { getByTestId } = render(
+
+
+
+
+ ,
+);
+
+expect(getByTestId('view-within-modal')).toBeVisible();
+```
+
+```javascript
+const { getByTestId } = render();
+
+expect(getByTestId('invisible-view')).not.toBeVisible();
+```
+
+```javascript
+const { getByTestId } = render();
+
+expect(getByTestId('display-none-view')).not.toBeVisible();
+```
+
+```javascript
+const { getByTestId } = render(
+
+
+
+
+ ,
+);
+
+expect(getByTestId('view-within-invisible-view')).not.toBeVisible();
+```
+
+```javascript
+const { getByTestId } = render(
+
+
+
+
+ ,
+);
+
+expect(getByTestId('view-within-display-none-view')).not.toBeVisible();
+```
+
+```javascript
+const { getByTestId } = render(
+
+
+
+
+ ,
+);
+
+// Children elements of not visible modals are not rendered.
+expect(queryByTestId('view-within-modal')).toBeNull();
+```
+
+```javascript
+const { getByTestId } = render();
+
+expect(getByTestId('not-visible-modal')).not.toBeVisible();
+```
+
+```javascript
+const { getByTestId } = render();
+
+expect(getByTestId('test')).not.toBeVisible();
+```
+
+```javascript
+const { getByTestId } = render(
+ ,
+);
+
+expect(getByTestId('test')).not.toBeVisible();
+```
+
## Inspiration
This library was made to be a companion for
diff --git a/extend-expect.d.ts b/extend-expect.d.ts
index 1ed913c..ea070d4 100644
--- a/extend-expect.d.ts
+++ b/extend-expect.d.ts
@@ -15,6 +15,7 @@ declare global {
/** @deprecated This function has been renamed to `toBeEmptyElement`. */
toBeEmpty(): R;
+ toBeVisible(): R;
}
}
}
diff --git a/src/__tests__/__snapshots__/to-be-visible.tsx.snap b/src/__tests__/__snapshots__/to-be-visible.tsx.snap
new file mode 100644
index 0000000..0e9de39
--- /dev/null
+++ b/src/__tests__/__snapshots__/to-be-visible.tsx.snap
@@ -0,0 +1,28 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`.toBeVisible throws an error when expectation is not matched 1`] = `
+"expect(element).not.toBeVisible()
+
+Received element is visible:
+ Object {
+ "props": Object {
+ "children": undefined,
+ "testID": "test",
+ },
+}"
+`;
+
+exports[`.toBeVisible throws an error when expectation is not matched 2`] = `
+"expect(element).toBeVisible()
+
+Received element is not visible:
+ Object {
+ "props": Object {
+ "children": undefined,
+ "style": Object {
+ "opacity": 0,
+ },
+ "testID": "test",
+ },
+}"
+`;
diff --git a/src/__tests__/to-be-visible.tsx b/src/__tests__/to-be-visible.tsx
new file mode 100644
index 0000000..589a079
--- /dev/null
+++ b/src/__tests__/to-be-visible.tsx
@@ -0,0 +1,135 @@
+import React from 'react';
+import { Modal, View } from 'react-native';
+import { render } from '@testing-library/react-native';
+
+describe('.toBeVisible', () => {
+ test('handles empty view', () => {
+ const { getByTestId } = render();
+ expect(getByTestId('test')).toBeVisible();
+ });
+
+ test('handles view with opacity', () => {
+ const { getByTestId } = render();
+ expect(getByTestId('test')).toBeVisible();
+ });
+
+ test('handles view with 0 opacity', () => {
+ const { getByTestId } = render();
+ expect(getByTestId('test')).not.toBeVisible();
+ });
+
+ test('handles view with display "none"', () => {
+ const { getByTestId } = render();
+ expect(getByTestId('test')).not.toBeVisible();
+ });
+
+ test('handles ancestor view with 0 opacity', () => {
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
+ expect(getByTestId('test')).not.toBeVisible();
+ });
+
+ test('handles ancestor view with display "none"', () => {
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
+ expect(getByTestId('test')).not.toBeVisible();
+ });
+
+ test('handles empty modal', () => {
+ const { getByTestId } = render();
+ expect(getByTestId('test')).toBeVisible();
+ });
+
+ test('handles view within modal', () => {
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
+ expect(getByTestId('view-within-modal')).toBeVisible();
+ });
+
+ test('handles view within not visible modal', () => {
+ const { getByTestId, queryByTestId } = render(
+
+
+
+
+ ,
+ );
+ expect(getByTestId('test')).not.toBeVisible();
+ // Children elements of not visible modals are not rendered.
+ expect(() => expect(getByTestId('view-within-modal')).not.toBeVisible()).toThrow();
+ expect(queryByTestId('view-within-modal')).toBeNull();
+ });
+
+ test('handles not visible modal', () => {
+ const { getByTestId } = render();
+ expect(getByTestId('test')).not.toBeVisible();
+ });
+
+ test('handles inaccessible view (iOS)', () => {
+ const { getByTestId, update } = render();
+ expect(getByTestId('test')).not.toBeVisible();
+
+ update();
+ expect(getByTestId('test')).toBeVisible();
+ });
+
+ test('handles view within inaccessible view (iOS)', () => {
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
+ expect(getByTestId('test')).not.toBeVisible();
+ });
+
+ test('handles inaccessible view (Android)', () => {
+ const { getByTestId, update } = render(
+ ,
+ );
+ expect(getByTestId('test')).not.toBeVisible();
+
+ update();
+ expect(getByTestId('test')).toBeVisible();
+ });
+
+ test('handles view within inaccessible view (Android)', () => {
+ const { getByTestId } = render(
+
+
+
+
+ ,
+ );
+ expect(getByTestId('test')).not.toBeVisible();
+ });
+
+ it('handles non-React elements', () => {
+ expect(() => expect({ name: 'Non-React element' }).not.toBeVisible()).toThrow();
+ expect(() => expect(true).not.toBeVisible()).toThrow();
+ });
+
+ it('throws an error when expectation is not matched', () => {
+ const { getByTestId, update } = render();
+ expect(() => expect(getByTestId('test')).not.toBeVisible()).toThrowErrorMatchingSnapshot();
+
+ update();
+ expect(() => expect(getByTestId('test')).toBeVisible()).toThrowErrorMatchingSnapshot();
+ });
+});
diff --git a/src/extend-expect.ts b/src/extend-expect.ts
index a0eaa96..0a0e5be 100644
--- a/src/extend-expect.ts
+++ b/src/extend-expect.ts
@@ -4,6 +4,7 @@ import { toContainElement } from './to-contain-element';
import { toHaveProp } from './to-have-prop';
import { toHaveStyle } from './to-have-style';
import { toHaveTextContent } from './to-have-text-content';
+import { toBeVisible } from './to-be-visible';
expect.extend({
toBeDisabled,
@@ -14,4 +15,5 @@ expect.extend({
toHaveProp,
toHaveStyle,
toHaveTextContent,
+ toBeVisible,
});
diff --git a/src/to-be-visible.ts b/src/to-be-visible.ts
new file mode 100644
index 0000000..323c1b3
--- /dev/null
+++ b/src/to-be-visible.ts
@@ -0,0 +1,48 @@
+import { Modal, StyleSheet } from 'react-native';
+import { matcherHint } from 'jest-matcher-utils';
+import type { ReactTestInstance } from 'react-test-renderer';
+
+import { checkReactElement, printElement } from './utils';
+
+function isStyleVisible(element: ReactTestInstance) {
+ const style = element.props.style || {};
+ const { display, opacity } = StyleSheet.flatten(style);
+ return display !== 'none' && opacity !== 0;
+}
+
+function isAttributeVisible(element: ReactTestInstance) {
+ return element.type !== Modal || element.props.visible !== false;
+}
+
+function isVisibleForAccessibility(element: ReactTestInstance) {
+ const visibleForiOSVoiceOver = !element.props.accessibilityElementsHidden;
+ const visibleForAndroidTalkBack =
+ element.props.importantForAccessibility !== 'no-hide-descendants';
+ return visibleForiOSVoiceOver && visibleForAndroidTalkBack;
+}
+
+function isElementVisible(element: ReactTestInstance): boolean {
+ return (
+ isStyleVisible(element) &&
+ isAttributeVisible(element) &&
+ isVisibleForAccessibility(element) &&
+ (!element.parent || isElementVisible(element.parent))
+ );
+}
+
+export function toBeVisible(this: jest.MatcherContext, element: ReactTestInstance) {
+ checkReactElement(element, toBeVisible, this);
+ const isVisible = isElementVisible(element);
+ return {
+ pass: isVisible,
+ message: () => {
+ const is = isVisible ? 'is' : 'is not';
+ return [
+ matcherHint(`${this.isNot ? '.not' : ''}.toBeVisible`, 'element', ''),
+ '',
+ `Received element ${is} visible:`,
+ printElement(element),
+ ].join('\n');
+ },
+ };
+}