Skip to content

Commit

Permalink
feature: toHaveStyle custom matcher (#12)
Browse files Browse the repository at this point in the history
* feature: toHaveStyle custom matcher

* Fix test coverage

* Use more robust css parser library

* Handle css parsing errors gracefully

* Improve how printed out styles look like in failing tests messages

* Add documentation to the README

* Use redent for indent
  • Loading branch information
gnapse authored May 17, 2018
1 parent bbdd96c commit 6b575db
Show file tree
Hide file tree
Showing 7 changed files with 181 additions and 4 deletions.
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ to maintain.
* [`toHaveTextContent`](#tohavetextcontent)
* [`toHaveAttribute`](#tohaveattribute)
* [`toHaveClass`](#tohaveclass)
* [`toHaveStyle`](#tohavestyle)
* [Inspiration](#inspiration)
* [Other Solutions](#other-solutions)
* [Guiding Principles](#guiding-principles)
Expand Down Expand Up @@ -163,6 +164,36 @@ expect(getByTestId(container, 'delete-button')).not.toHaveClass('btn-link')
// ...
```

### `toHaveStyle`

This allows you to check if a certain element has some specific css properties
with specific values applied. It matches only if the element has _all_ the
expected properties applied, not just some of them.

```javascript
// add the custom expect matchers once
import 'jest-dom/extend-expect'

// ...
// <button data-testid="delete-button" style="display: none; color: red">
// Delete item
// </button>
expect(getByTestId(container, 'delete-button')).toHaveStyle('display: none')
expect(getByTestId(container, 'delete-button')).toHaveStyle(`
color: red;
display: none;
`)
expect(getByTestId(container, 'delete-button')).not.toHaveStyle(`
display: none;
color: blue;
`)
// ...
```

This also works with rules that are applied to the element via a class name for
which some rules are defined in a stylesheet currently active in the document.
The usual rules of css precedence apply.

## Inspiration

This whole library was extracted out of Kent C. Dodds' [dom-testing-library][],
Expand Down
1 change: 1 addition & 0 deletions extend-expect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ declare namespace jest {
toHaveTextContent: (text: string) => R
toHaveClass: (className: string) => R
toBeInTheDOM: () => R
toHaveStyle: (css: string) => R
}
}
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@
"author": "Ernesto Garcia <[email protected]> (http://gnapse.github.io/)",
"license": "MIT",
"dependencies": {
"jest-matcher-utils": "^22.4.3"
"chalk": "^2.4.1",
"css": "^2.2.3",
"jest-diff": "^22.4.3",
"jest-matcher-utils": "^22.4.3",
"redent": "^2.0.0"
},
"devDependencies": {
"kcd-scripts": "^0.37.0"
Expand Down
58 changes: 58 additions & 0 deletions src/__tests__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,61 @@ test('.toHaveClass', () => {
expect(queryByTestId('cancel-button')).toHaveClass('btn-danger'),
).toThrowError()
})

test('.toHaveStyle', () => {
const {container} = render(`
<div class="label" style="background-color: blue; height: 100%">
Hello World
</div>
`)

const style = document.createElement('style')
style.innerHTML = `
.label {
background-color: black;
color: white;
float: left;
}
`
document.body.appendChild(style)
document.body.appendChild(container)

expect(container.querySelector('.label')).toHaveStyle(`
height: 100%;
color: white;
background-color: blue;
`)

expect(container.querySelector('.label')).toHaveStyle(`
background-color: blue;
color: white;
`)
expect(container.querySelector('.label')).toHaveStyle(
'background-color:blue;color:white',
)

expect(container.querySelector('.label')).not.toHaveStyle(`
color: white;
font-weight: bold;
`)

expect(() =>
expect(container.querySelector('.label')).toHaveStyle('font-weight: bold'),
).toThrowError()
expect(() =>
expect(container.querySelector('.label')).not.toHaveStyle('color: white'),
).toThrowError()

// Make sure the test fails if the css syntax is not valid
expect(() =>
expect(container.querySelector('.label')).not.toHaveStyle(
'font-weight bold',
),
).toThrowError()
expect(() =>
expect(container.querySelector('.label')).toHaveStyle('color white'),
).toThrowError()

document.body.removeChild(style)
document.body.removeChild(container)
})
9 changes: 8 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,12 @@ import {toBeInTheDOM} from './to-be-in-the-dom'
import {toHaveTextContent} from './to-have-text-content'
import {toHaveAttribute} from './to-have-attribute'
import {toHaveClass} from './to-have-class'
import {toHaveStyle} from './to-have-style'

export {toBeInTheDOM, toHaveTextContent, toHaveAttribute, toHaveClass}
export {
toBeInTheDOM,
toHaveTextContent,
toHaveAttribute,
toHaveClass,
toHaveStyle,
}
75 changes: 75 additions & 0 deletions src/to-have-style.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {parse} from 'css'
import {matcherHint} from 'jest-matcher-utils'
import jestDiff from 'jest-diff'
import chalk from 'chalk'
import {checkHtmlElement} from './utils'

function parseCSS(css) {
const ast = parse(`selector { ${css} }`, {silent: true}).stylesheet
if (ast.parsingErrors && ast.parsingErrors.length > 0) {
const {reason, line, column} = ast.parsingErrors[0]
return {
parsingError: `Syntax error parsing expected css: ${reason} in ${line}:${column}`,
}
}
const parsedRules = ast.rules[0].declarations
.filter(d => d.type === 'declaration')
.reduce(
(obj, {property, value}) => Object.assign(obj, {[property]: value}),
{},
)
return {parsedRules}
}

function isSubset(styles, computedStyle) {
return Object.entries(styles).every(
([prop, value]) => computedStyle.getPropertyValue(prop) === value,
)
}

function printoutStyles(styles) {
return Object.keys(styles)
.sort()
.map(prop => `${prop}: ${styles[prop]};`)
.join('\n')
}

// Highlights only style rules that were expected but were not found in the
// received computed styles
function expectedDiff(expected, computedStyles) {
const received = Array.from(computedStyles)
.filter(prop => expected[prop])
.reduce(
(obj, prop) =>
Object.assign(obj, {[prop]: computedStyles.getPropertyValue(prop)}),
{},
)
const diffOutput = jestDiff(
printoutStyles(expected),
printoutStyles(received),
)
// Remove the "+ Received" annotation because this is a one-way diff
return diffOutput.replace(`${chalk.red('+ Received')}\n`, '')
}

export function toHaveStyle(htmlElement, css) {
checkHtmlElement(htmlElement)
const {parsedRules: expected, parsingError} = parseCSS(css)
if (parsingError) {
return {
pass: this.isNot, // Fail regardless of the test being positive or negative
message: () => parsingError,
}
}
const received = getComputedStyle(htmlElement)
return {
pass: isSubset(expected, received),
message: () => {
const matcher = `${this.isNot ? '.not' : ''}.toHaveStyle`
return [
matcherHint(matcher, 'element', ''),
expectedDiff(expected, received),
].join('\n\n')
},
}
}
5 changes: 3 additions & 2 deletions src/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import redent from 'redent'
import {
RECEIVED_COLOR as receivedColor,
EXPECTED_COLOR as expectedColor,
Expand Down Expand Up @@ -30,8 +31,8 @@ function getMessage(
) {
return [
`${matcher}\n`,
`${expectedLabel}:\n ${expectedColor(expectedValue)}`,
`${receivedLabel}:\n ${receivedColor(receivedValue)}`,
`${expectedLabel}:\n${expectedColor(redent(expectedValue, 2))}`,
`${receivedLabel}:\n${receivedColor(redent(receivedValue, 2))}`,
].join('\n')
}

Expand Down

0 comments on commit 6b575db

Please sign in to comment.