diff --git a/src/__tests__/jest-native.test.tsx b/src/__tests__/jest-native.test.tsx index f52e74f7e..64601f47e 100644 --- a/src/__tests__/jest-native.test.tsx +++ b/src/__tests__/jest-native.test.tsx @@ -40,10 +40,10 @@ test('jest-native matchers work correctly', () => { expect(getByText('Disabled Button')).toBeDisabled(); expect(getByText('Enabled Button')).not.toBeDisabled(); - expect(getByA11yHint('Empty Text')).toBeEmpty(); - expect(getByA11yHint('Empty View')).toBeEmpty(); - expect(getByA11yHint('Not Empty Text')).not.toBeEmpty(); - expect(getByA11yHint('Not Empty View')).not.toBeEmpty(); + expect(getByA11yHint('Empty Text')).toBeEmptyElement(); + expect(getByA11yHint('Empty View')).toBeEmptyElement(); + expect(getByA11yHint('Not Empty Text')).not.toBeEmptyElement(); + expect(getByA11yHint('Not Empty View')).not.toBeEmptyElement(); expect(getByA11yHint('Container View')).toContainElement( // $FlowFixMe - TODO: fix @testing-library/jest-native flow typings diff --git a/src/__tests__/react-native-api.test.tsx b/src/__tests__/react-native-api.test.tsx new file mode 100644 index 000000000..6fa2018aa --- /dev/null +++ b/src/__tests__/react-native-api.test.tsx @@ -0,0 +1,100 @@ +import * as React from 'react'; +import { View, Text, TextInput } from 'react-native'; +import { render } from '..'; +import { getHostSelf } from '../helpers/component-tree'; + +/** + * Tests in this file are intended to give us an proactive warning that React Native behavior has + * changed in a way that may impact our code like queries or event handling. + */ + +test('React Native API assumption: renders single host element', () => { + const view = render(); + const hostView = view.getByTestId('test'); + expect(getHostSelf(hostView)).toBe(hostView); + + expect(view.toJSON()).toMatchInlineSnapshot(` + + `); +}); + +test('React Native API assumption: renders single host element', () => { + const view = render(Hello); + const compositeView = view.getByText('Hello'); + const hostView = view.getByTestId('test'); + expect(getHostSelf(compositeView)).toBe(hostView); + + expect(view.toJSON()).toMatchInlineSnapshot(` + + Hello + + `); +}); + +test('React Native API assumption: nested renders single host element', () => { + const view = render( + + Before + Hello + + Deeply nested + + + ); + expect(getHostSelf(view.getByText('Hello'))).toBe(view.getByTestId('test')); + expect(getHostSelf(view.getByText('Before'))).toBe( + view.getByTestId('before') + ); + expect(getHostSelf(view.getByText('Deeply nested'))).toBe( + view.getByTestId('deeplyNested') + ); + + expect(view.toJSON()).toMatchInlineSnapshot(` + + + Before + + Hello + + + Deeply nested + + + + `); +}); + +test('React Native API assumption: renders single host element', () => { + const view = render( + + ); + const compositeView = view.getByPlaceholderText('Placeholder'); + const hostView = view.getByTestId('test'); + expect(getHostSelf(compositeView)).toBe(hostView); + + expect(view.toJSON()).toMatchInlineSnapshot(` + + `); +}); diff --git a/src/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx index 2b1b5c619..29d793c56 100644 --- a/src/helpers/__tests__/component-tree.test.tsx +++ b/src/helpers/__tests__/component-tree.test.tsx @@ -4,10 +4,15 @@ import { render } from '../..'; import { getHostChildren, getHostParent, + getHostSelf, getHostSelves, getHostSiblings, } from '../component-tree'; +function ZeroHostChildren() { + return <>; +} + function MultipleHostChildren() { return ( <> @@ -18,185 +23,269 @@ function MultipleHostChildren() { ); } -test('returns host parent for host component', () => { - const view = render( - +describe('getHostParent()', () => { + it('returns host parent for host component', () => { + const view = render( + + + + + + + ); + + const hostParent = getHostParent(view.getByTestId('subject')); + expect(hostParent).toBe(view.getByTestId('parent')); + + const hostGrandparent = getHostParent(hostParent); + expect(hostGrandparent).toBe(view.getByTestId('grandparent')); + + expect(getHostParent(hostGrandparent)).toBe(null); + }); + + it('returns host parent for composite component', () => { + const view = render( + - - - ); + ); - const hostParent = getHostParent(view.getByTestId('subject')); - expect(hostParent).toBe(view.getByTestId('parent')); + const compositeComponent = view.UNSAFE_getByType(MultipleHostChildren); + const hostParent = getHostParent(compositeComponent); + expect(hostParent).toBe(view.getByTestId('parent')); + }); +}); - const hostGrandparent = getHostParent(hostParent); - expect(hostGrandparent).toBe(view.getByTestId('grandparent')); +describe('getHostChildren()', () => { + it('returns host children for host component', () => { + const view = render( + + + + + + + ); - expect(getHostParent(hostGrandparent)).toBe(null); -}); + const hostSubject = view.getByTestId('subject'); + expect(getHostChildren(hostSubject)).toEqual([]); -test('returns host parent for composite component', () => { - const view = render( - - - - - ); + const hostSibling = view.getByTestId('sibling'); + const hostParent = view.getByTestId('parent'); + expect(getHostChildren(hostParent)).toEqual([hostSubject, hostSibling]); - const compositeComponent = view.UNSAFE_getByType(MultipleHostChildren); - const hostParent = getHostParent(compositeComponent); - expect(hostParent).toBe(view.getByTestId('parent')); -}); + const hostGrandparent = view.getByTestId('grandparent'); + expect(getHostChildren(hostGrandparent)).toEqual([hostParent]); + }); -test('returns host children for host component', () => { - const view = render( - + it('returns host children for composite component', () => { + const view = render( + - - ); + ); - const hostSubject = view.getByTestId('subject'); - expect(getHostChildren(hostSubject)).toEqual([]); + expect(getHostChildren(view.getByTestId('parent'))).toEqual([ + view.getByTestId('child1'), + view.getByTestId('child2'), + view.getByTestId('child3'), + view.getByTestId('subject'), + view.getByTestId('sibling'), + ]); + }); +}); - const hostSibling = view.getByTestId('sibling'); - const hostParent = view.getByTestId('parent'); - expect(getHostChildren(hostParent)).toEqual([hostSubject, hostSibling]); +describe('getHostSelf()', () => { + it('returns passed element for host components', () => { + const view = render( + + + + + + + ); - const hostGrandparent = view.getByTestId('grandparent'); - expect(getHostChildren(hostGrandparent)).toEqual([hostParent]); -}); + const hostSubject = view.getByTestId('subject'); + expect(getHostSelf(hostSubject)).toEqual(hostSubject); -test('returns host children for composite component', () => { - const view = render( - - - - - - ); + const hostSibling = view.getByTestId('sibling'); + expect(getHostSelf(hostSibling)).toEqual(hostSibling); - expect(getHostChildren(view.getByTestId('parent'))).toEqual([ - view.getByTestId('child1'), - view.getByTestId('child2'), - view.getByTestId('child3'), - view.getByTestId('subject'), - view.getByTestId('sibling'), - ]); -}); + const hostParent = view.getByTestId('parent'); + expect(getHostSelf(hostParent)).toEqual(hostParent); + + const hostGrandparent = view.getByTestId('grandparent'); + expect(getHostSelf(hostGrandparent)).toEqual(hostGrandparent); + }); -test('returns host selves for host components', () => { - const view = render( - + it('returns single host child for React Native composite components', () => { + const view = render( - - + Text + - - ); + ); - const hostSubject = view.getByTestId('subject'); - expect(getHostSelves(hostSubject)).toEqual([hostSubject]); + const compositeText = view.getByText('Text'); + const hostText = view.getByTestId('text'); + expect(getHostSelf(compositeText)).toEqual(hostText); - const hostSibling = view.getByTestId('sibling'); - expect(getHostSelves(hostSibling)).toEqual([hostSibling]); + const compositeTextInputByValue = view.getByDisplayValue('TextInputValue'); + const compositeTextInputByPlaceholder = view.getByPlaceholderText( + 'TextInputPlaceholder' + ); + const hostTextInput = view.getByTestId('textInput'); + expect(getHostSelf(compositeTextInputByValue)).toEqual(hostTextInput); + expect(getHostSelf(compositeTextInputByPlaceholder)).toEqual(hostTextInput); + }); - const hostParent = view.getByTestId('parent'); - expect(getHostSelves(hostParent)).toEqual([hostParent]); + it('throws on non-single host children elements for custom composite components', () => { + const view = render( + + + + + ); - const hostGrandparent = view.getByTestId('grandparent'); - expect(getHostSelves(hostGrandparent)).toEqual([hostGrandparent]); + const zeroCompositeComponent = view.UNSAFE_getByType(ZeroHostChildren); + expect(() => getHostSelf(zeroCompositeComponent)).toThrow( + 'Expected exactly one host element, but found none.' + ); + + const multipleCompositeComponent = + view.UNSAFE_getByType(MultipleHostChildren); + expect(() => getHostSelf(multipleCompositeComponent)).toThrow( + 'Expected exactly one host element, but found 3.' + ); + }); }); -test('returns host selves for React Native composite components', () => { - const view = render( - - Text - - - ); +describe('getHostSelves()', () => { + it('returns passed element for host components', () => { + const view = render( + + + + + + + ); - const compositeText = view.getByText('Text'); - const hostText = view.getByTestId('text'); - expect(getHostSelves(compositeText)).toEqual([hostText]); + const hostSubject = view.getByTestId('subject'); + expect(getHostSelves(hostSubject)).toEqual([hostSubject]); - const compositeTextInputByValue = view.getByDisplayValue('TextInputValue'); - const compositeTextInputByPlaceholder = view.getByPlaceholderText( - 'TextInputPlaceholder' - ); - const hostTextInput = view.getByTestId('textInput'); - expect(getHostSelves(compositeTextInputByValue)).toEqual([hostTextInput]); - expect(getHostSelves(compositeTextInputByPlaceholder)).toEqual([ - hostTextInput, - ]); -}); + const hostSibling = view.getByTestId('sibling'); + expect(getHostSelves(hostSibling)).toEqual([hostSibling]); -test('returns host selves for custom composite components', () => { - const view = render( - - - - - ); + const hostParent = view.getByTestId('parent'); + expect(getHostSelves(hostParent)).toEqual([hostParent]); - const compositeComponent = view.UNSAFE_getByType(MultipleHostChildren); - const hostChild1 = view.getByTestId('child1'); - const hostChild2 = view.getByTestId('child2'); - const hostChild3 = view.getByTestId('child3'); - expect(getHostSelves(compositeComponent)).toEqual([ - hostChild1, - hostChild2, - hostChild3, - ]); -}); + const hostGrandparent = view.getByTestId('grandparent'); + expect(getHostSelves(hostGrandparent)).toEqual([hostGrandparent]); + }); -test('returns host siblings for host component', () => { - const view = render( - + test('returns single host element for React Native composite components', () => { + const view = render( - - - - + Text + - - ); + ); - const hostSiblings = getHostSiblings(view.getByTestId('subject')); - expect(hostSiblings).toEqual([ - view.getByTestId('siblingBefore'), - view.getByTestId('siblingAfter'), - view.getByTestId('child1'), - view.getByTestId('child2'), - view.getByTestId('child3'), - ]); -}); + const compositeText = view.getByText('Text'); + const hostText = view.getByTestId('text'); + expect(getHostSelves(compositeText)).toEqual([hostText]); -test('returns host siblings for composite component', () => { - const view = render( - + const compositeTextInputByValue = view.getByDisplayValue('TextInputValue'); + const compositeTextInputByPlaceholder = view.getByPlaceholderText( + 'TextInputPlaceholder' + ); + + const hostTextInput = view.getByTestId('textInput'); + expect(getHostSelves(compositeTextInputByValue)).toEqual([hostTextInput]); + expect(getHostSelves(compositeTextInputByPlaceholder)).toEqual([ + hostTextInput, + ]); + }); + + test('returns host children for custom composite components', () => { + const view = render( - - - + + - - ); + ); + + const zeroCompositeComponent = view.UNSAFE_getByType(ZeroHostChildren); + expect(getHostSelves(zeroCompositeComponent)).toEqual([]); + + const multipleCompositeComponent = + view.UNSAFE_getByType(MultipleHostChildren); + const hostChild1 = view.getByTestId('child1'); + const hostChild2 = view.getByTestId('child2'); + const hostChild3 = view.getByTestId('child3'); + expect(getHostSelves(multipleCompositeComponent)).toEqual([ + hostChild1, + hostChild2, + hostChild3, + ]); + }); +}); + +describe('getHostSiblings()', () => { + it('returns host siblings for host component', () => { + const view = render( + + + + + + + + + ); + + const hostSiblings = getHostSiblings(view.getByTestId('subject')); + expect(hostSiblings).toEqual([ + view.getByTestId('siblingBefore'), + view.getByTestId('siblingAfter'), + view.getByTestId('child1'), + view.getByTestId('child2'), + view.getByTestId('child3'), + ]); + }); + + it('returns host siblings for composite component', () => { + const view = render( + + + + + + + + + ); - const compositeComponent = view.UNSAFE_getByType(MultipleHostChildren); - const hostSiblings = getHostSiblings(compositeComponent); - expect(hostSiblings).toEqual([ - view.getByTestId('siblingBefore'), - view.getByTestId('subject'), - view.getByTestId('siblingAfter'), - ]); + const compositeComponent = view.UNSAFE_getByType(MultipleHostChildren); + const hostSiblings = getHostSiblings(compositeComponent); + expect(hostSiblings).toEqual([ + view.getByTestId('siblingBefore'), + view.getByTestId('subject'), + view.getByTestId('siblingAfter'), + ]); + }); }); diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts index 340472d72..d6be13755 100644 --- a/src/helpers/component-tree.ts +++ b/src/helpers/component-tree.ts @@ -59,6 +59,32 @@ export function getHostChildren( return hostChildren; } +/** + * Return a single host element that represent the passed host or composite element. + * + * @param element The element start traversing from. + * @throws Error if the passed element is a composite element and has no host children or has more than one host child. + * @returns If the passed element is a host element, it will return itself, if the passed element is a composite + * element, it will return a single host descendant. + */ +export function getHostSelf( + element: ReactTestInstance | null +): ReactTestInstance { + const hostSelves = getHostSelves(element); + + if (hostSelves.length === 0) { + throw new Error(`Expected exactly one host element, but found none.`); + } + + if (hostSelves.length > 1) { + throw new Error( + `Expected exactly one host element, but found ${hostSelves.length}.` + ); + } + + return hostSelves[0]; +} + /** * Return the array of host elements that represent the passed element. *