diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts
index 9475fb3f6..a9532d681 100644
--- a/src/__tests__/config.test.ts
+++ b/src/__tests__/config.test.ts
@@ -35,12 +35,13 @@ test('resetToDefaults() resets config to defaults', () => {
test('resetToDefaults() resets internal config to defaults', () => {
configureInternal({
- hostComponentNames: { text: 'A', textInput: 'A', switch: 'A' },
+ hostComponentNames: { text: 'A', textInput: 'A', switch: 'A', modal: 'A' },
});
expect(getConfig().hostComponentNames).toEqual({
text: 'A',
textInput: 'A',
switch: 'A',
+ modal: 'A',
});
resetToDefaults();
diff --git a/src/__tests__/host-component-names.test.tsx b/src/__tests__/host-component-names.test.tsx
index 434fdc36c..881663d87 100644
--- a/src/__tests__/host-component-names.test.tsx
+++ b/src/__tests__/host-component-names.test.tsx
@@ -21,6 +21,7 @@ describe('getHostComponentNames', () => {
text: 'banana',
textInput: 'banana',
switch: 'banana',
+ modal: 'banana',
},
});
@@ -28,6 +29,7 @@ describe('getHostComponentNames', () => {
text: 'banana',
textInput: 'banana',
switch: 'banana',
+ modal: 'banana',
});
});
@@ -40,6 +42,7 @@ describe('getHostComponentNames', () => {
text: 'Text',
textInput: 'TextInput',
switch: 'RCTSwitch',
+ modal: 'Modal',
});
expect(getConfig().hostComponentNames).toBe(hostComponentNames);
});
@@ -68,6 +71,7 @@ describe('configureHostComponentNamesIfNeeded', () => {
text: 'Text',
textInput: 'TextInput',
switch: 'RCTSwitch',
+ modal: 'Modal',
});
});
@@ -77,6 +81,7 @@ describe('configureHostComponentNamesIfNeeded', () => {
text: 'banana',
textInput: 'banana',
switch: 'banana',
+ modal: 'banana',
},
});
@@ -86,6 +91,7 @@ describe('configureHostComponentNamesIfNeeded', () => {
text: 'banana',
textInput: 'banana',
switch: 'banana',
+ modal: 'banana',
});
});
diff --git a/src/config.ts b/src/config.ts
index 5788f4fba..15522e310 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -24,6 +24,7 @@ export type HostComponentNames = {
text: string;
textInput: string;
switch: string;
+ modal: string;
};
export type InternalConfig = Config & {
diff --git a/src/helpers/__tests__/accessiblity.test.tsx b/src/helpers/__tests__/accessiblity.test.tsx
index d47ebd6a2..07bcbde94 100644
--- a/src/helpers/__tests__/accessiblity.test.tsx
+++ b/src/helpers/__tests__/accessiblity.test.tsx
@@ -41,6 +41,32 @@ describe('isHiddenFromAccessibility', () => {
expect(isHiddenFromAccessibility(null)).toBe(true);
});
+ test('detects elements with aria-hidden prop', () => {
+ const view = render();
+ expect(
+ isHiddenFromAccessibility(
+ view.getByTestId('subject', {
+ includeHiddenElements: true,
+ })
+ )
+ ).toBe(true);
+ });
+
+ test('detects nested elements with aria-hidden prop', () => {
+ const view = render(
+
+
+
+ );
+ expect(
+ isHiddenFromAccessibility(
+ view.getByTestId('subject', {
+ includeHiddenElements: true,
+ })
+ )
+ ).toBe(true);
+ });
+
test('detects elements with accessibilityElementsHidden prop', () => {
const view = render();
expect(
diff --git a/src/helpers/accessiblity.ts b/src/helpers/accessiblity.ts
index 99a4af60e..273d84d3b 100644
--- a/src/helpers/accessiblity.ts
+++ b/src/helpers/accessiblity.ts
@@ -62,6 +62,10 @@ function isSubtreeInaccessible(element: ReactTestInstance): boolean {
return false;
}
+ if (element.props['aria-hidden']) {
+ return true;
+ }
+
// iOS: accessibilityElementsHidden
// See: https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios
if (element.props.accessibilityElementsHidden) {
diff --git a/src/helpers/host-component-names.tsx b/src/helpers/host-component-names.tsx
index d378424c3..02a984cbd 100644
--- a/src/helpers/host-component-names.tsx
+++ b/src/helpers/host-component-names.tsx
@@ -1,6 +1,6 @@
import * as React from 'react';
import { ReactTestInstance } from 'react-test-renderer';
-import { Switch, Text, TextInput, View } from 'react-native';
+import { Modal, Switch, Text, TextInput, View } from 'react-native';
import { configureInternal, getConfig, HostComponentNames } from '../config';
import { renderWithAct } from '../render-act';
import { HostTestInstance } from './component-tree';
@@ -35,6 +35,7 @@ function detectHostComponentNames(): HostComponentNames {
Hello
+
);
@@ -42,6 +43,7 @@ function detectHostComponentNames(): HostComponentNames {
text: getByTestId(renderer.root, 'text').type as string,
textInput: getByTestId(renderer.root, 'textInput').type as string,
switch: getByTestId(renderer.root, 'switch').type as string,
+ modal: getByTestId(renderer.root, 'modal').type as string,
};
} catch (error) {
const errorMessage =
@@ -86,3 +88,13 @@ export function isHostTextInput(
): element is HostTestInstance {
return element?.type === getHostComponentNames().textInput;
}
+
+/**
+ * Checks if the given element is a host Modal.
+ * @param element The element to check.
+ */
+export function isHostModal(
+ element?: ReactTestInstance | null
+): element is HostTestInstance {
+ return element?.type === getHostComponentNames().modal;
+}
diff --git a/src/matchers/__tests__/to-be-visible.test.tsx b/src/matchers/__tests__/to-be-visible.test.tsx
new file mode 100644
index 000000000..ddcb439c5
--- /dev/null
+++ b/src/matchers/__tests__/to-be-visible.test.tsx
@@ -0,0 +1,282 @@
+import * as React from 'react';
+import { View, Modal } from 'react-native';
+import { render, screen } from '../..';
+import '../extend-expect';
+
+test('toBeVisible() on empty view', () => {
+ render();
+
+ const view = screen.getByTestId('view');
+ expect(view).toBeVisible();
+ expect(() => expect(view).not.toBeVisible())
+ .toThrowErrorMatchingInlineSnapshot(`
+ "expect(element).not.toBeVisible()
+
+ Received element is visible:
+ "
+ `);
+});
+
+test('toBeVisible() on view with opacity', () => {
+ render();
+
+ const view = screen.getByTestId('view');
+ expect(view).toBeVisible();
+ expect(() => expect(view).not.toBeVisible())
+ .toThrowErrorMatchingInlineSnapshot(`
+ "expect(element).not.toBeVisible()
+
+ Received element is visible:
+ "
+ `);
+});
+
+test('toBeVisible() on view with 0 opacity', () => {
+ render();
+
+ const view = screen.getByTestId('view');
+ expect(view).not.toBeVisible();
+ expect(() => expect(view).toBeVisible()).toThrowErrorMatchingInlineSnapshot(`
+ "expect(element).toBeVisible()
+
+ Received element is not visible:
+ "
+ `);
+});
+
+test('toBeVisible() on view with display "none"', () => {
+ render();
+
+ const view = screen.getByTestId('view', { includeHiddenElements: true });
+ expect(view).not.toBeVisible();
+ expect(() => expect(view).toBeVisible()).toThrowErrorMatchingInlineSnapshot(`
+ "expect(element).toBeVisible()
+
+ Received element is not visible:
+ "
+ `);
+});
+
+test('toBeVisible() on ancestor view with 0 opacity', () => {
+ render(
+
+
+
+
+
+ );
+
+ const view = screen.getByTestId('view');
+ expect(view).not.toBeVisible();
+ expect(() => expect(view).toBeVisible()).toThrowErrorMatchingInlineSnapshot(`
+ "expect(element).toBeVisible()
+
+ Received element is not visible:
+ "
+ `);
+});
+
+test('toBeVisible() on ancestor view with display "none"', () => {
+ render(
+
+
+
+
+
+ );
+
+ const view = screen.getByTestId('view', { includeHiddenElements: true });
+ expect(view).not.toBeVisible();
+ expect(() => expect(view).toBeVisible()).toThrowErrorMatchingInlineSnapshot(`
+ "expect(element).toBeVisible()
+
+ Received element is not visible:
+ "
+ `);
+});
+
+test('toBeVisible() on empty Modal', () => {
+ render();
+
+ const modal = screen.getByTestId('modal');
+ expect(modal).toBeVisible();
+ expect(() => expect(modal).not.toBeVisible())
+ .toThrowErrorMatchingInlineSnapshot(`
+ "expect(element).not.toBeVisible()
+
+ Received element is visible:
+ "
+ `);
+});
+
+test('toBeVisible() on view within Modal', () => {
+ render(
+
+
+
+
+
+ );
+ expect(screen.getByTestId('view-within-modal')).toBeVisible();
+});
+
+test('toBeVisible() on view within not visible Modal', () => {
+ render(
+
+
+
+
+
+ );
+
+ expect(screen.getByTestId('test')).not.toBeVisible();
+
+ // Children elements of not visible modals are not rendered.
+ expect(screen.queryByTestId('view-within-modal')).not.toBeVisible();
+ expect(() => expect(screen.getByTestId('view-within-modal')).toBeVisible())
+ .toThrowErrorMatchingInlineSnapshot(`
+ "Unable to find an element with testID: view-within-modal
+
+ "
+ `);
+});
+
+test('toBeVisible() on not visible Modal', () => {
+ render();
+
+ expect(screen.getByTestId('test')).not.toBeVisible();
+});
+
+test('toBeVisible() on inaccessible view', () => {
+ render();
+
+ const test = screen.getByTestId('test', { includeHiddenElements: true });
+ expect(test).not.toBeVisible();
+
+ screen.update();
+ expect(test).toBeVisible();
+});
+
+test('toBeVisible() on view within inaccessible view', () => {
+ render(
+
+
+
+
+
+ );
+ expect(
+ screen.getByTestId('test', { includeHiddenElements: true })
+ ).not.toBeVisible();
+});
+
+test('toBeVisible() on inaccessible view (iOS)', () => {
+ render();
+
+ const test = screen.getByTestId('test', { includeHiddenElements: true });
+ expect(test).not.toBeVisible();
+
+ screen.update();
+ expect(test).toBeVisible();
+});
+
+test('toBeVisible() on view within inaccessible view (iOS)', () => {
+ render(
+
+
+
+
+
+ );
+ expect(
+ screen.getByTestId('test', { includeHiddenElements: true })
+ ).not.toBeVisible();
+});
+
+test('toBeVisible() on inaccessible view (Android)', () => {
+ render(
+
+ );
+
+ const test = screen.getByTestId('test', { includeHiddenElements: true });
+ expect(test).not.toBeVisible();
+
+ screen.update();
+ expect(test).toBeVisible();
+});
+
+test('toBeVisible() on view within inaccessible view (Android)', () => {
+ render(
+
+
+
+
+
+ );
+ expect(
+ screen.getByTestId('test', { includeHiddenElements: true })
+ ).not.toBeVisible();
+});
+
+test('toBeVisible() on null elements', () => {
+ expect(null).not.toBeVisible();
+ expect(() => expect(null).toBeVisible()).toThrowErrorMatchingInlineSnapshot(`
+ "expect(received).toBeVisible()
+
+ received value must be a host element.
+ Received has value: null"
+ `);
+});
+
+test('toBeVisible() on non-React elements', () => {
+ expect(() => expect({ name: 'Non-React element' }).not.toBeVisible())
+ .toThrowErrorMatchingInlineSnapshot(`
+ "expect(received).not.toBeVisible()
+
+ received value must be a host element.
+ Received has type: object
+ Received has value: {"name": "Non-React element"}"
+ `);
+
+ expect(() => expect(true).not.toBeVisible())
+ .toThrowErrorMatchingInlineSnapshot(`
+ "expect(received).not.toBeVisible()
+
+ received value must be a host element.
+ Received has type: boolean
+ Received has value: true"
+ `);
+});
diff --git a/src/matchers/extend-expect.d.ts b/src/matchers/extend-expect.d.ts
index d10520755..7072b020e 100644
--- a/src/matchers/extend-expect.d.ts
+++ b/src/matchers/extend-expect.d.ts
@@ -3,6 +3,7 @@ import type { TextMatch, TextMatchOptions } from '../matches';
export interface JestNativeMatchers {
toBeOnTheScreen(): R;
toBeEmptyElement(): R;
+ toBeVisible(): R;
toHaveDisplayValue(expectedValue: TextMatch, options?: TextMatchOptions): R;
toHaveTextContent(expectedText: TextMatch, options?: TextMatchOptions): R;
}
diff --git a/src/matchers/extend-expect.ts b/src/matchers/extend-expect.ts
index 188deaa6b..327302c57 100644
--- a/src/matchers/extend-expect.ts
+++ b/src/matchers/extend-expect.ts
@@ -2,12 +2,14 @@
import { toBeOnTheScreen } from './to-be-on-the-screen';
import { toBeEmptyElement } from './to-be-empty-element';
+import { toBeVisible } from './to-be-visible';
import { toHaveDisplayValue } from './to-have-display-value';
import { toHaveTextContent } from './to-have-text-content';
expect.extend({
toBeOnTheScreen,
toBeEmptyElement,
+ toBeVisible,
toHaveDisplayValue,
toHaveTextContent,
});
diff --git a/src/matchers/index.tsx b/src/matchers/index.tsx
index 34adad661..ffe850f05 100644
--- a/src/matchers/index.tsx
+++ b/src/matchers/index.tsx
@@ -1,2 +1,3 @@
export { toBeOnTheScreen } from './to-be-on-the-screen';
export { toBeEmptyElement } from './to-be-empty-element';
+export { toBeVisible } from './to-be-visible';
diff --git a/src/matchers/to-be-visible.tsx b/src/matchers/to-be-visible.tsx
new file mode 100644
index 000000000..f26eda530
--- /dev/null
+++ b/src/matchers/to-be-visible.tsx
@@ -0,0 +1,63 @@
+import type { ReactTestInstance } from 'react-test-renderer';
+import { matcherHint } from 'jest-matcher-utils';
+import { StyleSheet } from 'react-native';
+import { isHiddenFromAccessibility } from '../helpers/accessiblity';
+import { getHostParent } from '../helpers/component-tree';
+import { isHostModal } from '../helpers/host-component-names';
+import { checkHostElement, formatElement } from './utils';
+
+export function toBeVisible(
+ this: jest.MatcherContext,
+ element: ReactTestInstance
+) {
+ if (element !== null || !this.isNot) {
+ checkHostElement(element, toBeVisible, this);
+ }
+
+ return {
+ pass: isElementVisible(element),
+ message: () => {
+ const is = this.isNot ? 'is' : 'is not';
+ return [
+ matcherHint(`${this.isNot ? '.not' : ''}.toBeVisible`, 'element', ''),
+ '',
+ `Received element ${is} visible:`,
+ formatElement(element),
+ ].join('\n');
+ },
+ };
+}
+
+function isElementVisible(
+ element: ReactTestInstance,
+ accessibilityCache?: WeakMap
+): boolean {
+ // Use cache to speed up repeated searches by `isHiddenFromAccessibility`.
+ const cache = accessibilityCache ?? new WeakMap();
+ if (isHiddenFromAccessibility(element, { cache })) {
+ return false;
+ }
+
+ if (isHiddenForStyles(element)) {
+ return false;
+ }
+
+ // Note: this seems to be a bug in React Native.
+ // PR with fix: https://github.com/facebook/react-native/pull/39157
+ if (isHostModal(element) && element.props.visible === false) {
+ return false;
+ }
+
+ const hostParent = getHostParent(element);
+ if (hostParent === null) {
+ return true;
+ }
+
+ return isElementVisible(hostParent, cache);
+}
+
+function isHiddenForStyles(element: ReactTestInstance) {
+ const style = element.props.style ?? {};
+ const { display, opacity } = StyleSheet.flatten(style);
+ return display === 'none' || opacity === 0;
+}