Skip to content

Commit 4bfd7fb

Browse files
feature: Jest matchers core (#1454)
* chore: jest matchers. * refactor: cleanup * chore: test for not exposing matchers by default * chore: cleanup * refactor: finishing touches * chore: improve code cov * Update package.json Co-authored-by: Michał Pierzchała <[email protected]> * refactor: code review changes
1 parent cae0583 commit 4bfd7fb

File tree

11 files changed

+337
-1
lines changed

11 files changed

+337
-1
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@
6060
"typescript": "^5.0.2"
6161
},
6262
"dependencies": {
63-
"pretty-format": "^29.0.0"
63+
"jest-matcher-utils": "^29.6.2",
64+
"pretty-format": "^29.6.2",
65+
"redent": "^3.0.0"
6466
},
6567
"peerDependencies": {
6668
"jest": ">=28.0.0",

src/helpers/__tests__/component-tree.test.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getHostParent,
77
getHostSelves,
88
getHostSiblings,
9+
getUnsafeRootElement,
910
} from '../component-tree';
1011

1112
function ZeroHostChildren() {
@@ -224,3 +225,20 @@ describe('getHostSiblings()', () => {
224225
]);
225226
});
226227
});
228+
229+
describe('getUnsafeRootElement()', () => {
230+
it('returns UNSAFE_root for mounted view', () => {
231+
const screen = render(
232+
<View>
233+
<View testID="view" />
234+
</View>
235+
);
236+
237+
const view = screen.getByTestId('view');
238+
expect(getUnsafeRootElement(view)).toEqual(screen.UNSAFE_root);
239+
});
240+
241+
it('returns null for null', () => {
242+
expect(getUnsafeRootElement(null)).toEqual(null);
243+
});
244+
});

