Skip to content

Commit 943a0c9

Browse files
authored
feat: Add new custom matcher toHaveDescription (#244)
* Add toHaveDescription() matcher * Add toHaveDescription() docs
1 parent 2afc2c5 commit 943a0c9

File tree

5 files changed

+248
-2
lines changed

5 files changed

+248
-2
lines changed

README.md

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ clear to read and to maintain.
6868
- [`toHaveValue`](#tohavevalue)
6969
- [`toHaveDisplayValue`](#tohavedisplayvalue)
7070
- [`toBeChecked`](#tobechecked)
71+
- [`toHaveDescription`](#tohavedescription)
7172
- [Deprecated matchers](#deprecated-matchers)
7273
- [`toBeInTheDOM`](#tobeinthedom)
7374
- [Inspiration](#inspiration)
@@ -86,9 +87,11 @@ should be installed as one of your project's `devDependencies`:
8687
```
8788
npm install --save-dev @testing-library/jest-dom
8889
```
89-
or
90+
91+
or
9092

9193
for installation with [yarn](https://yarnpkg.com/) package manager.
94+
9295
```
9396
yarn add --dev @testing-library/jest-dom
9497
```
@@ -725,7 +728,7 @@ const element = getByTestId('text-content')
725728

726729
expect(element).toHaveTextContent('Content')
727730
expect(element).toHaveTextContent(/^Text Content$/) // to match the whole content
728-
expect(element).toHaveTextContent(/content$/i) // to use case-insentive match
731+
expect(element).toHaveTextContent(/content$/i) // to use case-insensitive match
729732
expect(element).not.toHaveTextContent('content')
730733
```
731734

@@ -886,6 +889,60 @@ expect(ariaSwitchChecked).toBeChecked()
886889
expect(ariaSwitchUnchecked).not.toBeChecked()
887890
```
888891

892+
<hr />
893+
894+
### `toHaveDescription`
895+
896+
```typescript
897+
toHaveDescription(text: string | RegExp)
898+
```
899+
900+
This allows you to check whether the given element has a description or not.
901+
902+
An element gets its description via the
903+
[`aria-describedby` attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-describedby_attribute).
904+
Set this to the `id` of one or more other elements. These elements may be nested
905+
inside, be outside, or a sibling of the passed in element.
906+
907+
Whitespace is normalized. Using multiple ids will
908+
[join the referenced elements’ text content separated by a space](https://www.w3.org/TR/accname-1.1/#mapping_additional_nd_description).
909+
910+
When a `string` argument is passed through, it will perform a whole
911+
case-sensitive match to the description text.
912+
913+
To perform a case-insensitive match, you can use a `RegExp` with the `/i`
914+
modifier.
915+
916+
To perform a partial match, you can pass a `RegExp` or use
917+
`expect.stringContaining("partial string")`.
918+
919+
#### Examples
920+
921+
```html
922+
<button aria-label="Close" aria-describedby="description-close">
923+
X
924+
</button>
925+
<div id="description-close">
926+
Closing will discard any changes
927+
</div>
928+
929+
<button>Delete</button>
930+
```
931+
932+
```javascript
933+
const closeButton = getByRole('button', {name: 'Close'})
934+
935+
expect(closeButton).toHaveDescription('Closing will discard any changes')
936+
expect(closeButton).toHaveDescription(/will discard/) // to partially match
937+
expect(closeButton).toHaveDescription(expect.stringContaining('will discard')) // to partially match
938+
expect(closeButton).toHaveDescription(/^closing/i) // to use case-insensitive match
939+
expect(closeButton).not.toHaveDescription('Other description')
940+
941+
const deleteButton = getByRole('button', {name: 'Delete'})
942+
expect(deleteButton).not.toHaveDescription()
943+
expect(deleteButton).toHaveDescription('') // Missing or empty description always becomes a blank string
944+
```
945+
889946
## Deprecated matchers
890947

891948
### `toBeInTheDOM`
@@ -1026,6 +1083,7 @@ Thanks goes to these people ([emoji key][emojis]):
10261083

10271084
<!-- markdownlint-enable -->
10281085
<!-- prettier-ignore-end -->
1086+
10291087
<!-- ALL-CONTRIBUTORS-LIST:END -->
10301088

10311089
This project follows the [all-contributors][all-contributors] specification.

src/__tests__/helpers/test-utils.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ function render(html) {
55
container.innerHTML = html
66
const queryByTestId = testId =>
77
container.querySelector(`[data-testid="${testId}"]`)
8+
9+
// Some tests need to look up global ids with document.getElementById()
10+
// so we need to be inside an actual document.
11+
document.body.innerHTML = ''
12+
document.body.appendChild(container)
13+
814
return {container, queryByTestId}
915
}
1016

src/__tests__/to-have-description.js

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import {render} from './helpers/test-utils'
2+
3+
describe('.toHaveDescription', () => {
4+
test('handles positive test cases', () => {
5+
const {queryByTestId} = render(`
6+
<div id="description">The description</div>
7+
8+
<div data-testid="single" aria-describedby="description"></div>
9+
<div data-testid="invalid_id" aria-describedby="invalid"></div>
10+
<div data-testid="without"></div>
11+
`)
12+
13+
expect(queryByTestId('single')).toHaveDescription('The description')
14+
expect(queryByTestId('single')).toHaveDescription(
15+
expect.stringContaining('The'),
16+
)
17+
expect(queryByTestId('single')).toHaveDescription(/The/)
18+
expect(queryByTestId('single')).toHaveDescription(
19+
expect.stringMatching(/The/),
20+
)
21+
expect(queryByTestId('single')).toHaveDescription(/description/)
22+
expect(queryByTestId('single')).not.toHaveDescription('Something else')
23+
expect(queryByTestId('single')).not.toHaveDescription('The')
24+
25+
expect(queryByTestId('invalid_id')).not.toHaveDescription()
26+
expect(queryByTestId('invalid_id')).toHaveDescription('')
27+
28+
expect(queryByTestId('without')).not.toHaveDescription()
29+
expect(queryByTestId('without')).toHaveDescription('')
30+
})
31+
32+
test('handles multiple ids', () => {
33+
const {queryByTestId} = render(`
34+
<div id="first">First description</div>
35+
<div id="second">Second description</div>
36+
<div id="third">Third description</div>
37+
38+
<div data-testid="multiple" aria-describedby="first second third"></div>
39+
`)
40+
41+
expect(queryByTestId('multiple')).toHaveDescription(
42+
'First description Second description Third description',
43+
)
44+
expect(queryByTestId('multiple')).toHaveDescription(
45+
/Second description Third/,
46+
)
47+
expect(queryByTestId('multiple')).toHaveDescription(
48+
expect.stringContaining('Second description Third'),
49+
)
50+
expect(queryByTestId('multiple')).toHaveDescription(
51+
expect.stringMatching(/Second description Third/),
52+
)
53+
expect(queryByTestId('multiple')).not.toHaveDescription('Something else')
54+
expect(queryByTestId('multiple')).not.toHaveDescription('First')
55+
})
56+
57+
test('handles negative test cases', () => {
58+
const {queryByTestId} = render(`
59+
<div id="description">The description</div>
60+
<div data-testid="target" aria-describedby="description"></div>
61+
`)
62+
63+
expect(() =>
64+
expect(queryByTestId('other')).toHaveDescription('The description'),
65+
).toThrowError()
66+
67+
expect(() =>
68+
expect(queryByTestId('target')).toHaveDescription('Something else'),
69+
).toThrowError()
70+
71+
expect(() =>
72+
expect(queryByTestId('target')).not.toHaveDescription('The description'),
73+
).toThrowError()
74+
})
75+
76+
test('normalizes whitespace', () => {
77+
const {queryByTestId} = render(`
78+
<div id="first">
79+
Step
80+
1
81+
of
82+
4
83+
</div>
84+
<div id="second">
85+
And
86+
extra
87+
description
88+
</div>
89+
<div data-testid="target" aria-describedby="first second"></div>
90+
`)
91+
92+
expect(queryByTestId('target')).toHaveDescription(
93+
'Step 1 of 4 And extra description',
94+
)
95+
})
96+
97+
test('can handle multiple levels with content spread across decendants', () => {
98+
const {queryByTestId} = render(`
99+
<span id="description">
100+
<span>Step</span>
101+
<span> 1</span>
102+
<span><span>of</span></span>
103+
104+
105+
4</span>
106+
</span>
107+
<div data-testid="target" aria-describedby="description"></div>
108+
`)
109+
110+
expect(queryByTestId('target')).toHaveDescription('Step 1 of 4')
111+
})
112+
113+
test('handles extra whitespace with multiple ids', () => {
114+
const {queryByTestId} = render(`
115+
<div id="first">First description</div>
116+
<div id="second">Second description</div>
117+
<div id="third">Third description</div>
118+
119+
<div data-testid="multiple" aria-describedby=" first
120+
second third
121+
"></div>
122+
`)
123+
124+
expect(queryByTestId('multiple')).toHaveDescription(
125+
'First description Second description Third description',
126+
)
127+
})
128+
129+
test('is case-sensitive', () => {
130+
const {queryByTestId} = render(`
131+
<span id="description">Sensitive text</span>
132+
<div data-testid="target" aria-describedby="description"></div>
133+
`)
134+
135+
expect(queryByTestId('target')).toHaveDescription('Sensitive text')
136+
expect(queryByTestId('target')).not.toHaveDescription('sensitive text')
137+
})
138+
})

src/matchers.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {toBeInvalid, toBeValid} from './to-be-invalid'
1616
import {toHaveValue} from './to-have-value'
1717
import {toHaveDisplayValue} from './to-have-display-value'
1818
import {toBeChecked} from './to-be-checked'
19+
import {toHaveDescription} from './to-have-description'
1920

2021
export {
2122
toBeInTheDOM,
@@ -38,4 +39,5 @@ export {
3839
toHaveValue,
3940
toHaveDisplayValue,
4041
toBeChecked,
42+
toHaveDescription,
4143
}

src/to-have-description.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {matcherHint, printExpected, printReceived} from 'jest-matcher-utils'
2+
import {checkHtmlElement, getMessage, normalize} from './utils'
3+
4+
// See algoritm: https://www.w3.org/TR/accname-1.1/#mapping_additional_nd_description
5+
export function toHaveDescription(htmlElement, checkWith) {
6+
checkHtmlElement(htmlElement, toHaveDescription, this)
7+
8+
const expectsDescription = checkWith !== undefined
9+
10+
const descriptionIDRaw = htmlElement.getAttribute('aria-describedby') || ''
11+
const descriptionIDs = descriptionIDRaw.split(/\s+/).filter(Boolean)
12+
let description = ''
13+
if (descriptionIDs.length > 0) {
14+
const document = htmlElement.ownerDocument
15+
const descriptionEls = descriptionIDs
16+
.map(descriptionID => document.getElementById(descriptionID))
17+
.filter(Boolean)
18+
description = normalize(descriptionEls.map(el => el.textContent).join(' '))
19+
}
20+
21+
return {
22+
pass: expectsDescription
23+
? checkWith instanceof RegExp
24+
? checkWith.test(description)
25+
: this.equals(description, checkWith)
26+
: Boolean(description),
27+
message: () => {
28+
const to = this.isNot ? 'not to' : 'to'
29+
return getMessage(
30+
matcherHint(
31+
`${this.isNot ? '.not' : ''}.toHaveDescription`,
32+
'element',
33+
'',
34+
),
35+
`Expected the element ${to} have description`,
36+
printExpected(checkWith),
37+
'Received',
38+
printReceived(description),
39+
)
40+
},
41+
}
42+
}

0 commit comments

Comments
 (0)