Skip to content

Commit 2d96b77

Browse files
feat: toHaveProp matcher (#1477)
* feat: implement toHaveProp * refactor: use screen in the test * refactor: tweaks * refactor: tweaks * refactor: final polishing * refactor: cleanup --------- Co-authored-by: Maciej Jastrzebski <[email protected]>
1 parent 4659bba commit 2d96b77

9 files changed

+162
-16
lines changed

src/matchers/__tests__/to-be-disabled.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { render } from '../..';
1414
import '../extend-expect';
1515

16-
test('toBeDisabled/toBeEnabled supports basic case', () => {
16+
test('toBeDisabled()/toBeEnabled() supports basic case', () => {
1717
const screen = render(
1818
<View>
1919
<View testID="disabled-parent" aria-disabled>
@@ -87,7 +87,7 @@ test('toBeDisabled/toBeEnabled supports basic case', () => {
8787
`);
8888
});
8989

90-
test('toBeDisabled/toBeEnabled supports Pressable with "disabled" prop', () => {
90+
test('toBeDisabled()/toBeEnabled() supports Pressable with "disabled" prop', () => {
9191
const screen = render(
9292
<Pressable disabled testID="subject">
9393
<Text>Button</Text>
@@ -161,7 +161,7 @@ test.each([
161161
['TouchableWithoutFeedback', TouchableWithoutFeedback],
162162
['TouchableNativeFeedback', TouchableNativeFeedback],
163163
] as const)(
164-
'toBeDisabled/toBeEnabled supports %s with "disabled" prop',
164+
'toBeDisabled()/toBeEnabled() supports %s with "disabled" prop',
165165
(_, Component) => {
166166
const screen = render(
167167
// @ts-expect-error disabled prop is not available on all Touchables
@@ -194,7 +194,7 @@ test.each([
194194
['TouchableWithoutFeedback', TouchableWithoutFeedback],
195195
['TouchableNativeFeedback', TouchableNativeFeedback],
196196
] as const)(
197-
'toBeDisabled/toBeEnabled supports %s with "aria-disabled" prop',
197+
'toBeDisabled()/toBeEnabled() supports %s with "aria-disabled" prop',
198198
(_, Component) => {
199199
const screen = render(
200200
// @ts-expect-error too generic for typescript
@@ -221,7 +221,7 @@ test.each([
221221
['TouchableWithoutFeedback', TouchableWithoutFeedback],
222222
['TouchableNativeFeedback', TouchableNativeFeedback],
223223
] as const)(
224-
'toBeDisabled/toBeEnabled supports %s with "accessibilityState.disabled" prop',
224+
'toBeDisabled()/toBeEnabled() supports %s with "accessibilityState.disabled" prop',
225225
(_, Component) => {
226226
const screen = render(
227227
// @ts-expect-error disabled prop is not available on all Touchables
@@ -238,7 +238,7 @@ test.each([
238238
}
239239
);
240240

241-
test('toBeDisabled/toBeEnabled supports "editable" prop on TextInput', () => {
241+
test('toBeDisabled()/toBeEnabled() supports "editable" prop on TextInput', () => {
242242
const screen = render(
243243
<View>
244244
<TextInput testID="enabled-by-default" />
@@ -256,7 +256,7 @@ test('toBeDisabled/toBeEnabled supports "editable" prop on TextInput', () => {
256256
expect(screen.getByTestId('disabled')).not.toBeEnabled();
257257
});
258258

259-
test('toBeDisabled/toBeEnabled supports "disabled" prop on Button', () => {
259+
test('toBeDisabled()/toBeEnabled() supports "disabled" prop on Button', () => {
260260
const screen = render(
261261
<View>
262262
<Button testID="enabled" title="enabled" />

src/matchers/__tests__/to-be-empty-element.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ function DoNotRenderChildren({ children }: { children: React.ReactNode }) {
99
return null;
1010
}
1111

12-
test('toBeEmptyElement()', () => {
12+
test('toBeEmptyElement() base case', () => {
1313
render(
1414
<View testID="not-empty">
1515
<View testID="empty" />

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { View, Text } from 'react-native';
33
import { render, screen } from '../..';
44
import '../extend-expect';
55

6-
test('example test', () => {
6+
test('toBeOnTheScreen() example test', () => {
77
render(
88
<View>
99
<View testID="child" />

src/matchers/__tests__/to-have-display-value.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { TextInput, View } from 'react-native';
33
import { render, screen } from '../..';
44
import '../extend-expect';
55

6-
test('example test', () => {
6+
test('toHaveDisplayValue() example test', () => {
77
render(<TextInput testID="text-input" value="test" />);
88

99
const textInput = screen.getByTestId('text-input');
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import React from 'react';
2+
import { View, Text, TextInput } from 'react-native';
3+
import { render, screen } from '../..';
4+
import '../extend-expect';
5+
6+
test('toHaveProp() basic case', () => {
7+
render(
8+
<View testID="view" style={null}>
9+
<Text ellipsizeMode="head">Hello</Text>
10+
<TextInput testID="input" textAlign="right" />
11+
</View>
12+
);
13+
14+
const view = screen.getByTestId('view');
15+
expect(view).toHaveProp('style');
16+
expect(view).toHaveProp('style', null);
17+
expect(view).not.toHaveProp('ellipsizeMode');
18+
19+
const text = screen.getByText('Hello');
20+
expect(text).toHaveProp('ellipsizeMode');
21+
expect(text).toHaveProp('ellipsizeMode', 'head');
22+
expect(text).not.toHaveProp('style');
23+
expect(text).not.toHaveProp('ellipsizeMode', 'tail');
24+
25+
const input = screen.getByTestId('input');
26+
expect(input).toHaveProp('textAlign');
27+
expect(input).toHaveProp('textAlign', 'right');
28+
expect(input).not.toHaveProp('textAlign', 'left');
29+
expect(input).not.toHaveProp('editable');
30+
expect(input).not.toHaveProp('editable', false);
31+
});
32+
33+
test('toHaveProp() error messages', () => {
34+
render(<View testID="view" collapsable={false} />);
35+
36+
const view = screen.getByTestId('view');
37+
38+
expect(() => expect(view).toHaveProp('accessible'))
39+
.toThrowErrorMatchingInlineSnapshot(`
40+
"expect(element).toHaveProp("accessible")
41+
42+
Expected element to have prop:
43+
accessible
44+
Received:
45+
undefined"
46+
`);
47+
48+
expect(() => expect(view).toHaveProp('accessible', true))
49+
.toThrowErrorMatchingInlineSnapshot(`
50+
"expect(element).toHaveProp("accessible", true)
51+
52+
Expected element to have prop:
53+
accessible={true}
54+
Received:
55+
undefined"
56+
`);
57+
58+
expect(() => expect(view).not.toHaveProp('collapsable'))
59+
.toThrowErrorMatchingInlineSnapshot(`
60+
"expect(element).not.toHaveProp("collapsable")
61+
62+
Expected element not to have prop:
63+
collapsable
64+
Received:
65+
collapsable={false}"
66+
`);
67+
68+
expect(() => expect(view).toHaveProp('collapsable', true))
69+
.toThrowErrorMatchingInlineSnapshot(`
70+
"expect(element).toHaveProp("collapsable", true)
71+
72+
Expected element to have prop:
73+
collapsable={true}
74+
Received:
75+
collapsable={false}"
76+
`);
77+
78+
expect(() => expect(view).not.toHaveProp('collapsable', false))
79+
.toThrowErrorMatchingInlineSnapshot(`
80+
"expect(element).not.toHaveProp("collapsable", false)
81+
82+
Expected element not to have prop:
83+
collapsable={false}
84+
Received:
85+
collapsable={false}"
86+
`);
87+
});

src/matchers/extend-expect.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import type { TextMatch, TextMatchOptions } from '../matches';
22

33
export interface JestNativeMatchers<R> {
44
toBeOnTheScreen(): R;
5+
toBeDisabled(): R;
56
toBeEmptyElement(): R;
7+
toBeEnabled(): R;
68
toBeVisible(): R;
79
toHaveDisplayValue(expectedValue: TextMatch, options?: TextMatchOptions): R;
10+
toHaveProp(name: string, expectedValue?: unknown): R;
811
toHaveTextContent(expectedText: TextMatch, options?: TextMatchOptions): R;
9-
toBeDisabled(): R;
10-
toBeEnabled(): R;
1112
}
1213

1314
// Implicit Jest global `expect`.

src/matchers/extend-expect.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
/// <reference path="./extend-expect.d.ts" />
22

33
import { toBeOnTheScreen } from './to-be-on-the-screen';
4+
import { toBeDisabled, toBeEnabled } from './to-be-disabled';
45
import { toBeEmptyElement } from './to-be-empty-element';
56
import { toBeVisible } from './to-be-visible';
67
import { toHaveDisplayValue } from './to-have-display-value';
8+
import { toHaveProp } from './to-have-prop';
79
import { toHaveTextContent } from './to-have-text-content';
8-
import { toBeDisabled, toBeEnabled } from './to-be-disabled';
910

1011
expect.extend({
1112
toBeOnTheScreen,
13+
toBeDisabled,
1214
toBeEmptyElement,
15+
toBeEnabled,
1316
toBeVisible,
1417
toHaveDisplayValue,
18+
toHaveProp,
1519
toHaveTextContent,
16-
toBeDisabled,
17-
toBeEnabled,
1820
});

src/matchers/to-have-prop.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { ReactTestInstance } from 'react-test-renderer';
2+
import { matcherHint, stringify, printExpected } from 'jest-matcher-utils';
3+
import { checkHostElement, formatMessage } from './utils';
4+
5+
export function toHaveProp(
6+
this: jest.MatcherContext,
7+
element: ReactTestInstance,
8+
name: string,
9+
expectedValue: unknown
10+
) {
11+
checkHostElement(element, toHaveProp, this);
12+
13+
const isExpectedValueDefined = expectedValue !== undefined;
14+
const hasProp = name in element.props;
15+
const receivedValue = element.props[name];
16+
17+
const pass = isExpectedValueDefined
18+
? hasProp && this.equals(expectedValue, receivedValue)
19+
: hasProp;
20+
21+
return {
22+
pass,
23+
message: () => {
24+
const to = this.isNot ? 'not to' : 'to';
25+
const matcher = matcherHint(
26+
`${this.isNot ? '.not' : ''}.toHaveProp`,
27+
'element',
28+
printExpected(name),
29+
{
30+
secondArgument: isExpectedValueDefined
31+
? printExpected(expectedValue)
32+
: undefined,
33+
}
34+
);
35+
return formatMessage(
36+
matcher,
37+
`Expected element ${to} have prop`,
38+
formatProp(name, expectedValue),
39+
'Received',
40+
hasProp ? formatProp(name, receivedValue) : undefined
41+
);
42+
},
43+
};
44+
}
45+
46+
function formatProp(name: string, value: unknown) {
47+
if (value === undefined) {
48+
return name;
49+
}
50+
51+
if (typeof value === 'string') {
52+
return `${name}="${value}"`;
53+
}
54+
55+
return `${name}={${stringify(value)}}`;
56+
}

src/matchers/utils.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export function formatMessage(
111111
expectedLabel: string,
112112
expectedValue: string | RegExp,
113113
receivedLabel: string,
114-
receivedValue: string | null
114+
receivedValue: string | null | undefined
115115
) {
116116
return [
117117
`${matcher}\n`,

0 commit comments

Comments
 (0)