Skip to content

Commit 217fdcc

Browse files
authored
feat: Add toHaveErrorMessage matcher (#370)
* feat: add toHaveErrorMessage matcher * docs: add docs for toHaveErrorMessage update test cases to match example
1 parent c816955 commit 217fdcc

File tree

4 files changed

+331
-0
lines changed

4 files changed

+331
-0
lines changed

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ clear to read and to maintain.
7575
- [`toBeChecked`](#tobechecked)
7676
- [`toBePartiallyChecked`](#tobepartiallychecked)
7777
- [`toHaveDescription`](#tohavedescription)
78+
- [`toHaveErrorMessage`](#tohaveerrormessage)
7879
- [Deprecated matchers](#deprecated-matchers)
7980
- [`toBeInTheDOM`](#tobeinthedom)
8081
- [Inspiration](#inspiration)
@@ -1042,6 +1043,58 @@ expect(deleteButton).not.toHaveDescription()
10421043
expect(deleteButton).toHaveDescription('') // Missing or empty description always becomes a blank string
10431044
```
10441045

1046+
### `toHaveErrorMessage`
1047+
1048+
```typescript
1049+
toHaveErrorMessage(text: string | RegExp)
1050+
```
1051+
1052+
This allows you to check whether the given element has an
1053+
[ARIA error message](https://www.w3.org/TR/wai-aria/#aria-errormessage) or not.
1054+
1055+
Use the `aria-errormessage` attribute to reference another element that contains
1056+
custom error message text. Multiple ids is **NOT** allowed. Authors MUST use
1057+
`aria-invalid` in conjunction with `aria-errormessage`. Leran more from
1058+
[`aria-errormessage` spec](https://www.w3.org/TR/wai-aria/#aria-errormessage).
1059+
1060+
Whitespace is normalized.
1061+
1062+
When a `string` argument is passed through, it will perform a whole
1063+
case-sensitive match to the error message text.
1064+
1065+
To perform a case-insensitive match, you can use a `RegExp` with the `/i`
1066+
modifier.
1067+
1068+
To perform a partial match, you can pass a `RegExp` or use
1069+
`expect.stringContaining("partial string")`.
1070+
1071+
#### Examples
1072+
1073+
```html
1074+
<label for="startTime"> Please enter a start time for the meeting: </label>
1075+
<input
1076+
id="startTime"
1077+
type="text"
1078+
aria-errormessage="msgID"
1079+
aria-invalid="true"
1080+
value="11:30 PM"
1081+
/>
1082+
<span id="msgID" aria-live="assertive" style="visibility:visible">
1083+
Invalid time: the time must be between 9:00 AM and 5:00 PM"
1084+
</span>
1085+
```
1086+
1087+
```javascript
1088+
const timeInput = getByLabel('startTime')
1089+
1090+
expect(timeInput).toHaveErrorMessage(
1091+
'Invalid time: the time must be between 9:00 AM and 5:00 PM',
1092+
)
1093+
expect(timeInput).toHaveErrorMessage(/invalid time/i) // to partially match
1094+
expect(timeInput).toHaveErrorMessage(expect.stringContaining('Invalid time')) // to partially match
1095+
expect(timeInput).not.toHaveErrorMessage('Pikachu!')
1096+
```
1097+
10451098
## Deprecated matchers
10461099

10471100
### `toBeInTheDOM`

src/__tests__/to-have-errormessage.js

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import {render} from './helpers/test-utils'
2+
3+
// eslint-disable-next-line max-lines-per-function
4+
describe('.toHaveErrorMessage', () => {
5+
test('resolves for object with correct aria-errormessage reference', () => {
6+
const {queryByTestId} = render(`
7+
<label for="startTime"> Please enter a start time for the meeting: </label>
8+
<input data-testid="startTime" type="text" aria-errormessage="msgID" aria-invalid="true" value="11:30 PM" >
9+
<span id="msgID" aria-live="assertive" style="visibility:visible"> Invalid time: the time must be between 9:00 AM and 5:00 PM </span>
10+
`)
11+
12+
const timeInput = queryByTestId('startTime')
13+
14+
expect(timeInput).toHaveErrorMessage(
15+
'Invalid time: the time must be between 9:00 AM and 5:00 PM',
16+
)
17+
expect(timeInput).toHaveErrorMessage(/invalid time/i) // to partially match
18+
expect(timeInput).toHaveErrorMessage(
19+
expect.stringContaining('Invalid time'),
20+
) // to partially match
21+
expect(timeInput).not.toHaveErrorMessage('Pikachu!')
22+
})
23+
24+
test('works correctly on implicit invalid element', () => {
25+
const {queryByTestId} = render(`
26+
<label for="startTime"> Please enter a start time for the meeting: </label>
27+
<input data-testid="startTime" type="text" aria-errormessage="msgID" aria-invalid value="11:30 PM" >
28+
<span id="msgID" aria-live="assertive" style="visibility:visible"> Invalid time: the time must be between 9:00 AM and 5:00 PM </span>
29+
`)
30+
31+
const timeInput = queryByTestId('startTime')
32+
33+
expect(timeInput).toHaveErrorMessage(
34+
'Invalid time: the time must be between 9:00 AM and 5:00 PM',
35+
)
36+
expect(timeInput).toHaveErrorMessage(/invalid time/i) // to partially match
37+
expect(timeInput).toHaveErrorMessage(
38+
expect.stringContaining('Invalid time'),
39+
) // to partially match
40+
expect(timeInput).not.toHaveErrorMessage('Pikachu!')
41+
})
42+
43+
test('rejects for valid object', () => {
44+
const {queryByTestId} = render(`
45+
<div id="errormessage">The errormessage</div>
46+
<div data-testid="valid" aria-errormessage="errormessage"></div>
47+
<div data-testid="explicitly_valid" aria-errormessage="errormessage" aria-invalid="false"></div>
48+
`)
49+
50+
expect(queryByTestId('valid')).not.toHaveErrorMessage('The errormessage')
51+
expect(() => {
52+
expect(queryByTestId('valid')).toHaveErrorMessage('The errormessage')
53+
}).toThrowError()
54+
55+
expect(queryByTestId('explicitly_valid')).not.toHaveErrorMessage(
56+
'The errormessage',
57+
)
58+
expect(() => {
59+
expect(queryByTestId('explicitly_valid')).toHaveErrorMessage(
60+
'The errormessage',
61+
)
62+
}).toThrowError()
63+
})
64+
65+
test('rejects for object with incorrect aria-errormessage reference', () => {
66+
const {queryByTestId} = render(`
67+
<div id="errormessage">The errormessage</div>
68+
<div data-testid="invalid_id" aria-errormessage="invalid" aria-invalid="true"></div>
69+
`)
70+
71+
expect(queryByTestId('invalid_id')).not.toHaveErrorMessage()
72+
expect(queryByTestId('invalid_id')).toHaveErrorMessage('')
73+
})
74+
75+
test('handles invalid element without aria-errormessage', () => {
76+
const {queryByTestId} = render(`
77+
<div id="errormessage">The errormessage</div>
78+
<div data-testid="without" aria-invalid="true"></div>
79+
`)
80+
81+
expect(queryByTestId('without')).not.toHaveErrorMessage()
82+
expect(queryByTestId('without')).toHaveErrorMessage('')
83+
})
84+
85+
test('handles valid element without aria-errormessage', () => {
86+
const {queryByTestId} = render(`
87+
<div id="errormessage">The errormessage</div>
88+
<div data-testid="without"></div>
89+
`)
90+
91+
expect(queryByTestId('without')).not.toHaveErrorMessage()
92+
expect(() => {
93+
expect(queryByTestId('without')).toHaveErrorMessage()
94+
}).toThrowError()
95+
96+
expect(queryByTestId('without')).not.toHaveErrorMessage('')
97+
expect(() => {
98+
expect(queryByTestId('without')).toHaveErrorMessage('')
99+
}).toThrowError()
100+
})
101+
102+
test('handles multiple ids', () => {
103+
const {queryByTestId} = render(`
104+
<div id="first">First errormessage</div>
105+
<div id="second">Second errormessage</div>
106+
<div id="third">Third errormessage</div>
107+
<div data-testid="multiple" aria-errormessage="first second third" aria-invalid="true"></div>
108+
`)
109+
110+
expect(queryByTestId('multiple')).toHaveErrorMessage(
111+
'First errormessage Second errormessage Third errormessage',
112+
)
113+
expect(queryByTestId('multiple')).toHaveErrorMessage(
114+
/Second errormessage Third/,
115+
)
116+
expect(queryByTestId('multiple')).toHaveErrorMessage(
117+
expect.stringContaining('Second errormessage Third'),
118+
)
119+
expect(queryByTestId('multiple')).toHaveErrorMessage(
120+
expect.stringMatching(/Second errormessage Third/),
121+
)
122+
expect(queryByTestId('multiple')).not.toHaveErrorMessage('Something else')
123+
expect(queryByTestId('multiple')).not.toHaveErrorMessage('First')
124+
})
125+
126+
test('handles negative test cases', () => {
127+
const {queryByTestId} = render(`
128+
<div id="errormessage">The errormessage</div>
129+
<div data-testid="target" aria-errormessage="errormessage" aria-invalid="true"></div>
130+
`)
131+
132+
expect(() =>
133+
expect(queryByTestId('other')).toHaveErrorMessage('The errormessage'),
134+
).toThrowError()
135+
136+
expect(() =>
137+
expect(queryByTestId('target')).toHaveErrorMessage('Something else'),
138+
).toThrowError()
139+
140+
expect(() =>
141+
expect(queryByTestId('target')).not.toHaveErrorMessage(
142+
'The errormessage',
143+
),
144+
).toThrowError()
145+
})
146+
147+
test('normalizes whitespace', () => {
148+
const {queryByTestId} = render(`
149+
<div id="first">
150+
Step
151+
1
152+
of
153+
4
154+
</div>
155+
<div id="second">
156+
And
157+
extra
158+
errormessage
159+
</div>
160+
<div data-testid="target" aria-errormessage="first second" aria-invalid="true"></div>
161+
`)
162+
163+
expect(queryByTestId('target')).toHaveErrorMessage(
164+
'Step 1 of 4 And extra errormessage',
165+
)
166+
})
167+
168+
test('can handle multiple levels with content spread across decendants', () => {
169+
const {queryByTestId} = render(`
170+
<span id="errormessage">
171+
<span>Step</span>
172+
<span> 1</span>
173+
<span><span>of</span></span>
174+
4</span>
175+
</span>
176+
<div data-testid="target" aria-errormessage="errormessage" aria-invalid="true"></div>
177+
`)
178+
179+
expect(queryByTestId('target')).toHaveErrorMessage('Step 1 of 4')
180+
})
181+
182+
test('handles extra whitespace with multiple ids', () => {
183+
const {queryByTestId} = render(`
184+
<div id="first">First errormessage</div>
185+
<div id="second">Second errormessage</div>
186+
<div id="third">Third errormessage</div>
187+
<div data-testid="multiple" aria-errormessage=" first
188+
second third
189+
" aria-invalid="true"></div>
190+
`)
191+
192+
expect(queryByTestId('multiple')).toHaveErrorMessage(
193+
'First errormessage Second errormessage Third errormessage',
194+
)
195+
})
196+
197+
test('is case-sensitive', () => {
198+
const {queryByTestId} = render(`
199+
<span id="errormessage">Sensitive text</span>
200+
<div data-testid="target" aria-errormessage="errormessage" aria-invalid="true"></div>
201+
`)
202+
203+
expect(queryByTestId('target')).toHaveErrorMessage('Sensitive text')
204+
expect(queryByTestId('target')).not.toHaveErrorMessage('sensitive text')
205+
})
206+
})

src/matchers.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {toHaveDisplayValue} from './to-have-display-value'
1919
import {toBeChecked} from './to-be-checked'
2020
import {toBePartiallyChecked} from './to-be-partially-checked'
2121
import {toHaveDescription} from './to-have-description'
22+
import {toHaveErrorMessage} from './to-have-errormessage'
2223

2324
export {
2425
toBeInTheDOM,
@@ -44,4 +45,5 @@ export {
4445
toBeChecked,
4546
toBePartiallyChecked,
4647
toHaveDescription,
48+
toHaveErrorMessage,
4749
}

src/to-have-errormessage.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {checkHtmlElement, getMessage, normalize} from './utils'
2+
3+
// See aria-errormessage spec https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage
4+
export function toHaveErrorMessage(htmlElement, checkWith) {
5+
checkHtmlElement(htmlElement, toHaveErrorMessage, this)
6+
7+
if (
8+
!htmlElement.hasAttribute('aria-invalid') ||
9+
htmlElement.getAttribute('aria-invalid') === 'false'
10+
) {
11+
const not = this.isNot ? '.not' : ''
12+
13+
return {
14+
pass: false,
15+
message: () => {
16+
return getMessage(
17+
this,
18+
this.utils.matcherHint(`${not}.toHaveErrorMessage`, 'element', ''),
19+
`Expected the element to have invalid state indicated by`,
20+
'aria-invalid="true"',
21+
'Received',
22+
htmlElement.hasAttribute('aria-invalid')
23+
? `aria-invalid="${htmlElement.getAttribute('aria-invalid')}"`
24+
: this.utils.printReceived(''),
25+
)
26+
},
27+
}
28+
}
29+
30+
const expectsErrorMessage = checkWith !== undefined
31+
32+
const errormessageIDRaw = htmlElement.getAttribute('aria-errormessage') || ''
33+
const errormessageIDs = errormessageIDRaw.split(/\s+/).filter(Boolean)
34+
35+
let errormessage = ''
36+
if (errormessageIDs.length > 0) {
37+
const document = htmlElement.ownerDocument
38+
39+
const errormessageEls = errormessageIDs
40+
.map(errormessageID => document.getElementById(errormessageID))
41+
.filter(Boolean)
42+
43+
errormessage = normalize(
44+
errormessageEls.map(el => el.textContent).join(' '),
45+
)
46+
}
47+
48+
return {
49+
pass: expectsErrorMessage
50+
? checkWith instanceof RegExp
51+
? checkWith.test(errormessage)
52+
: this.equals(errormessage, checkWith)
53+
: Boolean(errormessage),
54+
message: () => {
55+
const to = this.isNot ? 'not to' : 'to'
56+
return getMessage(
57+
this,
58+
this.utils.matcherHint(
59+
`${this.isNot ? '.not' : ''}.toHaveErrorMessage`,
60+
'element',
61+
'',
62+
),
63+
`Expected the element ${to} have error message`,
64+
this.utils.printExpected(checkWith),
65+
'Received',
66+
this.utils.printReceived(errormessage),
67+
)
68+
},
69+
}
70+
}

0 commit comments

Comments
 (0)