From 6b575db21b73b016dd13684a3ca301587569c7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Thu, 17 May 2018 17:05:37 -0500 Subject: [PATCH] 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 --- README.md | 31 +++++++++++++++++ extend-expect.d.ts | 1 + package.json | 6 +++- src/__tests__/index.js | 58 ++++++++++++++++++++++++++++++++ src/index.js | 9 ++++- src/to-have-style.js | 75 ++++++++++++++++++++++++++++++++++++++++++ src/utils.js | 5 +-- 7 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 src/to-have-style.js diff --git a/README.md b/README.md index ec12a358..6ad2e0d0 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ to maintain. * [`toHaveTextContent`](#tohavetextcontent) * [`toHaveAttribute`](#tohaveattribute) * [`toHaveClass`](#tohaveclass) + * [`toHaveStyle`](#tohavestyle) * [Inspiration](#inspiration) * [Other Solutions](#other-solutions) * [Guiding Principles](#guiding-principles) @@ -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' + +// ... +// +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][], diff --git a/extend-expect.d.ts b/extend-expect.d.ts index d50d1898..9c75e2ae 100644 --- a/extend-expect.d.ts +++ b/extend-expect.d.ts @@ -4,5 +4,6 @@ declare namespace jest { toHaveTextContent: (text: string) => R toHaveClass: (className: string) => R toBeInTheDOM: () => R + toHaveStyle: (css: string) => R } } diff --git a/package.json b/package.json index 1a196490..8603e300 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,11 @@ "author": "Ernesto Garcia (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" diff --git a/src/__tests__/index.js b/src/__tests__/index.js index 56059879..3b330653 100644 --- a/src/__tests__/index.js +++ b/src/__tests__/index.js @@ -109,3 +109,61 @@ test('.toHaveClass', () => { expect(queryByTestId('cancel-button')).toHaveClass('btn-danger'), ).toThrowError() }) + +test('.toHaveStyle', () => { + const {container} = render(` +
+ Hello World +
+ `) + + 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) +}) diff --git a/src/index.js b/src/index.js index 03097a37..386c19e0 100644 --- a/src/index.js +++ b/src/index.js @@ -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, +} diff --git a/src/to-have-style.js b/src/to-have-style.js new file mode 100644 index 00000000..fcd14043 --- /dev/null +++ b/src/to-have-style.js @@ -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') + }, + } +} diff --git a/src/utils.js b/src/utils.js index d388eec5..ceef6900 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,3 +1,4 @@ +import redent from 'redent' import { RECEIVED_COLOR as receivedColor, EXPECTED_COLOR as expectedColor, @@ -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') }