diff --git a/package.json b/package.json index 99420c6c2..f8b5fe97f 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,9 @@ "typescript": "^5.0.2" }, "dependencies": { - "pretty-format": "^29.0.0" + "jest-matcher-utils": "^29.6.2", + "pretty-format": "^29.6.2", + "redent": "^3.0.0" }, "peerDependencies": { "jest": ">=28.0.0", diff --git a/src/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx index e275a58be..4e64250bd 100644 --- a/src/helpers/__tests__/component-tree.test.tsx +++ b/src/helpers/__tests__/component-tree.test.tsx @@ -6,6 +6,7 @@ import { getHostParent, getHostSelves, getHostSiblings, + getUnsafeRootElement, } from '../component-tree'; function ZeroHostChildren() { @@ -224,3 +225,20 @@ describe('getHostSiblings()', () => { ]); }); }); + +describe('getUnsafeRootElement()', () => { + it('returns UNSAFE_root for mounted view', () => { + const screen = render( + + + + ); + + const view = screen.getByTestId('view'); + expect(getUnsafeRootElement(view)).toEqual(screen.UNSAFE_root); + }); + + it('returns null for null', () => { + expect(getUnsafeRootElement(null)).toEqual(null); + }); +}); diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts index 46993e956..6fe6fd13b 100644 --- a/src/helpers/component-tree.ts +++ b/src/helpers/component-tree.ts @@ -92,3 +92,22 @@ export function getHostSiblings( (sibling) => !hostSelves.includes(sibling) ); } + +/** + * Returns the unsafe root element of the tree (probably composite). + * + * @param element The element start traversing from. + * @returns The root element of the tree (host or composite). + */ +export function getUnsafeRootElement(element: ReactTestInstance | null) { + if (element == null) { + return null; + } + + let current = element; + while (current.parent) { + current = current.parent; + } + + return current; +} diff --git a/src/matchers/__tests__/extend-expect.test.tsx b/src/matchers/__tests__/extend-expect.test.tsx new file mode 100644 index 000000000..1889926aa --- /dev/null +++ b/src/matchers/__tests__/extend-expect.test.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { View } from 'react-native'; + +// Note: that must point to root of the /src to reliably replicate default import. +import { render } from '../..'; + +// This is check that RNTL does not extend "expect" by default, until we actually want to expose Jest matchers publically. +test('does not extend "expect" by default', () => { + render(); + + // @ts-expect-error + expect(expect.toBeOnTheScreen).toBeUndefined(); +}); diff --git a/src/matchers/__tests__/to-be-on-the-screen.test.tsx b/src/matchers/__tests__/to-be-on-the-screen.test.tsx new file mode 100644 index 000000000..cfb6c8c8a --- /dev/null +++ b/src/matchers/__tests__/to-be-on-the-screen.test.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { View, Text } from 'react-native'; +import { render, screen } from '../..'; +import '../extend-expect'; + +test('example test', () => { + render( + + + + ); + + const child = screen.getByTestId('child'); + expect(child).toBeOnTheScreen(); + + screen.update(); + expect(child).not.toBeOnTheScreen(); +}); + +test('toBeOnTheScreen() on attached element', () => { + render(); + + const element = screen.getByTestId('test'); + expect(element).toBeOnTheScreen(); + expect(() => expect(element).not.toBeOnTheScreen()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toBeOnTheScreen() + + expected element tree not to contain element, but found + " + `); +}); + +function ShowChildren({ show }: { show: boolean }) { + return show ? ( + + Hello + + ) : ( + + ); +} + +test('toBeOnTheScreen() on detached element', () => { + render(); + + const element = screen.getByTestId('text'); + // Next line will unmount the element, yet `element` variable will still hold reference to it. + screen.update(); + + expect(element).toBeTruthy(); + expect(element).not.toBeOnTheScreen(); + expect(() => expect(element).toBeOnTheScreen()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeOnTheScreen() + + element could not be found in the element tree" + `); +}); + +test('toBeOnTheScreen() on null element', () => { + expect(null).not.toBeOnTheScreen(); + expect(() => expect(null).toBeOnTheScreen()) + .toThrowErrorMatchingInlineSnapshot(` + "expect(received).toBeOnTheScreen() + + received value must be a host element. + Received has value: null" + `); +}); diff --git a/src/matchers/__tests__/utils.test.tsx b/src/matchers/__tests__/utils.test.tsx new file mode 100644 index 000000000..0e210459d --- /dev/null +++ b/src/matchers/__tests__/utils.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { View } from 'react-native'; +import { render } from '../..'; +import { formatElement, checkHostElement } from '../utils'; + +function fakeMatcher() { + // Do nothing. +} + +test('formatElement', () => { + expect(formatElement(null)).toMatchInlineSnapshot(`"null"`); +}); + +test('checkHostElement allows host element', () => { + const screen = render(); + + expect(() => { + // @ts-expect-error + checkHostElement(screen.getByTestId('view'), fakeMatcher, {}); + }).not.toThrow(); +}); + +test('checkHostElement allows rejects composite element', () => { + const screen = render(); + + expect(() => { + // @ts-expect-error + checkHostElement(screen.UNSAFE_root, fakeMatcher, {}); + }).toThrow(/value must be a host element./); +}); + +test('checkHostElement allows rejects null element', () => { + expect(() => { + // @ts-expect-error + checkHostElement(null, fakeMatcher, {}); + }).toThrowErrorMatchingInlineSnapshot(` + "expect(received).fakeMatcher() + + received value must be a host element. + Received has value: null" + `); +}); diff --git a/src/matchers/extend-expect.ts b/src/matchers/extend-expect.ts new file mode 100644 index 000000000..4eb7b373f --- /dev/null +++ b/src/matchers/extend-expect.ts @@ -0,0 +1,5 @@ +import { toBeOnTheScreen } from './to-be-on-the-screen'; + +expect.extend({ + toBeOnTheScreen, +}); diff --git a/src/matchers/index.tsx b/src/matchers/index.tsx new file mode 100644 index 000000000..8d1540b14 --- /dev/null +++ b/src/matchers/index.tsx @@ -0,0 +1 @@ +export { toBeOnTheScreen } from './to-be-on-the-screen'; diff --git a/src/matchers/to-be-on-the-screen.tsx b/src/matchers/to-be-on-the-screen.tsx new file mode 100644 index 000000000..3fd1ce615 --- /dev/null +++ b/src/matchers/to-be-on-the-screen.tsx @@ -0,0 +1,44 @@ +import type { ReactTestInstance } from 'react-test-renderer'; +import { matcherHint, RECEIVED_COLOR } from 'jest-matcher-utils'; +import { getUnsafeRootElement } from '../helpers/component-tree'; +import { screen } from '../screen'; +import { checkHostElement, formatElement } from './utils'; + +export function toBeOnTheScreen( + this: jest.MatcherContext, + element: ReactTestInstance +) { + if (element !== null || !this.isNot) { + checkHostElement(element, toBeOnTheScreen, this); + } + + const pass = + element === null + ? false + : screen.UNSAFE_root === getUnsafeRootElement(element); + + const errorFound = () => { + return `expected element tree not to contain element, but found\n${formatElement( + element + )}`; + }; + + const errorNotFound = () => { + return `element could not be found in the element tree`; + }; + + return { + pass, + message: () => { + return [ + matcherHint( + `${this.isNot ? '.not' : ''}.toBeOnTheScreen`, + 'element', + '' + ), + '', + RECEIVED_COLOR(this.isNot ? errorFound() : errorNotFound()), + ].join('\n'); + }, + }; +} diff --git a/src/matchers/utils.tsx b/src/matchers/utils.tsx new file mode 100644 index 000000000..51d9f7d1f --- /dev/null +++ b/src/matchers/utils.tsx @@ -0,0 +1,91 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import { + RECEIVED_COLOR, + matcherHint, + printWithType, + printReceived, +} from 'jest-matcher-utils'; +import prettyFormat, { plugins } from 'pretty-format'; +import redent from 'redent'; +import { isHostElement } from '../helpers/component-tree'; + +class HostElementTypeError extends Error { + constructor( + received: unknown, + matcherFn: jest.CustomMatcher, + context: jest.MatcherContext + ) { + super(); + + /* istanbul ignore next */ + if (Error.captureStackTrace) { + Error.captureStackTrace(this, matcherFn); + } + + let withType = ''; + try { + withType = printWithType('Received', received, printReceived); + /* istanbul ignore next */ + } catch (e) { + // Deliberately empty. + } + + this.message = [ + matcherHint( + `${context.isNot ? '.not' : ''}.${matcherFn.name}`, + 'received', + '' + ), + '', + `${RECEIVED_COLOR('received')} value must be a host element.`, + withType, + ].join('\n'); + } +} + +/** + * Throws HostElementTypeError if passed element is not a host element. + * + * @param element ReactTestInstance to check. + * @param matcherFn Matcher function calling the check used for formatting error. + * @param context Jest matcher context used for formatting error. + */ +export function checkHostElement( + element: ReactTestInstance | null | undefined, + matcherFn: jest.CustomMatcher, + context: jest.MatcherContext +): asserts element is ReactTestInstance { + if (!isHostElement(element)) { + throw new HostElementTypeError(element, matcherFn, context); + } +} + +/*** + * Format given element as a pretty-printed string. + * + * @param element Element to format. + */ +export function formatElement(element: ReactTestInstance | null) { + if (element == null) { + return 'null'; + } + + return redent( + prettyFormat( + { + // This prop is needed persuade the prettyFormat that the element is + // a ReactTestRendererJSON instance, so it is formatted as JSX. + $$typeof: Symbol.for('react.test.json'), + type: element.type, + props: element.props, + }, + { + plugins: [plugins.ReactTestComponent, plugins.ReactElement], + printFunctionName: false, + printBasicPrototype: false, + highlight: true, + } + ), + 2 + ); +} diff --git a/yarn.lock b/yarn.lock index d0b3bedc8..86e8789dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4664,6 +4664,16 @@ jest-diff@^29.0.1, jest-diff@^29.6.1: jest-get-type "^29.4.3" pretty-format "^29.6.1" +jest-diff@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.6.2.tgz#c36001e5543e82a0805051d3ceac32e6825c1c46" + integrity sha512-t+ST7CB9GX5F2xKwhwCf0TAR17uNDiaPTZnVymP9lw0lssa9vG+AFyDZoeIHStU3WowFFwT+ky+er0WVl2yGhA== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.4.3" + jest-get-type "^29.4.3" + pretty-format "^29.6.2" + jest-docblock@^29.4.3: version "29.4.3" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.4.3.tgz#90505aa89514a1c7dceeac1123df79e414636ea8" @@ -4741,6 +4751,16 @@ jest-matcher-utils@^29.0.1, jest-matcher-utils@^29.6.1: jest-get-type "^29.4.3" pretty-format "^29.6.1" +jest-matcher-utils@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.6.2.tgz#39de0be2baca7a64eacb27291f0bd834fea3a535" + integrity sha512-4LiAk3hSSobtomeIAzFTe+N8kL6z0JtF3n6I4fg29iIW7tt99R7ZcIFW34QkX+DuVrf+CUe6wuVOpm7ZKFJzZQ== + dependencies: + chalk "^4.0.0" + jest-diff "^29.6.2" + jest-get-type "^29.4.3" + pretty-format "^29.6.2" + jest-message-util@^29.6.1: version "29.6.1" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.6.1.tgz#d0b21d87f117e1b9e165e24f245befd2ff34ff8d" @@ -6279,6 +6299,15 @@ pretty-format@^29.0.0, pretty-format@^29.0.3, pretty-format@^29.6.1: ansi-styles "^5.0.0" react-is "^18.0.0" +pretty-format@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.6.2.tgz#3d5829261a8a4d89d8b9769064b29c50ed486a47" + integrity sha512-1q0oC8eRveTg5nnBEWMXAU2qpv65Gnuf2eCQzSjxpWFkPaPARwqZZDGuNE0zPAZfTCHzIk3A8dIjwlQKKLphyg== + dependencies: + "@jest/schemas" "^29.6.0" + ansi-styles "^5.0.0" + react-is "^18.0.0" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"