Skip to content

feature: Jest matchers core #1454

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Aug 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions src/helpers/__tests__/component-tree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getHostParent,
getHostSelves,
getHostSiblings,
getUnsafeRootElement,
} from '../component-tree';

function ZeroHostChildren() {
Expand Down Expand Up @@ -224,3 +225,20 @@ describe('getHostSiblings()', () => {
]);
});
});

describe('getUnsafeRootElement()', () => {
it('returns UNSAFE_root for mounted view', () => {
const screen = render(
<View>
<View testID="view" />
</View>
);

const view = screen.getByTestId('view');
expect(getUnsafeRootElement(view)).toEqual(screen.UNSAFE_root);
});

it('returns null for null', () => {
expect(getUnsafeRootElement(null)).toEqual(null);
});
});
19 changes: 19 additions & 0 deletions src/helpers/component-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
13 changes: 13 additions & 0 deletions src/matchers/__tests__/extend-expect.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<View />);

// @ts-expect-error
expect(expect.toBeOnTheScreen).toBeUndefined();
});
72 changes: 72 additions & 0 deletions src/matchers/__tests__/to-be-on-the-screen.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<View>
<View testID="child" />
</View>
);

const child = screen.getByTestId('child');
expect(child).toBeOnTheScreen();

screen.update(<View />);
expect(child).not.toBeOnTheScreen();
});

test('toBeOnTheScreen() on attached element', () => {
render(<View testID="test" />);

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
<View
testID="test"
/>"
`);
});

function ShowChildren({ show }: { show: boolean }) {
return show ? (
<View>
<Text testID="text">Hello</Text>
</View>
) : (
<View />
);
}

test('toBeOnTheScreen() on detached element', () => {
render(<ShowChildren show={true} />);

const element = screen.getByTestId('text');
// Next line will unmount the element, yet `element` variable will still hold reference to it.
screen.update(<ShowChildren show={false} />);

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"
`);
});
42 changes: 42 additions & 0 deletions src/matchers/__tests__/utils.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<View testID="view" />);

expect(() => {
// @ts-expect-error
checkHostElement(screen.getByTestId('view'), fakeMatcher, {});
}).not.toThrow();
});

test('checkHostElement allows rejects composite element', () => {
const screen = render(<View testID="view" />);

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"
`);
});
5 changes: 5 additions & 0 deletions src/matchers/extend-expect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { toBeOnTheScreen } from './to-be-on-the-screen';

expect.extend({
toBeOnTheScreen,
});
1 change: 1 addition & 0 deletions src/matchers/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { toBeOnTheScreen } from './to-be-on-the-screen';
44 changes: 44 additions & 0 deletions src/matchers/to-be-on-the-screen.tsx
Original file line number Diff line number Diff line change
@@ -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');
},
};
}
91 changes: 91 additions & 0 deletions src/matchers/utils.tsx
Original file line number Diff line number Diff line change
@@ -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
);
}
29 changes: 29 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down