385 |
386 |
387 |
388 | `;
389 |
390 | const results = await configuredAxe(html);
391 | expect(results.violations[0].id).toBe("demo-rule");
392 | });
393 | });
394 |
395 | describe("custom configuration for user impact", () => {
396 | const axe = configureAxe({
397 | // How serious the violation is. Can be one of "minor", "moderate", "serious", or "critical".
398 | impactLevels: ["critical"],
399 | });
400 |
401 | expect.extend(toHaveNoViolations);
402 |
403 | it("should pass the test, because only critical violations are noted.", async () => {
404 | // 1 x moderate violation -> https://dequeuniversity.com/rules/axe/4.0/region?application=axeAPI
405 | const render = () => `
406 |
407 |
408 | some content
409 |
410 |
411 | `;
412 |
413 | // pass anything that outputs html to axe
414 | const html = render();
415 |
416 | expect(await axe(html)).toHaveNoViolations();
417 | });
418 | });
419 | });
420 | });
421 |
--------------------------------------------------------------------------------
/__tests__/reactjs.test.js:
--------------------------------------------------------------------------------
1 | const React = require("react");
2 | const ReactDOMServer = require("react-dom/server");
3 | const { render } = require("@testing-library/react");
4 |
5 | const { axe, toHaveNoViolations } = require("../index");
6 |
7 | expect.extend(toHaveNoViolations);
8 |
9 | describe("React", () => {
10 | test("renders correctly", async () => {
11 | const element = React.createElement("img", { src: "#" });
12 | const html = ReactDOMServer.renderToString(element);
13 |
14 | const results = await axe(html);
15 | expect(() => {
16 | expect(results).toHaveNoViolations();
17 | }).toThrowErrorMatchingSnapshot();
18 | });
19 |
20 | test("renders a react testing library container correctly", async () => {
21 | const element = React.createElement("img", { src: "#" });
22 | const { container } = render(element);
23 | const results = await axe(container);
24 |
25 | expect(() => {
26 | expect(results).toHaveNoViolations();
27 | }).toThrowErrorMatchingSnapshot();
28 | });
29 |
30 | test("renders a react testing library container without duplicate ids", async () => {
31 | const element = React.createElement("img", {
32 | src: "#",
33 | alt: "test",
34 | id: "test",
35 | });
36 | const { container } = render(element);
37 | const results = await axe(container);
38 |
39 | expect(results).toHaveNoViolations();
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/__tests__/vuejs.test.js:
--------------------------------------------------------------------------------
1 | const { mount } = require("@vue/test-utils");
2 | const { render } = require("@testing-library/vue");
3 |
4 | const { axe, toHaveNoViolations } = require("../index");
5 |
6 | const Image = {
7 | data: () => ({ src: "#" }),
8 | template: '
![]()
',
9 | };
10 |
11 | expect.extend(toHaveNoViolations);
12 |
13 | describe("Vue", () => {
14 | it("renders correctly", async () => {
15 | const wrapper = mount(Image);
16 | const results = await axe(wrapper.element);
17 |
18 | expect(() => {
19 | expect(results).toHaveNoViolations();
20 | }).toThrowErrorMatchingSnapshot();
21 | });
22 |
23 | it("renders a vue testing library container correctly", async () => {
24 | const { container } = render(Image);
25 | const results = await axe(container);
26 |
27 | expect(() => {
28 | expect(results).toHaveNoViolations();
29 | }).toThrowErrorMatchingSnapshot();
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/example-cli.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NickColley/jest-axe/084d822a318457f804e15395b498128ca414bbd8/example-cli.png
--------------------------------------------------------------------------------
/extend-expect.js:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | This allows users to add `require('jest-axe/extend-expect')`
4 | at the top of their test file rather than have two lines for this.
5 |
6 | It also allows users to use jest's setupFiles configuration and
7 | point directly to `jest-axe/extend-expect`
8 |
9 | */
10 |
11 | const { toHaveNoViolations } = require("./");
12 |
13 | expect.extend(toHaveNoViolations);
14 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | const axeCore = require("axe-core");
3 | const merge = require("lodash.merge");
4 | const chalk = require("chalk");
5 | const { printReceived, matcherHint } = require("jest-matcher-utils");
6 |
7 | const AXE_RULES_COLOR = axeCore.getRules(["cat.color"]);
8 |
9 | /**
10 | * Converts a HTML string or HTML element to a mounted HTML element.
11 | * @param {Element | string} a HTML element or a HTML string
12 | * @returns {[Element, function]} a HTML element and a function to restore the document
13 | */
14 | function mount(html) {
15 | if (isHTMLElement(html)) {
16 | if (document.body.contains(html)) {
17 | return [html, () => undefined];
18 | }
19 |
20 | html = html.outerHTML;
21 | }
22 |
23 | if (isHTMLString(html)) {
24 | const originalHTML = document.body.innerHTML;
25 | const restore = () => {
26 | document.body.innerHTML = originalHTML;
27 | };
28 |
29 | document.body.innerHTML = html;
30 | return [document.body, restore];
31 | }
32 |
33 | if (typeof html === "string") {
34 | throw new Error(`html parameter ("${html}") has no elements`);
35 | }
36 |
37 | throw new Error(`html parameter should be an HTML string or an HTML element`);
38 | }
39 |
40 | /**
41 | * Small wrapper for axe-core#run that enables promises (required for Jest),
42 | * default options and injects html to be tested
43 | * @param {object} [options] default options to use in all instances
44 | * @param {object} [options.globalOptions] Global axe-core configuration (See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#api-name-axeconfigure)
45 | * @param {object} [options.*] Any other property will be passed as the runner configuration (See https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter)
46 | * @returns {function} returns instance of axe
47 | */
48 | function configureAxe(options = {}) {
49 | const { globalOptions = {}, ...runnerOptions } = options;
50 |
51 | // Set the global configuration for axe-core
52 | // https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#api-name-axeconfigure
53 | const { rules = [], ...otherGlobalOptions } = globalOptions;
54 |
55 | // Color contrast checking doesnt work in a jsdom environment.
56 | // So we need to identify them and disable them by default.
57 | const defaultRules = AXE_RULES_COLOR.map(({ ruleId: id }) => ({
58 | id,
59 | enabled: false,
60 | }));
61 |
62 | axeCore.configure({
63 | rules: [...defaultRules, ...rules],
64 | ...otherGlobalOptions,
65 | });
66 |
67 | /**
68 | * Small wrapper for axe-core#run that enables promises (required for Jest),
69 | * default options and injects html to be tested
70 | * @param {string} html requires a html string to be injected into the body
71 | * @param {object} [additionalOptions] aXe options to merge with default options
72 | * @returns {promise} returns promise that will resolve with axe-core#run results object
73 | */
74 | return function axe(html, additionalOptions = {}) {
75 | const [element, restore] = mount(html);
76 | const options = merge({}, runnerOptions, additionalOptions);
77 |
78 | return new Promise((resolve, reject) => {
79 | axeCore.run(element, options, (err, results) => {
80 | restore();
81 | if (err) reject(err);
82 | resolve(results);
83 | });
84 | });
85 | };
86 | }
87 |
88 | /**
89 | * Checks if the HTML parameter provided is a HTML element.
90 | * @param {Element} a HTML element or a HTML string
91 | * @returns {boolean} true or false
92 | */
93 | function isHTMLElement(html) {
94 | return !!html && typeof html === "object" && typeof html.tagName === "string";
95 | }
96 |
97 | /**
98 | * Checks that the HTML parameter provided is a string that contains HTML.
99 | * @param {string} a HTML element or a HTML string
100 | * @returns {boolean} true or false
101 | */
102 | function isHTMLString(html) {
103 | return typeof html === "string" && /(<([^>]+)>)/i.test(html);
104 | }
105 |
106 | /**
107 | * Filters all violations by user impact
108 | * @param {object} violations result of the accessibilty check by axe
109 | * @param {array} impactLevels defines which impact level should be considered (e.g ['critical'])
110 | * The level of impact can be "minor", "moderate", "serious", or "critical".
111 | * @returns {object} violations filtered by impact level
112 | */
113 | function filterViolations(violations, impactLevels) {
114 | if (impactLevels && impactLevels.length > 0) {
115 | return violations.filter((v) => impactLevels.includes(v.impact));
116 | }
117 | return violations;
118 | }
119 |
120 | /**
121 | * Custom Jest expect matcher, that can check aXe results for violations.
122 | * @param {object} object requires an instance of aXe's results object
123 | * (https://github.com/dequelabs/axe-core/blob/develop-2x/doc/API.md#results-object)
124 | * @returns {object} returns Jest matcher object
125 | */
126 | const toHaveNoViolations = {
127 | toHaveNoViolations(results) {
128 | if (typeof results.violations === "undefined") {
129 | throw new Error(
130 | "Unexpected aXe results object. No violations property found.\nDid you change the `reporter` in your aXe configuration?"
131 | );
132 | }
133 |
134 | const violations = filterViolations(
135 | results.violations,
136 | results.toolOptions ? results.toolOptions.impactLevels : []
137 | );
138 |
139 | const reporter = (violations) => {
140 | if (violations.length === 0) {
141 | return [];
142 | }
143 |
144 | const lineBreak = "\n\n";
145 | const horizontalLine = "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500";
146 |
147 | return violations
148 | .map((violation) => {
149 | const errorBody = violation.nodes
150 | .map((node) => {
151 | const selector = node.target.join(", ");
152 | const expectedText =
153 | `Expected the HTML found at $('${selector}') to have no violations:` +
154 | lineBreak;
155 | return (
156 | expectedText +
157 | chalk.grey(node.html) +
158 | lineBreak +
159 | `Received:` +
160 | lineBreak +
161 | printReceived(`${violation.help} (${violation.id})`) +
162 | lineBreak +
163 | chalk.yellow(node.failureSummary) +
164 | lineBreak +
165 | (violation.helpUrl
166 | ? `You can find more information on this issue here: \n${chalk.blue(
167 | violation.helpUrl
168 | )}`
169 | : "")
170 | );
171 | })
172 | .join(lineBreak);
173 |
174 | return errorBody;
175 | })
176 | .join(lineBreak + horizontalLine + lineBreak);
177 | };
178 |
179 | const formatedViolations = reporter(violations);
180 | const pass = formatedViolations.length === 0;
181 |
182 | const message = () => {
183 | if (pass) {
184 | return;
185 | }
186 | return (
187 | matcherHint(".toHaveNoViolations") + "\n\n" + `${formatedViolations}`
188 | );
189 | };
190 |
191 | return { actual: violations, message, pass };
192 | },
193 | };
194 |
195 | module.exports = {
196 | configureAxe,
197 | axe: configureAxe(),
198 | toHaveNoViolations,
199 | };
200 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jest-axe",
3 | "version": "10.0.0",
4 | "description": "Custom Jest matcher for aXe for testing accessibility",
5 | "repository": "nickcolley/jest-axe",
6 | "main": "index.js",
7 | "files": [
8 | "index.js",
9 | "extend-expect.js"
10 | ],
11 | "scripts": {
12 | "test": "npm run lint && npm run jest",
13 | "jest": "FORCE_COLOR=0 jest",
14 | "lint": "eslint *.js __tests__/**/*.js && prettier --check *.js __tests__/**/*.js"
15 | },
16 | "keywords": [
17 | "jest",
18 | "matcher",
19 | "axe",
20 | "accessibility",
21 | "a11y"
22 | ],
23 | "author": "Nick Colley",
24 | "license": "MIT",
25 | "engines": {
26 | "node": ">= 16.0.0"
27 | },
28 | "dependencies": {
29 | "axe-core": "4.10.2",
30 | "chalk": "4.1.2",
31 | "jest-matcher-utils": "29.2.2",
32 | "lodash.merge": "4.6.2"
33 | },
34 | "devDependencies": {
35 | "@testing-library/react": "^13.4.0",
36 | "@testing-library/vue": "^6.6.1",
37 | "@vue/test-utils": "^2.2.1",
38 | "eslint": "^8.26.0",
39 | "eslint-config-prettier": "^8.5.0",
40 | "eslint-plugin-jest": "^27.1.3",
41 | "eslint-plugin-jest-dom": "^4.0.2",
42 | "eslint-plugin-n": "^15.4.0",
43 | "eslint-plugin-prettier": "^4.2.1",
44 | "jest": "^29.2.2",
45 | "jest-environment-jsdom": "^29.2.2",
46 | "prettier": "^2.7.1",
47 | "react": "^18.2.0",
48 | "react-dom": "^18.2.0",
49 | "vue": "^3.2.41"
50 | },
51 | "jest": {
52 | "testEnvironment": "jsdom",
53 | "testEnvironmentOptions": {
54 | "customExportConditions": [
55 | "node",
56 | "node-addons"
57 | ]
58 | }
59 | },
60 | "eslintConfig": {
61 | "globals": {
62 | "document": true
63 | },
64 | "env": {
65 | "shared-node-browser": true,
66 | "jest/globals": true
67 | },
68 | "extends": [
69 | "eslint:recommended",
70 | "plugin:n/recommended",
71 | "prettier",
72 | "plugin:jest/recommended",
73 | "plugin:jest-dom/recommended"
74 | ],
75 | "plugins": [
76 | "prettier",
77 | "jest",
78 | "jest-dom"
79 | ],
80 | "rules": {
81 | "prettier/prettier": "error"
82 | },
83 | "parserOptions": {
84 | "ecmaVersion": 2022
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------