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:
+ [32mblue[39m
+Received:
+ [31mred[39m"
+`;
+
+exports[`toHaveStyleRule returns a message explaining the failure 2`] = `
+"Expected color not to match:
+ [32mred[39m
+Received:
+ [31mred[39m"
+`;
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: