Skip to content

Commit

Permalink
Add toHaveStyleRule to jest-emotion (#662)
Browse files Browse the repository at this point in the history
* Support react-test-renderer and enzyme shallow render methods

* extract utils

* support enzyme.mount

* support enzyme.render

* support styled-components in all enzyme methods

* tidy and document

* add more tests

* Add flow to a file and add chalk as a dep of jest-emotion
  • Loading branch information
danreeves authored and emmatown committed May 25, 2018
1 parent 75af5b1 commit 0210b94
Show file tree
Hide file tree
Showing 8 changed files with 327 additions and 38 deletions.
28 changes: 28 additions & 0 deletions packages/jest-emotion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<H1>hello world</H1>).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.
1 change: 1 addition & 0 deletions packages/jest-emotion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"clean": "rimraf lib"
},
"dependencies": {
"chalk": "^2.4.1",
"css": "^2.2.1"
},
"devDependencies": {
Expand Down
40 changes: 3 additions & 37 deletions packages/jest-emotion/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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 = {}
Expand Down
84 changes: 84 additions & 0 deletions packages/jest-emotion/src/matchers.js
Original file line number Diff line number Diff line change
@@ -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<string>, 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
}
}
73 changes: 73 additions & 0 deletions packages/jest-emotion/src/utils.js
Original file line number Diff line number Diff line change
@@ -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<any>) {
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)
}, [])
}
15 changes: 15 additions & 0 deletions packages/jest-emotion/test/__snapshots__/matchers.test.js.snap
Original file line number Diff line number Diff line change
@@ -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"
`;
Loading

0 comments on commit 0210b94

Please sign in to comment.