Skip to content

Commit

Permalink
feat: toHaveStyle matcher (#1487)
Browse files Browse the repository at this point in the history
* feat: implement toHaveStyle with tests

* refactor: tweaks and cleanup

---------

Co-authored-by: Marcin Kornek <7931279+marcinkornek@users.noreply.github.com>
Co-authored-by: Maciej Jastrzebski <mdjastrzebski@gmail.com>
  • Loading branch information
3 people committed Sep 12, 2023
1 parent 701433c commit 3b9c4da
Show file tree
Hide file tree
Showing 6 changed files with 268 additions and 0 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"flow": "flow",
"copy-flowtypes": "cp typings/index.flow.js build",
"lint": "eslint src --cache",
"validate": "yarn lint && yarn typecheck && yarn test",
"prepublish": "yarn build",
"build:js": "babel src --out-dir build --extensions \".js,.ts,.jsx,.tsx\" --source-maps --ignore \"**/__tests__/**\"",
"build:js:watch": "yarn build:js --watch",
Expand Down
175 changes: 175 additions & 0 deletions src/matchers/__tests__/to-have-style.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import React from 'react';
import { StyleSheet, View, Pressable } from 'react-native';
import { render } from '../..';
import '../extend-expect';

const styles = StyleSheet.create({
container: { borderBottomColor: 'white' },
});

test('toHaveStyle() handles basic cases', () => {
const screen = render(
<View
testID="view"
style={[
{
backgroundColor: 'blue',
height: '40%',
transform: [{ scale: 2 }, { rotate: '45deg' }],
},
[{ height: '100%' }],
[[{ width: '50%' }]],
styles.container,
]}
/>
);

const view = screen.getByTestId('view');
expect(view).toHaveStyle({ backgroundColor: 'blue' });
expect(view).toHaveStyle({ height: '100%' });
expect(view).toHaveStyle({ backgroundColor: 'blue', height: '100%' });
expect(view).toHaveStyle([{ backgroundColor: 'blue' }, { height: '100%' }]);

expect(view).toHaveStyle({ borderBottomColor: 'white' });
expect(view).toHaveStyle({ width: '50%' });
expect(view).toHaveStyle([[{ width: '50%' }]]);
expect(view).toHaveStyle({
transform: [{ scale: 2 }, { rotate: '45deg' }],
});

expect(view).not.toHaveStyle({ backgroundColor: 'red' });
expect(view).not.toHaveStyle({ height: '50%' });
expect(view).not.toHaveStyle({ backgroundColor: 'blue', height: '50%' });
expect(view).not.toHaveStyle([
{ backgroundColor: 'blue' },
{ height: '50%' },
]);
expect(view).not.toHaveStyle({
transform: [{ scale: 2 }],
});
expect(view).not.toHaveStyle({
transform: [{ rotate: '45deg' }, { scale: 2 }],
});
});

test('toHaveStyle error messages', () => {
const screen = render(
<View
testID="view"
style={{
backgroundColor: 'blue',
borderBottomColor: 'black',
height: '100%',
transform: [{ scale: 2 }, { rotate: '45deg' }],
}}
/>
);

const view = screen.getByTestId('view');
expect(() => expect(view).toHaveStyle({ backgroundColor: 'red' }))
.toThrowErrorMatchingInlineSnapshot(`
"expect(element).toHaveStyle()
- Expected
+ Received
- backgroundColor: red;
+ backgroundColor: blue;"
`);

expect(() =>
expect(view).toHaveStyle({
backgroundColor: 'blue',
transform: [{ scale: 1 }],
})
).toThrowErrorMatchingInlineSnapshot(`
"expect(element).toHaveStyle()
- Expected
+ Received
backgroundColor: blue;
transform: [
{
- "scale": 1
+ "scale": 2
+ },
+ {
+ "rotate": "45deg"
}
];"
`);

expect(() => expect(view).not.toHaveStyle({ backgroundColor: 'blue' }))
.toThrowErrorMatchingInlineSnapshot(`
"expect(element).not.toHaveStyle()
Expected element not to have style:
backgroundColor: blue;
Received:
backgroundColor: blue;"
`);

expect(() => expect(view).toHaveStyle({ fontWeight: 'bold' }))
.toThrowErrorMatchingInlineSnapshot(`
"expect(element).toHaveStyle()
- Expected
+ Received
- fontWeight: bold;"
`);

expect(() => expect(view).not.toHaveStyle({ backgroundColor: 'blue' }))
.toThrowErrorMatchingInlineSnapshot(`
"expect(element).not.toHaveStyle()
Expected element not to have style:
backgroundColor: blue;
Received:
backgroundColor: blue;"
`);
});

test('toHaveStyle() supports missing "style" prop', () => {
const screen = render(<View testID="view" />);

const view = screen.getByTestId('view');
expect(view).not.toHaveStyle({ fontWeight: 'bold' });
});

test('toHaveStyle() supports undefined "transform" style', () => {
const screen = render(
<View
testID="view"
style={{
backgroundColor: 'blue',
transform: undefined,
}}
/>
);

const view = screen.getByTestId('view');
expect(() => expect(view).toHaveStyle({ transform: [{ scale: 1 }] }))
.toThrowErrorMatchingInlineSnapshot(`
"expect(element).toHaveStyle()
- Expected
+ Received
- transform: [
- {
- "scale": 1
- }
- ];
+ transform: undefined;"
`);
});

test('toHaveStyle() supports Pressable with function "style" prop', () => {
const screen = render(
<Pressable testID="view" style={() => ({ backgroundColor: 'blue' })} />
);

expect(screen.getByTestId('view')).toHaveStyle({ backgroundColor: 'blue' });
});
3 changes: 3 additions & 0 deletions src/matchers/extend-expect.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { StyleProp } from 'react-native';
import type { TextMatch, TextMatchOptions } from '../matches';
import type { Style } from './to-have-style';

export interface JestNativeMatchers<R> {
toBeOnTheScreen(): R;
Expand All @@ -12,6 +14,7 @@ export interface JestNativeMatchers<R> {
toBeVisible(): R;
toHaveDisplayValue(expectedValue: TextMatch, options?: TextMatchOptions): R;
toHaveProp(name: string, expectedValue?: unknown): R;
toHaveStyle(style: StyleProp<Style>): R;
toHaveTextContent(expectedText: TextMatch, options?: TextMatchOptions): R;
}

Expand Down
2 changes: 2 additions & 0 deletions src/matchers/extend-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { toBeSelected } from './to-be-selected';
import { toBeVisible } from './to-be-visible';
import { toHaveDisplayValue } from './to-have-display-value';
import { toHaveProp } from './to-have-prop';
import { toHaveStyle } from './to-have-style';
import { toHaveTextContent } from './to-have-text-content';

expect.extend({
Expand All @@ -24,5 +25,6 @@ expect.extend({
toBeVisible,
toHaveDisplayValue,
toHaveProp,
toHaveStyle,
toHaveTextContent,
});
1 change: 1 addition & 0 deletions src/matchers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export { toBePartiallyChecked } from './to-be-partially-checked';
export { toBeVisible } from './to-be-visible';
export { toHaveDisplayValue } from './to-have-display-value';
export { toHaveProp } from './to-have-prop';
export { toHaveStyle } from './to-have-style';
export { toHaveTextContent } from './to-have-text-content';
export { toBeSelected } from './to-be-selected';
86 changes: 86 additions & 0 deletions src/matchers/to-have-style.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { ReactTestInstance } from 'react-test-renderer';
import {
ImageStyle,
StyleProp,
StyleSheet,
TextStyle,
ViewStyle,
} from 'react-native';
import { matcherHint, diff } from 'jest-matcher-utils';
import { checkHostElement, formatMessage } from './utils';

export type Style = ViewStyle | TextStyle | ImageStyle;

type StyleLike = Record<string, unknown>;

export function toHaveStyle(
this: jest.MatcherContext,
element: ReactTestInstance,
style: StyleProp<Style>
) {
checkHostElement(element, toHaveStyle, this);

const expected = (StyleSheet.flatten(style) as StyleLike) ?? {};
const received = (StyleSheet.flatten(element.props.style) as StyleLike) ?? {};

const pass = Object.keys(expected).every((key) =>
this.equals(expected[key], received[key])
);

return {
pass,
message: () => {
const to = this.isNot ? 'not to' : 'to';
const matcher = matcherHint(
`${this.isNot ? '.not' : ''}.toHaveStyle`,
'element',
''
);

if (pass) {
return formatMessage(
matcher,
`Expected element ${to} have style`,
formatStyles(expected),
'Received',
formatStyles(pickReceivedStyles(expected, received))
);
} else {
return [matcher, '', expectedDiff(expected, received)].join('\n');
}
},
};
}

/**
* Generate diff between `expected` and `received` styles.
*/
function expectedDiff(expected: StyleLike, received: StyleLike) {
const receivedNarrow = pickReceivedStyles(expected, received);
return diff(formatStyles(expected), formatStyles(receivedNarrow));
}

/**
* Pick from `received` style only the keys present in `expected` style.
*/
function pickReceivedStyles(expected: StyleLike, received: StyleLike) {
const result: StyleLike = {};
Object.keys(received).forEach((key) => {
if (expected[key] !== undefined) {
result[key] = received[key];
}
});

return result;
}

function formatStyles(style: StyleLike) {
return Object.keys(style)
.sort()
.map((prop) =>
Array.isArray(style[prop])
? `${prop}: ${JSON.stringify(style[prop], null, 2)};`
: `${prop}: ${style[prop]};`
)
.join('\n');
}

0 comments on commit 3b9c4da

Please sign in to comment.