From f0fee3512aaaa9aa786181d6eb83fad419519b09 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Fri, 25 Aug 2023 12:45:19 +0100 Subject: [PATCH] feat: `toBeVisible` matcher (#1465) * feat: add toBeVisible matcher * test(toBeVisible): improve snapshot matching * chore: detect Modal as host component * chore: fix tests to host component names * refactor: merge accessibility related code with existing utils * chore: add comments * refactor: tweak unit test * chore: fix test * refactor: tweaks --------- Co-authored-by: Maciej Jastrzebski --- src/__tests__/config.test.ts | 3 +- src/__tests__/host-component-names.test.tsx | 6 + src/config.ts | 1 + src/helpers/__tests__/accessiblity.test.tsx | 26 ++ src/helpers/accessiblity.ts | 4 + src/helpers/host-component-names.tsx | 14 +- src/matchers/__tests__/to-be-visible.test.tsx | 282 ++++++++++++++++++ src/matchers/extend-expect.d.ts | 1 + src/matchers/extend-expect.ts | 2 + src/matchers/index.tsx | 1 + src/matchers/to-be-visible.tsx | 63 ++++ 11 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 src/matchers/__tests__/to-be-visible.test.tsx create mode 100644 src/matchers/to-be-visible.tsx diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 9475fb3f..a9532d68 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 434fdc36..881663d8 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 5788f4fb..15522e31 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 d47ebd6a..07bcbde9 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 99a4af60..273d84d3 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 d378424c..02a984cb 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 00000000..ddcb439c --- /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 d1052075..7072b020 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 188deaa6..327302c5 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 34adad66..ffe850f0 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 00000000..f26eda53 --- /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; +}