From 943a0c9783de6acc83a397b5dab22570f2611c3c Mon Sep 17 00:00:00 2001 From: Patrick Smith Date: Fri, 8 May 2020 04:33:03 +1000 Subject: [PATCH] feat: Add new custom matcher toHaveDescription (#244) * Add toHaveDescription() matcher * Add toHaveDescription() docs --- README.md | 62 +++++++++++- src/__tests__/helpers/test-utils.js | 6 ++ src/__tests__/to-have-description.js | 138 +++++++++++++++++++++++++++ src/matchers.js | 2 + src/to-have-description.js | 42 ++++++++ 5 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/to-have-description.js create mode 100644 src/to-have-description.js diff --git a/README.md b/README.md index 4e2361ee..7cd44fca 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ clear to read and to maintain. - [`toHaveValue`](#tohavevalue) - [`toHaveDisplayValue`](#tohavedisplayvalue) - [`toBeChecked`](#tobechecked) + - [`toHaveDescription`](#tohavedescription) - [Deprecated matchers](#deprecated-matchers) - [`toBeInTheDOM`](#tobeinthedom) - [Inspiration](#inspiration) @@ -86,9 +87,11 @@ should be installed as one of your project's `devDependencies`: ``` npm install --save-dev @testing-library/jest-dom ``` -or + +or for installation with [yarn](https://yarnpkg.com/) package manager. + ``` yarn add --dev @testing-library/jest-dom ``` @@ -725,7 +728,7 @@ const element = getByTestId('text-content') expect(element).toHaveTextContent('Content') expect(element).toHaveTextContent(/^Text Content$/) // to match the whole content -expect(element).toHaveTextContent(/content$/i) // to use case-insentive match +expect(element).toHaveTextContent(/content$/i) // to use case-insensitive match expect(element).not.toHaveTextContent('content') ``` @@ -886,6 +889,60 @@ expect(ariaSwitchChecked).toBeChecked() expect(ariaSwitchUnchecked).not.toBeChecked() ``` +
+ +### `toHaveDescription` + +```typescript +toHaveDescription(text: string | RegExp) +``` + +This allows you to check whether the given element has a description or not. + +An element gets its description via the +[`aria-describedby` attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-describedby_attribute). +Set this to the `id` of one or more other elements. These elements may be nested +inside, be outside, or a sibling of the passed in element. + +Whitespace is normalized. Using multiple ids will +[join the referenced elements’ text content separated by a space](https://www.w3.org/TR/accname-1.1/#mapping_additional_nd_description). + +When a `string` argument is passed through, it will perform a whole +case-sensitive match to the description text. + +To perform a case-insensitive match, you can use a `RegExp` with the `/i` +modifier. + +To perform a partial match, you can pass a `RegExp` or use +`expect.stringContaining("partial string")`. + +#### Examples + +```html + +
+ Closing will discard any changes +
+ + +``` + +```javascript +const closeButton = getByRole('button', {name: 'Close'}) + +expect(closeButton).toHaveDescription('Closing will discard any changes') +expect(closeButton).toHaveDescription(/will discard/) // to partially match +expect(closeButton).toHaveDescription(expect.stringContaining('will discard')) // to partially match +expect(closeButton).toHaveDescription(/^closing/i) // to use case-insensitive match +expect(closeButton).not.toHaveDescription('Other description') + +const deleteButton = getByRole('button', {name: 'Delete'}) +expect(deleteButton).not.toHaveDescription() +expect(deleteButton).toHaveDescription('') // Missing or empty description always becomes a blank string +``` + ## Deprecated matchers ### `toBeInTheDOM` @@ -1026,6 +1083,7 @@ Thanks goes to these people ([emoji key][emojis]): + This project follows the [all-contributors][all-contributors] specification. diff --git a/src/__tests__/helpers/test-utils.js b/src/__tests__/helpers/test-utils.js index 3efabeac..2133b163 100644 --- a/src/__tests__/helpers/test-utils.js +++ b/src/__tests__/helpers/test-utils.js @@ -5,6 +5,12 @@ function render(html) { container.innerHTML = html const queryByTestId = testId => container.querySelector(`[data-testid="${testId}"]`) + + // Some tests need to look up global ids with document.getElementById() + // so we need to be inside an actual document. + document.body.innerHTML = '' + document.body.appendChild(container) + return {container, queryByTestId} } diff --git a/src/__tests__/to-have-description.js b/src/__tests__/to-have-description.js new file mode 100644 index 00000000..ffb10032 --- /dev/null +++ b/src/__tests__/to-have-description.js @@ -0,0 +1,138 @@ +import {render} from './helpers/test-utils' + +describe('.toHaveDescription', () => { + test('handles positive test cases', () => { + const {queryByTestId} = render(` +
The description
+ +
+
+
+ `) + + expect(queryByTestId('single')).toHaveDescription('The description') + expect(queryByTestId('single')).toHaveDescription( + expect.stringContaining('The'), + ) + expect(queryByTestId('single')).toHaveDescription(/The/) + expect(queryByTestId('single')).toHaveDescription( + expect.stringMatching(/The/), + ) + expect(queryByTestId('single')).toHaveDescription(/description/) + expect(queryByTestId('single')).not.toHaveDescription('Something else') + expect(queryByTestId('single')).not.toHaveDescription('The') + + expect(queryByTestId('invalid_id')).not.toHaveDescription() + expect(queryByTestId('invalid_id')).toHaveDescription('') + + expect(queryByTestId('without')).not.toHaveDescription() + expect(queryByTestId('without')).toHaveDescription('') + }) + + test('handles multiple ids', () => { + const {queryByTestId} = render(` +
First description
+
Second description
+
Third description
+ +
+ `) + + expect(queryByTestId('multiple')).toHaveDescription( + 'First description Second description Third description', + ) + expect(queryByTestId('multiple')).toHaveDescription( + /Second description Third/, + ) + expect(queryByTestId('multiple')).toHaveDescription( + expect.stringContaining('Second description Third'), + ) + expect(queryByTestId('multiple')).toHaveDescription( + expect.stringMatching(/Second description Third/), + ) + expect(queryByTestId('multiple')).not.toHaveDescription('Something else') + expect(queryByTestId('multiple')).not.toHaveDescription('First') + }) + + test('handles negative test cases', () => { + const {queryByTestId} = render(` +
The description
+
+ `) + + expect(() => + expect(queryByTestId('other')).toHaveDescription('The description'), + ).toThrowError() + + expect(() => + expect(queryByTestId('target')).toHaveDescription('Something else'), + ).toThrowError() + + expect(() => + expect(queryByTestId('target')).not.toHaveDescription('The description'), + ).toThrowError() + }) + + test('normalizes whitespace', () => { + const {queryByTestId} = render(` +
+ Step + 1 + of + 4 +
+
+ And + extra + description +
+
+ `) + + expect(queryByTestId('target')).toHaveDescription( + 'Step 1 of 4 And extra description', + ) + }) + + test('can handle multiple levels with content spread across decendants', () => { + const {queryByTestId} = render(` + + Step + 1 + of + + + 4 + +
+ `) + + expect(queryByTestId('target')).toHaveDescription('Step 1 of 4') + }) + + test('handles extra whitespace with multiple ids', () => { + const {queryByTestId} = render(` +
First description
+
Second description
+
Third description
+ +
+ `) + + expect(queryByTestId('multiple')).toHaveDescription( + 'First description Second description Third description', + ) + }) + + test('is case-sensitive', () => { + const {queryByTestId} = render(` + Sensitive text +
+ `) + + expect(queryByTestId('target')).toHaveDescription('Sensitive text') + expect(queryByTestId('target')).not.toHaveDescription('sensitive text') + }) +}) diff --git a/src/matchers.js b/src/matchers.js index 552f1300..703dd18c 100644 --- a/src/matchers.js +++ b/src/matchers.js @@ -16,6 +16,7 @@ import {toBeInvalid, toBeValid} from './to-be-invalid' import {toHaveValue} from './to-have-value' import {toHaveDisplayValue} from './to-have-display-value' import {toBeChecked} from './to-be-checked' +import {toHaveDescription} from './to-have-description' export { toBeInTheDOM, @@ -38,4 +39,5 @@ export { toHaveValue, toHaveDisplayValue, toBeChecked, + toHaveDescription, } diff --git a/src/to-have-description.js b/src/to-have-description.js new file mode 100644 index 00000000..1f1f769b --- /dev/null +++ b/src/to-have-description.js @@ -0,0 +1,42 @@ +import {matcherHint, printExpected, printReceived} from 'jest-matcher-utils' +import {checkHtmlElement, getMessage, normalize} from './utils' + +// See algoritm: https://www.w3.org/TR/accname-1.1/#mapping_additional_nd_description +export function toHaveDescription(htmlElement, checkWith) { + checkHtmlElement(htmlElement, toHaveDescription, this) + + const expectsDescription = checkWith !== undefined + + const descriptionIDRaw = htmlElement.getAttribute('aria-describedby') || '' + const descriptionIDs = descriptionIDRaw.split(/\s+/).filter(Boolean) + let description = '' + if (descriptionIDs.length > 0) { + const document = htmlElement.ownerDocument + const descriptionEls = descriptionIDs + .map(descriptionID => document.getElementById(descriptionID)) + .filter(Boolean) + description = normalize(descriptionEls.map(el => el.textContent).join(' ')) + } + + return { + pass: expectsDescription + ? checkWith instanceof RegExp + ? checkWith.test(description) + : this.equals(description, checkWith) + : Boolean(description), + message: () => { + const to = this.isNot ? 'not to' : 'to' + return getMessage( + matcherHint( + `${this.isNot ? '.not' : ''}.toHaveDescription`, + 'element', + '', + ), + `Expected the element ${to} have description`, + printExpected(checkWith), + 'Received', + printReceived(description), + ) + }, + } +}