Skip to content

Commit 3b9c4da

Browse files
feat: toHaveStyle matcher (#1487)
* feat: implement toHaveStyle with tests * refactor: tweaks and cleanup --------- Co-authored-by: Marcin Kornek <[email protected]> Co-authored-by: Maciej Jastrzebski <[email protected]>
1 parent 701433c commit 3b9c4da

File tree

6 files changed

+268
-0
lines changed

6 files changed

+268
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
"flow": "flow",
8383
"copy-flowtypes": "cp typings/index.flow.js build",
8484
"lint": "eslint src --cache",
85+
"validate": "yarn lint && yarn typecheck && yarn test",
8586
"prepublish": "yarn build",
8687
"build:js": "babel src --out-dir build --extensions \".js,.ts,.jsx,.tsx\" --source-maps --ignore \"**/__tests__/**\"",
8788
"build:js:watch": "yarn build:js --watch",
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import React from 'react';
2+
import { StyleSheet, View, Pressable } from 'react-native';
3+
import { render } from '../..';
4+
import '../extend-expect';
5+
6+
const styles = StyleSheet.create({
7+
container: { borderBottomColor: 'white' },
8+
});
9+
10+
test('toHaveStyle() handles basic cases', () => {
11+
const screen = render(
12+
<View
13+
testID="view"
14+
style={[
15+
{
16+
backgroundColor: 'blue',
17+
height: '40%',
18+
transform: [{ scale: 2 }, { rotate: '45deg' }],
19+
},
20+
[{ height: '100%' }],
21+
[[{ width: '50%' }]],
22+
styles.container,
23+
]}
24+
/>
25+
);
26+
27+
const view = screen.getByTestId('view');
28+
expect(view).toHaveStyle({ backgroundColor: 'blue' });
29+
expect(view).toHaveStyle({ height: '100%' });
30+
expect(view).toHaveStyle({ backgroundColor: 'blue', height: '100%' });
31+
expect(view).toHaveStyle([{ backgroundColor: 'blue' }, { height: '100%' }]);
32+
33+
expect(view).toHaveStyle({ borderBottomColor: 'white' });
34+
expect(view).toHaveStyle({ width: '50%' });
35+
expect(view).toHaveStyle([[{ width: '50%' }]]);
36+
expect(view).toHaveStyle({
37+
transform: [{ scale: 2 }, { rotate: '45deg' }],
38+
});
39+
40+
expect(view).not.toHaveStyle({ backgroundColor: 'red' });
41+
expect(view).not.toHaveStyle({ height: '50%' });
42+
expect(view).not.toHaveStyle({ backgroundColor: 'blue', height: '50%' });
43+
expect(view).not.toHaveStyle([
44+
{ backgroundColor: 'blue' },
45+
{ height: '50%' },
46+
]);
47+
expect(view).not.toHaveStyle({
48+
transform: [{ scale: 2 }],
49+
});
50+
expect(view).not.toHaveStyle({
51+
transform: [{ rotate: '45deg' }, { scale: 2 }],
52+
});
53+
});
54+
55+
test('toHaveStyle error messages', () => {
56+
const screen = render(
57+
<View
58+
testID="view"
59+
style={{
60+
backgroundColor: 'blue',
61+
borderBottomColor: 'black',
62+
height: '100%',
63+
transform: [{ scale: 2 }, { rotate: '45deg' }],
64+
}}
65+
/>
66+
);
67+
68+
const view = screen.getByTestId('view');
69+
expect(() => expect(view).toHaveStyle({ backgroundColor: 'red' }))
70+
.toThrowErrorMatchingInlineSnapshot(`
71+
"expect(element).toHaveStyle()
72+
73+
- Expected
74+
+ Received
75+
76+
- backgroundColor: red;
77+
+ backgroundColor: blue;"
78+
`);
79+
80+
expect(() =>
81+
expect(view).toHaveStyle({
82+
backgroundColor: 'blue',
83+
transform: [{ scale: 1 }],
84+
})
85+
).toThrowErrorMatchingInlineSnapshot(`
86+
"expect(element).toHaveStyle()
87+
88+
- Expected
89+
+ Received
90+
91+
backgroundColor: blue;
92+
transform: [
93+
{
94+
- "scale": 1
95+
+ "scale": 2
96+
+ },
97+
+ {
98+
+ "rotate": "45deg"
99+
}
100+
];"
101+
`);
102+
103+
expect(() => expect(view).not.toHaveStyle({ backgroundColor: 'blue' }))
104+
.toThrowErrorMatchingInlineSnapshot(`
105+
"expect(element).not.toHaveStyle()
106+
107+
Expected element not to have style:
108+
backgroundColor: blue;
109+
Received:
110+
backgroundColor: blue;"
111+
`);
112+
113+
expect(() => expect(view).toHaveStyle({ fontWeight: 'bold' }))
114+
.toThrowErrorMatchingInlineSnapshot(`
115+
"expect(element).toHaveStyle()
116+
117+
- Expected
118+
+ Received
119+
120+
- fontWeight: bold;"
121+
`);
122+
123+
expect(() => expect(view).not.toHaveStyle({ backgroundColor: 'blue' }))
124+
.toThrowErrorMatchingInlineSnapshot(`
125+
"expect(element).not.toHaveStyle()
126+
127+
Expected element not to have style:
128+
backgroundColor: blue;
129+
Received:
130+
backgroundColor: blue;"
131+
`);
132+
});
133+
134+
test('toHaveStyle() supports missing "style" prop', () => {
135+
const screen = render(<View testID="view" />);
136+
137+
const view = screen.getByTestId('view');
138+
expect(view).not.toHaveStyle({ fontWeight: 'bold' });
139+
});
140+
141+
test('toHaveStyle() supports undefined "transform" style', () => {
142+
const screen = render(
143+
<View
144+
testID="view"
145+
style={{
146+
backgroundColor: 'blue',
147+
transform: undefined,
148+
}}
149+
/>
150+
);
151+
152+
const view = screen.getByTestId('view');
153+
expect(() => expect(view).toHaveStyle({ transform: [{ scale: 1 }] }))
154+
.toThrowErrorMatchingInlineSnapshot(`
155+
"expect(element).toHaveStyle()
156+
157+
- Expected
158+
+ Received
159+
160+
- transform: [
161+
- {
162+
- "scale": 1
163+
- }
164+
- ];
165+
+ transform: undefined;"
166+
`);
167+
});
168+
169+
test('toHaveStyle() supports Pressable with function "style" prop', () => {
170+
const screen = render(
171+
<Pressable testID="view" style={() => ({ backgroundColor: 'blue' })} />
172+
);
173+
174+
expect(screen.getByTestId('view')).toHaveStyle({ backgroundColor: 'blue' });
175+
});

src/matchers/extend-expect.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import type { StyleProp } from 'react-native';
12
import type { TextMatch, TextMatchOptions } from '../matches';
3+
import type { Style } from './to-have-style';
24

35
export interface JestNativeMatchers<R> {
46
toBeOnTheScreen(): R;
@@ -12,6 +14,7 @@ export interface JestNativeMatchers<R> {
1214
toBeVisible(): R;
1315
toHaveDisplayValue(expectedValue: TextMatch, options?: TextMatchOptions): R;
1416
toHaveProp(name: string, expectedValue?: unknown): R;
17+
toHaveStyle(style: StyleProp<Style>): R;
1518
toHaveTextContent(expectedText: TextMatch, options?: TextMatchOptions): R;
1619
}
1720

src/matchers/extend-expect.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { toBeSelected } from './to-be-selected';
1010
import { toBeVisible } from './to-be-visible';
1111
import { toHaveDisplayValue } from './to-have-display-value';
1212
import { toHaveProp } from './to-have-prop';
13+
import { toHaveStyle } from './to-have-style';
1314
import { toHaveTextContent } from './to-have-text-content';
1415

1516
expect.extend({
@@ -24,5 +25,6 @@ expect.extend({
2425
toBeVisible,
2526
toHaveDisplayValue,
2627
toHaveProp,
28+
toHaveStyle,
2729
toHaveTextContent,
2830
});

src/matchers/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ export { toBePartiallyChecked } from './to-be-partially-checked';
77
export { toBeVisible } from './to-be-visible';
88
export { toHaveDisplayValue } from './to-have-display-value';
99
export { toHaveProp } from './to-have-prop';
10+
export { toHaveStyle } from './to-have-style';
1011
export { toHaveTextContent } from './to-have-text-content';
1112
export { toBeSelected } from './to-be-selected';

src/matchers/to-have-style.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { ReactTestInstance } from 'react-test-renderer';
2+
import {
3+
ImageStyle,
4+
StyleProp,
5+
StyleSheet,
6+
TextStyle,
7+
ViewStyle,
8+
} from 'react-native';
9+
import { matcherHint, diff } from 'jest-matcher-utils';
10+
import { checkHostElement, formatMessage } from './utils';
11+
12+
export type Style = ViewStyle | TextStyle | ImageStyle;
13+
14+
type StyleLike = Record<string, unknown>;
15+
16+
export function toHaveStyle(
17+
this: jest.MatcherContext,
18+
element: ReactTestInstance,
19+
style: StyleProp<Style>
20+
) {
21+
checkHostElement(element, toHaveStyle, this);
22+
23+
const expected = (StyleSheet.flatten(style) as StyleLike) ?? {};
24+
const received = (StyleSheet.flatten(element.props.style) as StyleLike) ?? {};
25+
26+
const pass = Object.keys(expected).every((key) =>
27+
this.equals(expected[key], received[key])
28+
);
29+
30+
return {
31+
pass,
32+
message: () => {
33+
const to = this.isNot ? 'not to' : 'to';
34+
const matcher = matcherHint(
35+
`${this.isNot ? '.not' : ''}.toHaveStyle`,
36+
'element',
37+
''
38+
);
39+
40+
if (pass) {
41+
return formatMessage(
42+
matcher,
43+
`Expected element ${to} have style`,
44+
formatStyles(expected),
45+
'Received',
46+
formatStyles(pickReceivedStyles(expected, received))
47+
);
48+
} else {
49+
return [matcher, '', expectedDiff(expected, received)].join('\n');
50+
}
51+
},
52+
};
53+
}
54+
55+
/**
56+
* Generate diff between `expected` and `received` styles.
57+
*/
58+
function expectedDiff(expected: StyleLike, received: StyleLike) {
59+
const receivedNarrow = pickReceivedStyles(expected, received);
60+
return diff(formatStyles(expected), formatStyles(receivedNarrow));
61+
}
62+
63+
/**
64+
* Pick from `received` style only the keys present in `expected` style.
65+
*/
66+
function pickReceivedStyles(expected: StyleLike, received: StyleLike) {
67+
const result: StyleLike = {};
68+
Object.keys(received).forEach((key) => {
69+
if (expected[key] !== undefined) {
70+
result[key] = received[key];
71+
}
72+
});
73+
74+
return result;
75+
}
76+
77+
function formatStyles(style: StyleLike) {
78+
return Object.keys(style)
79+
.sort()
80+
.map((prop) =>
81+
Array.isArray(style[prop])
82+
? `${prop}: ${JSON.stringify(style[prop], null, 2)};`
83+
: `${prop}: ${style[prop]};`
84+
)
85+
.join('\n');
86+
}

0 commit comments

Comments
 (0)