src/helpers/component-tree.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,22 @@ export function getHostSiblings(
9292
(sibling) => !hostSelves.includes(sibling)
9393
);
9494
}
95+
96+
/**
97+
* Returns the unsafe root element of the tree (probably composite).
98+
*
99+
* @param element The element start traversing from.
100+
* @returns The root element of the tree (host or composite).
101+
*/
102+
export function getUnsafeRootElement(element: ReactTestInstance | null) {
103+
if (element == null) {
104+
return null;
105+
}
106+
107+
let current = element;
108+
while (current.parent) {
109+
current = current.parent;
110+
}
111+
112+
return current;
113+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as React from 'react';
2+
import { View } from 'react-native';
3+
4+
// Note: that must point to root of the /src to reliably replicate default import.
5+
import { render } from '../..';
6+
7+
// This is check that RNTL does not extend "expect" by default, until we actually want to expose Jest matchers publically.
8+
test('does not extend "expect" by default', () => {
9+
render(<View />);
10+
11+
// @ts-expect-error
12+
expect(expect.toBeOnTheScreen).toBeUndefined();
13+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import * as React from 'react';
2+
import { View, Text } from 'react-native';
3+
import { render, screen } from '../..';
4+
import '../extend-expect';
5+
6+
test('example test', () => {
7+
render(
8+
<View>
9+
<View testID="child" />
10+
</View>
11+
);
12+
13+
const child = screen.getByTestId('child');
14+
expect(child).toBeOnTheScreen();
15+
16+
screen.update(<View />);
17+
expect(child).not.toBeOnTheScreen();
18+
});
19+
20+
test('toBeOnTheScreen() on attached element', () => {
21+
render(<View testID="test" />);
22+
23+
const element = screen.getByTestId('test');
24+
expect(element).toBeOnTheScreen();
25+
expect(() => expect(element).not.toBeOnTheScreen())
26+
.toThrowErrorMatchingInlineSnapshot(`
27+
"expect(element).not.toBeOnTheScreen()
28+
29+
expected element tree not to contain element, but found
30+
<View
31+
testID="test"
32+
/>"
33+
`);
34+
});
35+
36+
function ShowChildren({ show }: { show: boolean }) {
37+
return show ? (
38+
<View>
39+
<Text testID="text">Hello</Text>
40+
</View>
41+
) : (
42+
<View />
43+
);
44+
}
45+
46+
test('toBeOnTheScreen() on detached element', () => {
47+
render(<ShowChildren show={true} />);
48+
49+
const element = screen.getByTestId('text');
50+
// Next line will unmount the element, yet `element` variable will still hold reference to it.
51+
screen.update(<ShowChildren show={false} />);
52+
53+
expect(element).toBeTruthy();
54+
expect(element).not.toBeOnTheScreen();
55+
expect(() => expect(element).toBeOnTheScreen())
56+
.toThrowErrorMatchingInlineSnapshot(`
57+
"expect(element).toBeOnTheScreen()
58+
59+
element could not be found in the element tree"
60+
`);
61+
});
62+
63+
test('toBeOnTheScreen() on null element', () => {
64+
expect(null).not.toBeOnTheScreen();
65+
expect(() => expect(null).toBeOnTheScreen())
66+
.toThrowErrorMatchingInlineSnapshot(`
67+
"expect(received).toBeOnTheScreen()
68+
69+
received value must be a host element.
70+
Received has value: null"
71+
`);
72+
});

src/matchers/__tests__/utils.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React from 'react';
2+
import { View } from 'react-native';
3+
import { render } from '../..';
4+
import { formatElement, checkHostElement } from '../utils';
5+
6+
function fakeMatcher() {
7+
// Do nothing.
8+
}
9+
10+
test('formatElement', () => {
11+
expect(formatElement(null)).toMatchInlineSnapshot(`"null"`);
12+
});
13+
14+
test('checkHostElement allows host element', () => {
15+
const screen = render(<View testID="view" />);
16+
17+
expect(() => {
18+
// @ts-expect-error
19+
checkHostElement(screen.getByTestId('view'), fakeMatcher, {});
20+
}).not.toThrow();
21+
});
22+
23+
test('checkHostElement allows rejects composite element', () => {
24+
const screen = render(<View testID="view" />);
25+
26+
expect(() => {
27+
// @ts-expect-error
28+
checkHostElement(screen.UNSAFE_root, fakeMatcher, {});
29+
}).toThrow(/value must be a host element./);
30+
});
31+
32+
test('checkHostElement allows rejects null element', () => {
33+
expect(() => {
34+
// @ts-expect-error
35+
checkHostElement(null, fakeMatcher, {});
36+
}).toThrowErrorMatchingInlineSnapshot(`
37+
"expect(received).fakeMatcher()
38+
39+
received value must be a host element.
40+
Received has value: null"
41+
`);
42+
});

src/matchers/extend-expect.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { toBeOnTheScreen } from './to-be-on-the-screen';
2+
3+
expect.extend({
4+
toBeOnTheScreen,
5+
});

src/matchers/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { toBeOnTheScreen } from './to-be-on-the-screen';

src/matchers/to-be-on-the-screen.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { ReactTestInstance } from 'react-test-renderer';
2+
import { matcherHint, RECEIVED_COLOR } from 'jest-matcher-utils';
3+
import { getUnsafeRootElement } from '../helpers/component-tree';
4+
import { screen } from '../screen';
5+
import { checkHostElement, formatElement } from './utils';
6+
7+
export function toBeOnTheScreen(
8+
this: jest.MatcherContext,
9+
element: ReactTestInstance
10+
) {
11+
if (element !== null || !this.isNot) {
12+
checkHostElement(element, toBeOnTheScreen, this);
13+
}
14+
15+
const pass =
16+
element === null
17+
? false
18+
: screen.UNSAFE_root === getUnsafeRootElement(element);
19+
20+
const errorFound = () => {
21+
return `expected element tree not to contain element, but found\n${formatElement(
22+
element
23+
)}`;
24+
};
25+
26+
const errorNotFound = () => {
27+
return `element could not be found in the element tree`;
28+
};
29+
30+
return {
31+
pass,
32+
message: () => {
33+
return [
34+
matcherHint(
35+
`${this.isNot ? '.not' : ''}.toBeOnTheScreen`,
36+
'element',
37+
''
38+
),
39+
'',
40+
RECEIVED_COLOR(this.isNot ? errorFound() : errorNotFound()),
41+
].join('\n');
42+
},
43+
};
44+
}

src/matchers/utils.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { ReactTestInstance } from 'react-test-renderer';
2+
import {
3+
RECEIVED_COLOR,
4+
matcherHint,
5+
printWithType,
6+
printReceived,
7+
} from 'jest-matcher-utils';
8+
import prettyFormat, { plugins } from 'pretty-format';
9+
import redent from 'redent';
10+
import { isHostElement } from '../helpers/component-tree';
11+
12+
class HostElementTypeError extends Error {
13+
constructor(
14+
received: unknown,
15+
matcherFn: jest.CustomMatcher,
16+
context: jest.MatcherContext
17+
) {
18+
super();
19+
20+
/* istanbul ignore next */
21+
if (Error.captureStackTrace) {
22+
Error.captureStackTrace(this, matcherFn);
23+
}
24+
25+
let withType = '';
26+
try {
27+
withType = printWithType('Received', received, printReceived);
28+
/* istanbul ignore next */
29+
} catch (e) {
30+
// Deliberately empty.
31+
}
32+
33+
this.message = [
34+
matcherHint(
35+
`${context.isNot ? '.not' : ''}.${matcherFn.name}`,
36+
'received',
37+
''
38+
),
39+
'',
40+
`${RECEIVED_COLOR('received')} value must be a host element.`,
41+
withType,
42+
].join('\n');
43+
}
44+
}
45+
46+
/**
47+
* Throws HostElementTypeError if passed element is not a host element.
48+
*
49+
* @param element ReactTestInstance to check.
50+
* @param matcherFn Matcher function calling the check used for formatting error.
51+
* @param context Jest matcher context used for formatting error.
52+
*/
53+
export function checkHostElement(
54+
element: ReactTestInstance | null | undefined,
55+
matcherFn: jest.CustomMatcher,
56+
context: jest.MatcherContext
57+
): asserts element is ReactTestInstance {
58+
if (!isHostElement(element)) {
59+
throw new HostElementTypeError(element, matcherFn, context);
60+
}
61+
}
62+
63+
/***
64+
* Format given element as a pretty-printed string.
65+
*
66+
* @param element Element to format.
67+
*/
68+
export function formatElement(element: ReactTestInstance | null) {
69+
if (element == null) {
70+
return 'null';
71+
}
72+
73+
return redent(
74+
prettyFormat(
75+
{
76+
// This prop is needed persuade the prettyFormat that the element is
77+
// a ReactTestRendererJSON instance, so it is formatted as JSX.
78+
$$typeof: Symbol.for('react.test.json'),
79+
type: element.type,
80+
props: element.props,
81+
},
82+
{
83+
plugins: [plugins.ReactTestComponent, plugins.ReactElement],
84+
printFunctionName: false,
85+
printBasicPrototype: false,
86+
highlight: true,
87+
}
88+
),
89+
2
90+
);
91+
}

yarn.lock

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4664,6 +4664,16 @@ jest-diff@^29.0.1, jest-diff@^29.6.1:
46644664
jest-get-type "^29.4.3"
46654665
pretty-format "^29.6.1"
46664666

4667+
jest-diff@^29.6.2:
4668+
version "29.6.2"
4669+
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.6.2.tgz#c36001e5543e82a0805051d3ceac32e6825c1c46"
4670+
integrity sha512-t+ST7CB9GX5F2xKwhwCf0TAR17uNDiaPTZnVymP9lw0lssa9vG+AFyDZoeIHStU3WowFFwT+ky+er0WVl2yGhA==
4671+
dependencies:
4672+
chalk "^4.0.0"
4673+
diff-sequences "^29.4.3"
4674+
jest-get-type "^29.4.3"
4675+
pretty-format "^29.6.2"
4676+
46674677
jest-docblock@^29.4.3:
46684678
version "29.4.3"
46694679
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:
47414751
jest-get-type "^29.4.3"
47424752
pretty-format "^29.6.1"
47434753

4754+
jest-matcher-utils@^29.6.2:
4755+
version "29.6.2"
4756+
resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.6.2.tgz#39de0be2baca7a64eacb27291f0bd834fea3a535"
4757+
integrity sha512-4LiAk3hSSobtomeIAzFTe+N8kL6z0JtF3n6I4fg29iIW7tt99R7ZcIFW34QkX+DuVrf+CUe6wuVOpm7ZKFJzZQ==
4758+
dependencies:
4759+
chalk "^4.0.0"
4760+
jest-diff "^29.6.2"
4761+
jest-get-type "^29.4.3"
4762+
pretty-format "^29.6.2"
4763+
47444764
jest-message-util@^29.6.1:
47454765
version "29.6.1"
47464766
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:
62796299
ansi-styles "^5.0.0"
62806300
react-is "^18.0.0"
62816301

6302+
pretty-format@^29.6.2:
6303+
version "29.6.2"
6304+
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.6.2.tgz#3d5829261a8a4d89d8b9769064b29c50ed486a47"
6305+
integrity sha512-1q0oC8eRveTg5nnBEWMXAU2qpv65Gnuf2eCQzSjxpWFkPaPARwqZZDGuNE0zPAZfTCHzIk3A8dIjwlQKKLphyg==
6306+
dependencies:
6307+
"@jest/schemas" "^29.6.0"
6308+
ansi-styles "^5.0.0"
6309+
react-is "^18.0.0"
6310+
62826311
process-nextick-args@~2.0.0:
62836312
version "2.0.1"
62846313
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"

0 commit comments

Comments
 (0)