Skip to content

Commit 6b575db

Browse files
authored
feature: toHaveStyle custom matcher (#12)
* 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
1 parent bbdd96c commit 6b575db

File tree

7 files changed

+181
-4
lines changed

7 files changed

+181
-4
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ to maintain.
4646
* [`toHaveTextContent`](#tohavetextcontent)
4747
* [`toHaveAttribute`](#tohaveattribute)
4848
* [`toHaveClass`](#tohaveclass)
49+
* [`toHaveStyle`](#tohavestyle)
4950
* [Inspiration](#inspiration)
5051
* [Other Solutions](#other-solutions)
5152
* [Guiding Principles](#guiding-principles)
@@ -163,6 +164,36 @@ expect(getByTestId(container, 'delete-button')).not.toHaveClass('btn-link')
163164
// ...
164165
```
165166

167+
### `toHaveStyle`
168+
169+
This allows you to check if a certain element has some specific css properties
170+
with specific values applied. It matches only if the element has _all_ the
171+
expected properties applied, not just some of them.
172+
173+
```javascript
174+
// add the custom expect matchers once
175+
import 'jest-dom/extend-expect'
176+
177+
// ...
178+
// <button data-testid="delete-button" style="display: none; color: red">
179+
// Delete item
180+
// </button>
181+
expect(getByTestId(container, 'delete-button')).toHaveStyle('display: none')
182+
expect(getByTestId(container, 'delete-button')).toHaveStyle(`
183+
color: red;
184+
display: none;
185+
`)
186+
expect(getByTestId(container, 'delete-button')).not.toHaveStyle(`
187+
display: none;
188+
color: blue;
189+
`)
190+
// ...
191+
```
192+
193+
This also works with rules that are applied to the element via a class name for
194+
which some rules are defined in a stylesheet currently active in the document.
195+
The usual rules of css precedence apply.
196+
166197
## Inspiration
167198

168199
This whole library was extracted out of Kent C. Dodds' [dom-testing-library][],

extend-expect.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ declare namespace jest {
44
toHaveTextContent: (text: string) => R
55
toHaveClass: (className: string) => R
66
toBeInTheDOM: () => R
7+
toHaveStyle: (css: string) => R
78
}
89
}

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@
3232
"author": "Ernesto Garcia <[email protected]> (http://gnapse.github.io/)",
3333
"license": "MIT",
3434
"dependencies": {
35-
"jest-matcher-utils": "^22.4.3"
35+
"chalk": "^2.4.1",
36+
"css": "^2.2.3",
37+
"jest-diff": "^22.4.3",
38+
"jest-matcher-utils": "^22.4.3",
39+
"redent": "^2.0.0"
3640
},
3741
"devDependencies": {
3842
"kcd-scripts": "^0.37.0"

src/__tests__/index.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,61 @@ test('.toHaveClass', () => {
109109
expect(queryByTestId('cancel-button')).toHaveClass('btn-danger'),
110110
).toThrowError()
111111
})
112+
113+
test('.toHaveStyle', () => {
114+
const {container} = render(`
115+
<div class="label" style="background-color: blue; height: 100%">
116+
Hello World
117+
</div>
118+
`)
119+
120+
const style = document.createElement('style')
121+
style.innerHTML = `
122+
.label {
123+
background-color: black;
124+
color: white;
125+
float: left;
126+
}
127+
`
128+
document.body.appendChild(style)
129+
document.body.appendChild(container)
130+
131+
expect(container.querySelector('.label')).toHaveStyle(`
132+
height: 100%;
133+
color: white;
134+
background-color: blue;
135+
`)
136+
137+
expect(container.querySelector('.label')).toHaveStyle(`
138+
background-color: blue;
139+
color: white;
140+
`)
141+
expect(container.querySelector('.label')).toHaveStyle(
142+
'background-color:blue;color:white',
143+
)
144+
145+
expect(container.querySelector('.label')).not.toHaveStyle(`
146+
color: white;
147+
font-weight: bold;
148+
`)
149+
150+
expect(() =>
151+
expect(container.querySelector('.label')).toHaveStyle('font-weight: bold'),
152+
).toThrowError()
153+
expect(() =>
154+
expect(container.querySelector('.label')).not.toHaveStyle('color: white'),
155+
).toThrowError()
156+
157+
// Make sure the test fails if the css syntax is not valid
158+
expect(() =>
159+
expect(container.querySelector('.label')).not.toHaveStyle(
160+
'font-weight bold',
161+
),
162+
).toThrowError()
163+
expect(() =>
164+
expect(container.querySelector('.label')).toHaveStyle('color white'),
165+
).toThrowError()
166+
167+
document.body.removeChild(style)
168+
document.body.removeChild(container)
169+
})

src/index.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,12 @@ import {toBeInTheDOM} from './to-be-in-the-dom'
22
import {toHaveTextContent} from './to-have-text-content'
33
import {toHaveAttribute} from './to-have-attribute'
44
import {toHaveClass} from './to-have-class'
5+
import {toHaveStyle} from './to-have-style'
56

6-
export {toBeInTheDOM, toHaveTextContent, toHaveAttribute, toHaveClass}
7+
export {
8+
toBeInTheDOM,
9+
toHaveTextContent,
10+
toHaveAttribute,
11+
toHaveClass,
12+
toHaveStyle,
13+
}

src/to-have-style.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {parse} from 'css'
2+
import {matcherHint} from 'jest-matcher-utils'
3+
import jestDiff from 'jest-diff'
4+
import chalk from 'chalk'
5+
import {checkHtmlElement} from './utils'
6+
7+
function parseCSS(css) {
8+
const ast = parse(`selector { ${css} }`, {silent: true}).stylesheet
9+
if (ast.parsingErrors && ast.parsingErrors.length > 0) {
10+
const {reason, line, column} = ast.parsingErrors[0]
11+
return {
12+
parsingError: `Syntax error parsing expected css: ${reason} in ${line}:${column}`,
13+
}
14+
}
15+
const parsedRules = ast.rules[0].declarations
16+
.filter(d => d.type === 'declaration')
17+
.reduce(
18+
(obj, {property, value}) => Object.assign(obj, {[property]: value}),
19+
{},
20+
)
21+
return {parsedRules}
22+
}
23+
24+
function isSubset(styles, computedStyle) {
25+
return Object.entries(styles).every(
26+
([prop, value]) => computedStyle.getPropertyValue(prop) === value,
27+
)
28+
}
29+
30+
function printoutStyles(styles) {
31+
return Object.keys(styles)
32+
.sort()
33+
.map(prop => `${prop}: ${styles[prop]};`)
34+
.join('\n')
35+
}
36+
37+
// Highlights only style rules that were expected but were not found in the
38+
// received computed styles
39+
function expectedDiff(expected, computedStyles) {
40+
const received = Array.from(computedStyles)
41+
.filter(prop => expected[prop])
42+
.reduce(
43+
(obj, prop) =>
44+
Object.assign(obj, {[prop]: computedStyles.getPropertyValue(prop)}),
45+
{},
46+
)
47+
const diffOutput = jestDiff(
48+
printoutStyles(expected),
49+
printoutStyles(received),
50+
)
51+
// Remove the "+ Received" annotation because this is a one-way diff
52+
return diffOutput.replace(`${chalk.red('+ Received')}\n`, '')
53+
}
54+
55+
export function toHaveStyle(htmlElement, css) {
56+
checkHtmlElement(htmlElement)
57+
const {parsedRules: expected, parsingError} = parseCSS(css)
58+
if (parsingError) {
59+
return {
60+
pass: this.isNot, // Fail regardless of the test being positive or negative
61+
message: () => parsingError,
62+
}
63+
}
64+
const received = getComputedStyle(htmlElement)
65+
return {
66+
pass: isSubset(expected, received),
67+
message: () => {
68+
const matcher = `${this.isNot ? '.not' : ''}.toHaveStyle`
69+
return [
70+
matcherHint(matcher, 'element', ''),
71+
expectedDiff(expected, received),
72+
].join('\n\n')
73+
},
74+
}
75+
}

src/utils.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import redent from 'redent'
12
import {
23
RECEIVED_COLOR as receivedColor,
34
EXPECTED_COLOR as expectedColor,
@@ -30,8 +31,8 @@ function getMessage(
3031
) {
3132
return [
3233
`${matcher}\n`,
33-
`${expectedLabel}:\n ${expectedColor(expectedValue)}`,
34-
`${receivedLabel}:\n ${receivedColor(receivedValue)}`,
34+
`${expectedLabel}:\n${expectedColor(redent(expectedValue, 2))}`,
35+
`${receivedLabel}:\n${receivedColor(redent(receivedValue, 2))}`,
3536
].join('\n')
3637
}
3738

0 commit comments

Comments
 (0)