diff --git a/packages/jest-emotion/README.md b/packages/jest-emotion/README.md index 204c2a231..751ccd72a 100644 --- a/packages/jest-emotion/README.md +++ b/packages/jest-emotion/README.md @@ -89,6 +89,34 @@ test('correct styles are inserted', () => { }) ``` +# Custom matchers + +## toHaveStyleRule + +To make more explicit assertions when testing your styled components you can use the `toHaveStyleRule` matcher. + +```jsx +import React from 'react' +import renderer from 'react-test-renderer' +import { createMatchers } from 'jest-emotion' +import * as emotion from 'emotion' +import styled from 'react-emotion' + +// Add the custom matchers provided by 'jest-emotion' +expect.extend(createMatchers(emotion)) + +test('renders with correct styles', () => { + const H1 = styled.h1` + float: left; + ` + + const tree = renderer.create(

hello world

).toJSON() + + expect(tree).toHaveStyleRule('float', 'left') + expect(tree).not.toHaveStyleRule('color', 'hotpink') +}) +``` + ## Thanks Thanks to [Kent C. Dodds](https://twitter.com/kentcdodds) who wrote [jest-glamor-react](https://github.com/kentcdodds/jest-glamor-react) which this library is largely based on. diff --git a/packages/jest-emotion/package.json b/packages/jest-emotion/package.json index 05c529347..a943d766a 100644 --- a/packages/jest-emotion/package.json +++ b/packages/jest-emotion/package.json @@ -14,6 +14,7 @@ "clean": "rimraf lib" }, "dependencies": { + "chalk": "^2.4.1", "css": "^2.2.1" }, "devDependencies": { diff --git a/packages/jest-emotion/src/index.js b/packages/jest-emotion/src/index.js index 35a761c63..108017116 100644 --- a/packages/jest-emotion/src/index.js +++ b/packages/jest-emotion/src/index.js @@ -4,8 +4,11 @@ import { replaceClassNames, type ClassNameReplacer } from './replace-class-names' +import { getClassNamesFromNodes, isReactElement, isDOMElement } from './utils' import type { Emotion } from 'create-emotion' +export { createMatchers } from './matchers' + type Options = { classNameReplacer: ClassNameReplacer, DOMElements: boolean @@ -25,28 +28,6 @@ function getNodes(node, nodes = []) { return nodes } -function getClassNames(selectors, classes) { - return classes ? selectors.concat(classes.split(' ')) : selectors -} - -function getClassNamesFromProps(selectors, props) { - return getClassNames(selectors, props.className || props.class) -} - -function getClassNamesFromDOMElement(selectors, node) { - return getClassNames(selectors, node.getAttribute('class')) -} - -function getClassNamesFromNodes(nodes) { - return nodes.reduce( - (selectors, node) => - isReactElement(node) - ? getClassNamesFromProps(selectors, node.props) - : getClassNamesFromDOMElement(selectors, node), - [] - ) -} - export function getStyles(emotion: Emotion) { return Object.keys(emotion.caches.inserted).reduce((style, current) => { if (emotion.caches.inserted[current] === true) { @@ -56,21 +37,6 @@ export function getStyles(emotion: Emotion) { }, '') } -function isReactElement(val) { - return val.$$typeof === Symbol.for('react.test.json') -} - -const domElementPattern = /^((HTML|SVG)\w*)?Element$/ - -function isDOMElement(val) { - return ( - val.nodeType === 1 && - val.constructor && - val.constructor.name && - domElementPattern.test(val.constructor.name) - ) -} - export function createSerializer( emotion: Emotion, { classNameReplacer, DOMElements = true }: Options = {} diff --git a/packages/jest-emotion/src/matchers.js b/packages/jest-emotion/src/matchers.js new file mode 100644 index 000000000..c0b4ee74d --- /dev/null +++ b/packages/jest-emotion/src/matchers.js @@ -0,0 +1,84 @@ +// @flow +import chalk from 'chalk' +import * as css from 'css' +import { getClassNamesFromNodes } from './utils' +import type { Emotion } from 'create-emotion' + +/* + * Taken from + * https://github.com/facebook/jest/blob/be4bec387d90ac8d6a7596be88bf8e4994bc3ed9/packages/expect/src/jasmine_utils.js#L234 + */ +function isA(typeName, value) { + return Object.prototype.toString.apply(value) === `[object ${typeName}]` +} + +/* + * Taken from + * https://github.com/facebook/jest/blob/be4bec387d90ac8d6a7596be88bf8e4994bc3ed9/packages/expect/src/jasmine_utils.js#L36 + */ +function isAsymmetric(obj) { + return obj && isA('Function', obj.asymmetricMatch) +} + +function valueMatches(declaration, value) { + if (value instanceof RegExp) { + return value.test(declaration.value) + } + + if (isAsymmetric(value)) { + return value.asymmetricMatch(declaration.value) + } + + return value === declaration.value +} + +function getStylesFromClassNames(classNames: Array, emotion) { + return Object.keys(emotion.caches.registered).reduce((styles, className) => { + let indexOfClassName = classNames.indexOf(className) + if (indexOfClassName !== -1) { + let nameWithoutKey = classNames[indexOfClassName].substring( + emotion.caches.key.length + 1 + ) + // $FlowFixMe + styles += emotion.caches.inserted[nameWithoutKey] + } + return styles + }, '') +} + +export function createMatchers(emotion: Emotion) { + function toHaveStyleRule(received: *, property: *, value: *) { + const selectors = getClassNamesFromNodes([received]) + const cssString = getStylesFromClassNames(selectors, emotion) + const styles = css.parse(cssString) + + const declaration = styles.stylesheet.rules + .reduce((decs, rule) => Object.assign([], decs, rule.declarations), []) + .filter(dec => dec.type === 'declaration' && dec.property === property) + .pop() + + if (!declaration) { + return { + pass: false, + message: () => `Property not found: ${property}` + } + } + + const pass = valueMatches(declaration, value) + + const message = () => + `Expected ${property}${pass ? ' not ' : ' '}to match:\n` + + ` ${chalk.green(value)}\n` + + 'Received:\n' + + ` ${chalk.red(declaration.value)}` + + return { + pass, + message + } + } + + return { + toHaveStyleRule + } +} diff --git a/packages/jest-emotion/src/utils.js b/packages/jest-emotion/src/utils.js new file mode 100644 index 000000000..edbaf4023 --- /dev/null +++ b/packages/jest-emotion/src/utils.js @@ -0,0 +1,73 @@ +// @flow + +function getClassNames(selectors, classes) { + return classes ? selectors.concat(classes.split(' ')) : selectors +} + +function getClassNamesFromTestRenderer(selectors, node) { + const props = node.props + return getClassNames(selectors, props.className || props.class) +} + +function shouldDive(node) { + return typeof node.dive === 'function' && typeof node.type() !== 'string' +} + +function isTagWithClassName(node) { + return node.prop('className') && typeof node.type() === 'string' +} + +function getClassNamesFromEnzyme(selectors, node) { + // We need to dive if we have selected a styled child from a shallow render + const actualComponent = shouldDive(node) ? node.dive() : node + // Find the first node with a className prop + const components = actualComponent.findWhere(isTagWithClassName) + const classes = components.length && components.first().prop('className') + + return getClassNames(selectors, classes) +} + +function getClassNamesFromCheerio(selectors, node) { + const classes = node.attr('class') + return getClassNames(selectors, classes) +} + +function getClassNamesFromDOMElement(selectors, node: any) { + return getClassNames(selectors, node.getAttribute('class')) +} + +export function isReactElement(val: any): boolean { + return val.$$typeof === Symbol.for('react.test.json') +} + +const domElementPattern = /^((HTML|SVG)\w*)?Element$/ + +export function isDOMElement(val: any): boolean { + return ( + val.nodeType === 1 && + val.constructor && + val.constructor.name && + domElementPattern.test(val.constructor.name) + ) +} + +function isEnzymeElement(val: any): boolean { + return typeof val.findWhere === 'function' +} + +function isCheerioElement(val: any): boolean { + return val.cheerio === '[cheerio object]' +} + +export function getClassNamesFromNodes(nodes: Array) { + return nodes.reduce((selectors, node) => { + if (isReactElement(node)) { + return getClassNamesFromTestRenderer(selectors, node) + } else if (isEnzymeElement(node)) { + return getClassNamesFromEnzyme(selectors, node) + } else if (isCheerioElement(node)) { + return getClassNamesFromCheerio(selectors, node) + } + return getClassNamesFromDOMElement(selectors, node) + }, []) +} diff --git a/packages/jest-emotion/test/__snapshots__/matchers.test.js.snap b/packages/jest-emotion/test/__snapshots__/matchers.test.js.snap new file mode 100644 index 000000000..e8e094274 --- /dev/null +++ b/packages/jest-emotion/test/__snapshots__/matchers.test.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`toHaveStyleRule returns a message explaining the failure 1`] = ` +"Expected color to match: + blue +Received: + red" +`; + +exports[`toHaveStyleRule returns a message explaining the failure 2`] = ` +"Expected color not to match: + red +Received: + red" +`; diff --git a/packages/jest-emotion/test/matchers.test.js b/packages/jest-emotion/test/matchers.test.js new file mode 100644 index 000000000..78e0f8ac6 --- /dev/null +++ b/packages/jest-emotion/test/matchers.test.js @@ -0,0 +1,122 @@ +import React from 'react' +import renderer from 'react-test-renderer' +import * as enzyme from 'enzyme' +import * as emotion from 'emotion' +import styled from 'react-emotion' +import { createMatchers } from '../src' + +const matchers = createMatchers(emotion) +const { toHaveStyleRule } = matchers + +expect.extend(matchers) + +describe('toHaveStyleRule', () => { + const divStyle = emotion.css` + color: red; + ` + + const svgStyle = emotion.css` + width: 100%; + ` + + const enzymeMethods = ['shallow', 'mount', 'render'] + + it('matches styles on the top-most node passed in', () => { + const tree = renderer + .create( +
+ +
+ ) + .toJSON() + + expect(tree).toHaveStyleRule('color', 'red') + expect(tree).not.toHaveStyleRule('width', '100%') + + const svgNode = tree.children[0] + + expect(svgNode).toHaveStyleRule('width', '100%') + expect(svgNode).not.toHaveStyleRule('color', 'red') + }) + + it('supports asymmetric matchers', () => { + const tree = renderer + .create( +
+ +
+ ) + .toJSON() + + expect(tree).toHaveStyleRule('color', expect.anything()) + expect(tree).not.toHaveStyleRule('padding', expect.anything()) + + const svgNode = tree.children[0] + + expect(svgNode).toHaveStyleRule('width', expect.stringMatching(/.*%$/)) + }) + + it('supports enzyme render methods', () => { + const Component = () => ( +
+ +
+ ) + + enzymeMethods.forEach(method => { + const wrapper = enzyme[method]() + expect(wrapper).toHaveStyleRule('color', 'red') + expect(wrapper).not.toHaveStyleRule('width', '100%') + const svgNode = wrapper.find('svg') + expect(svgNode).toHaveStyleRule('width', '100%') + expect(svgNode).not.toHaveStyleRule('color', 'red') + }) + }) + + it('supports styled components', () => { + const Div = styled('div')` + color: red; + ` + const Svg = styled('svg')` + width: 100%; + ` + + enzymeMethods.forEach(method => { + const wrapper = enzyme[method]( +
+ +
+ ) + expect(wrapper).toHaveStyleRule('color', 'red') + expect(wrapper).not.toHaveStyleRule('width', '100%') + const svgNode = + method === 'render' ? wrapper.find('svg') : wrapper.find(Svg) + expect(svgNode).toHaveStyleRule('width', '100%') + expect(svgNode).not.toHaveStyleRule('color', 'red') + }) + }) + + it('fails if no styles are found', () => { + const tree = renderer.create(
).toJSON() + const result = toHaveStyleRule(tree, 'color', 'red') + expect(result.pass).toBe(false) + expect(result.message()).toBe('Property not found: color') + }) + + it('supports regex values', () => { + const tree = renderer.create(
).toJSON() + expect(tree).toHaveStyleRule('color', /red/) + }) + + it('returns a message explaining the failure', () => { + const tree = renderer.create(
).toJSON() + + // When expect(tree).toHaveStyleRule('color', 'blue') fails + const resultFail = toHaveStyleRule(tree, 'color', 'blue') + expect(resultFail.message()).toMatchSnapshot() + + // When expect(tree).not.toHaveStyleRule('color', 'red') + const resultPass = toHaveStyleRule(tree, 'color', 'red') + expect(resultPass.message()).toMatchSnapshot() + }) +}) diff --git a/yarn.lock b/yarn.lock index 182cd6112..93ab5822d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2810,7 +2810,7 @@ chalk@^2.0: escape-string-regexp "^1.0.5" supports-color "^4.0.0" -chalk@^2.3.0: +chalk@^2.3.0, chalk@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" dependencies: