Skip to content

feature: toHaveStyle custom matcher #12

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 17, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could use css.stringify to ensure that this handles things like media queries and stuff properly?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it, but there's a problem with this. The css library seems to only be able to handle the syntax of css to the stylesheet level, that is, property: value; pairs listed inside a selector { ... }. And here I'm handling only plain css property/value pairs (well, sets of property/value pairs really) abstracted away from a selector. I do not even think media queries apply.

For instance, I'd have to convert what getComputedStyles give me, which is, again, a set of plain css property/value pairs, and manually build the AST that the css lib would have created out of those rules with a fake css selector wrapping them, just to make css.stringify be able to process it.

I saw the point of using css to parse because even though I had to fake wrapping the plain css props in a selector, it gives me syntax error handling, which is amazing for the pretty error messages when expectations in tests fail. But after all this reasoning do you still think is worth using css.stringify @kentcdodds?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Do we even need to handle media queries here? Maybe I'm missing out some potential great use case for this, but I don't see it yet)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I don't think we need to use css.stringify 👍

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