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'); + }, + }; +}