Skip to content

Commit d717c66

Browse files
authoredJul 18, 2023
feat: New toHaveAccessibleErrorMessage better implementing the spec, deprecate toHaveErrorMessage (#503)
* fix: Conform `toHaveErrorMessage` to Spec and Rename Included Changes: - According to the WAI-ARIA spec, passing an invalid `id` to `aria-errormessage` now fails assertion. This means that any empty spaces inside `aria-errormessage` will now cause test failures. - According to the WAI-ARIA spec, developers can now assert that an accessible error message is missing if `aria-invalid` is `false` (or if the `aria-errormessage` attribute is invalid). - Updated the error message and test cases surrounding the requirement for `aria-invalid`. They are now more detailed/accurate. - Renamed `toHaveErrorMessage` to `toHaveAccessibleErrorMessage` to be consistent with the other a11y-related methods (`toHaveAccessibleName` and `toHaveAccessibleDescription`). - Note: This deprecates the previous `toHaveErrorMessage` method. - Updated documentation. Similar to the `toHaveAccessibleDescription` method, this description is much more lean, as the reader can simply read the WAI ARIA spec for additional details/requirements. * refactor: Simplify Exports from `matchers.js` This makes the code easier to maintain as more exports are added.
1 parent 948d90f commit d717c66

5 files changed

+451
-54
lines changed
 

‎README.md

+62
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ clear to read and to maintain.
6464
- [`toContainElement`](#tocontainelement)
6565
- [`toContainHTML`](#tocontainhtml)
6666
- [`toHaveAccessibleDescription`](#tohaveaccessibledescription)
67+
- [`toHaveAccessibleErrorMessage`](#tohaveaccessibleerrormessage)
6768
- [`toHaveAccessibleName`](#tohaveaccessiblename)
6869
- [`toHaveAttribute`](#tohaveattribute)
6970
- [`toHaveClass`](#tohaveclass)
@@ -561,6 +562,63 @@ expect(getByTestId('logo')).toHaveAccessibleDescription(
561562

562563
<hr />
563564

565+
### `toHaveAccessibleErrorMessage`
566+
567+
```typescript
568+
toHaveAccessibleErrorMessage(expectedAccessibleErrorMessage?: string | RegExp)
569+
```
570+
571+
This allows you to assert that an element has the expected
572+
[accessible error message](https://w3c.github.io/aria/#aria-errormessage).
573+
574+
You can pass the exact string of the expected accessible error message.
575+
Alternatively, you can perform a partial match by passing a regular expression
576+
or by using
577+
[expect.stringContaining](https://jestjs.io/docs/en/expect.html#expectnotstringcontainingstring)/[expect.stringMatching](https://jestjs.io/docs/en/expect.html#expectstringmatchingstring-regexp).
578+
579+
#### Examples
580+
581+
```html
582+
<input
583+
aria-label="Has Error"
584+
aria-invalid="true"
585+
aria-errormessage="error-message"
586+
/>
587+
<div id="error-message" role="alert">This field is invalid</div>
588+
589+
<input aria-label="No Error Attributes" />
590+
<input
591+
aria-label="Not Invalid"
592+
aria-invalid="false"
593+
aria-errormessage="error-message"
594+
/>
595+
```
596+
597+
```js
598+
// Inputs with Valid Error Messages
599+
expect(getByRole('textbox', {name: 'Has Error'})).toHaveAccessibleErrorMessage()
600+
expect(getByRole('textbox', {name: 'Has Error'})).toHaveAccessibleErrorMessage(
601+
'This field is invalid',
602+
)
603+
expect(getByRole('textbox', {name: 'Has Error'})).toHaveAccessibleErrorMessage(
604+
/invalid/i,
605+
)
606+
expect(
607+
getByRole('textbox', {name: 'Has Error'}),
608+
).not.toHaveAccessibleErrorMessage('This field is absolutely correct!')
609+
610+
// Inputs without Valid Error Messages
611+
expect(
612+
getByRole('textbox', {name: 'No Error Attributes'}),
613+
).not.toHaveAccessibleErrorMessage()
614+
615+
expect(
616+
getByRole('textbox', {name: 'Not Invalid'}),
617+
).not.toHaveAccessibleErrorMessage()
618+
```
619+
620+
<hr />
621+
564622
### `toHaveAccessibleName`
565623

566624
```typescript
@@ -1069,6 +1127,10 @@ expect(inputCheckboxIndeterminate).toBePartiallyChecked()
10691127

10701128
### `toHaveErrorMessage`
10711129

1130+
> This custom matcher is deprecated. Prefer
1131+
> [`toHaveAccessibleErrorMessage`](#tohaveaccessibleerrormessage) instead, which
1132+
> is more comprehensive in implementing the official spec.
1133+
10721134
```typescript
10731135
toHaveErrorMessage(text: string | RegExp)
10741136
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import {render} from './helpers/test-utils'
2+
3+
describe('.toHaveAccessibleErrorMessage', () => {
4+
const input = 'input'
5+
const errorId = 'error-id'
6+
const error = 'This field is invalid'
7+
const strings = {true: String(true), false: String(false)}
8+
9+
describe('Positive Test Cases', () => {
10+
it("Fails the test if an invalid `id` is provided for the target element's `aria-errormessage`", () => {
11+
const secondId = 'id2'
12+
const secondError = 'LISTEN TO ME!!!'
13+
14+
const {queryByTestId} = render(`
15+
<div>
16+
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId} ${secondId}" />
17+
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
18+
<div data-testid="${secondId}" id="${secondId}" role="alert">${secondError}</div>
19+
</div>
20+
`)
21+
22+
const field = queryByTestId('input')
23+
expect(() => expect(field).toHaveAccessibleErrorMessage())
24+
.toThrowErrorMatchingInlineSnapshot(`
25+
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
26+
27+
Expected element's \`aria-errormessage\` attribute to be empty or a single, valid ID:
28+
29+
Received:
30+
<red> aria-errormessage="error-id id2"</>
31+
`)
32+
33+
// Assume the remaining error messages are the EXACT same as above
34+
expect(() =>
35+
expect(field).toHaveAccessibleErrorMessage(new RegExp(error[0])),
36+
).toThrow()
37+
38+
expect(() => expect(field).toHaveAccessibleErrorMessage(error)).toThrow()
39+
expect(() =>
40+
expect(field).toHaveAccessibleErrorMessage(secondError),
41+
).toThrow()
42+
43+
expect(() =>
44+
expect(field).toHaveAccessibleErrorMessage(new RegExp(secondError[0])),
45+
).toThrow()
46+
})
47+
48+
it('Fails the test if the target element is valid according to the WAI-ARIA spec', () => {
49+
const noAriaInvalidAttribute = 'no-aria-invalid-attribute'
50+
const validFieldState = 'false'
51+
const invalidFieldStates = [
52+
'true',
53+
'',
54+
'grammar',
55+
'spelling',
56+
'asfdafbasdfasa',
57+
]
58+
59+
function renderFieldWithState(state) {
60+
return render(`
61+
<div>
62+
<${input} data-testid="${input}" aria-invalid="${state}" aria-errormessage="${errorId}" />
63+
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
64+
65+
<input data-testid="${noAriaInvalidAttribute}" aria-errormessage="${errorId}" />
66+
</div>
67+
`)
68+
}
69+
70+
// Success Cases
71+
invalidFieldStates.forEach(invalidState => {
72+
const {queryByTestId} = renderFieldWithState(invalidState)
73+
const field = queryByTestId('input')
74+
75+
expect(field).toHaveAccessibleErrorMessage()
76+
expect(field).toHaveAccessibleErrorMessage(error)
77+
})
78+
79+
// Failure Case
80+
const {queryByTestId} = renderFieldWithState(validFieldState)
81+
const field = queryByTestId('input')
82+
const fieldWithoutAttribute = queryByTestId(noAriaInvalidAttribute)
83+
84+
expect(() => expect(fieldWithoutAttribute).toHaveAccessibleErrorMessage())
85+
.toThrowErrorMatchingInlineSnapshot(`
86+
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
87+
88+
Expected element to be marked as invalid with attribute:
89+
<green> aria-invalid="true"</>
90+
Received:
91+
<red> null</>
92+
`)
93+
94+
expect(() => expect(field).toHaveAccessibleErrorMessage())
95+
.toThrowErrorMatchingInlineSnapshot(`
96+
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
97+
98+
Expected element to be marked as invalid with attribute:
99+
<green> aria-invalid="true"</>
100+
Received:
101+
<red> aria-invalid="false</>
102+
`)
103+
104+
// Assume the remaining error messages are the EXACT same as above
105+
expect(() => expect(field).toHaveAccessibleErrorMessage(error)).toThrow()
106+
expect(() =>
107+
expect(field).toHaveAccessibleErrorMessage(new RegExp(error, 'i')),
108+
).toThrow()
109+
})
110+
111+
it('Passes the test if the target element has ANY recognized, non-empty error message', () => {
112+
const {queryByTestId} = render(`
113+
<div>
114+
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
115+
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
116+
</div>
117+
`)
118+
119+
const field = queryByTestId(input)
120+
expect(field).toHaveAccessibleErrorMessage()
121+
})
122+
123+
it('Fails the test if NO recognized, non-empty error message was found for the target element', () => {
124+
const empty = 'empty'
125+
const emptyErrorId = 'empty-error'
126+
const missing = 'missing'
127+
128+
const {queryByTestId} = render(`
129+
<div>
130+
<input data-testid="${empty}" aria-invalid="${strings.true}" aria-errormessage="${emptyErrorId}" />
131+
<div data-testid="${emptyErrorId}" id="${emptyErrorId}" role="alert"></div>
132+
133+
<input data-testid="${missing}" aria-invalid="${strings.true}" aria-errormessage="${missing}-error" />
134+
</div>
135+
`)
136+
137+
const fieldWithEmptyError = queryByTestId(empty)
138+
const fieldMissingError = queryByTestId(missing)
139+
140+
expect(() => expect(fieldWithEmptyError).toHaveAccessibleErrorMessage())
141+
.toThrowErrorMatchingInlineSnapshot(`
142+
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
143+
144+
Expected element to have accessible error message:
145+
146+
Received:
147+
148+
`)
149+
150+
expect(() => expect(fieldMissingError).toHaveAccessibleErrorMessage())
151+
.toThrowErrorMatchingInlineSnapshot(`
152+
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
153+
154+
Expected element to have accessible error message:
155+
156+
Received:
157+
158+
`)
159+
})
160+
161+
it('Passes the test if the target element has the error message that was SPECIFIED', () => {
162+
const {queryByTestId} = render(`
163+
<div>
164+
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
165+
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
166+
</div>
167+
`)
168+
169+
const field = queryByTestId(input)
170+
const halfOfError = error.slice(0, Math.floor(error.length * 0.5))
171+
172+
expect(field).toHaveAccessibleErrorMessage(error)
173+
expect(field).toHaveAccessibleErrorMessage(new RegExp(halfOfError), 'i')
174+
expect(field).toHaveAccessibleErrorMessage(
175+
expect.stringContaining(halfOfError),
176+
)
177+
expect(field).toHaveAccessibleErrorMessage(
178+
expect.stringMatching(new RegExp(halfOfError), 'i'),
179+
)
180+
})
181+
182+
it('Fails the test if the target element DOES NOT have the error message that was SPECIFIED', () => {
183+
const {queryByTestId} = render(`
184+
<div>
185+
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
186+
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
187+
</div>
188+
`)
189+
190+
const field = queryByTestId(input)
191+
const msg = 'asdflkje2984fguyvb bnafdsasfa;lj'
192+
193+
expect(() => expect(field).toHaveAccessibleErrorMessage(''))
194+
.toThrowErrorMatchingInlineSnapshot(`
195+
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
196+
197+
Expected element to have accessible error message:
198+
199+
Received:
200+
<red> This field is invalid</>
201+
`)
202+
203+
// Assume this error is SIMILAR to the error above
204+
expect(() => expect(field).toHaveAccessibleErrorMessage(msg)).toThrow()
205+
expect(() =>
206+
expect(field).toHaveAccessibleErrorMessage(
207+
error.slice(0, Math.floor(error.length * 0.5)),
208+
),
209+
).toThrow()
210+
211+
expect(() =>
212+
expect(field).toHaveAccessibleErrorMessage(new RegExp(msg), 'i'),
213+
).toThrowErrorMatchingInlineSnapshot(`
214+
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
215+
216+
Expected element to have accessible error message:
217+
<green> /asdflkje2984fguyvb bnafdsasfa;lj/</>
218+
Received:
219+
<red> This field is invalid</>
220+
`)
221+
})
222+
223+
it('Normalizes the whitespace of the received error message', () => {
224+
const {queryByTestId} = render(`
225+
<div>
226+
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
227+
<div data-testid="${errorId}" id="${errorId}" role="alert">
228+
Step
229+
1
230+
of
231+
9000
232+
</div>
233+
</div>
234+
`)
235+
236+
const field = queryByTestId(input)
237+
expect(field).toHaveAccessibleErrorMessage('Step 1 of 9000')
238+
})
239+
})
240+
241+
// These tests for the `.not` use cases will help us cover our bases and complete test coverage
242+
describe('Negated Test Cases', () => {
243+
it("Passes the test if an invalid `id` is provided for the target element's `aria-errormessage`", () => {
244+
const secondId = 'id2'
245+
const secondError = 'LISTEN TO ME!!!'
246+
247+
const {queryByTestId} = render(`
248+
<div>
249+
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId} ${secondId}" />
250+
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
251+
<div data-testid="${secondId}" id="${secondId}" role="alert">${secondError}</div>
252+
</div>
253+
`)
254+
255+
const field = queryByTestId('input')
256+
expect(field).not.toHaveAccessibleErrorMessage()
257+
expect(field).not.toHaveAccessibleErrorMessage(error)
258+
expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0]))
259+
expect(field).not.toHaveAccessibleErrorMessage(secondError)
260+
expect(field).not.toHaveAccessibleErrorMessage(new RegExp(secondError[0]))
261+
})
262+
263+
it('Passes the test if the target element is valid according to the WAI-ARIA spec', () => {
264+
const {queryByTestId} = render(`
265+
<div>
266+
<${input} data-testid="${input}" aria-errormessage="${errorId}" />
267+
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
268+
</div>
269+
`)
270+
271+
const field = queryByTestId(input)
272+
expect(field).not.toHaveAccessibleErrorMessage()
273+
expect(field).not.toHaveAccessibleErrorMessage(error)
274+
expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0]))
275+
})
276+
})
277+
})

‎src/matchers.js

+25-53
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,25 @@
1-
import {toBeInTheDOM} from './to-be-in-the-dom'
2-
import {toBeInTheDocument} from './to-be-in-the-document'
3-
import {toBeEmpty} from './to-be-empty'
4-
import {toBeEmptyDOMElement} from './to-be-empty-dom-element'
5-
import {toContainElement} from './to-contain-element'
6-
import {toContainHTML} from './to-contain-html'
7-
import {toHaveTextContent} from './to-have-text-content'
8-
import {toHaveAccessibleDescription} from './to-have-accessible-description'
9-
import {toHaveAccessibleName} from './to-have-accessible-name'
10-
import {toHaveAttribute} from './to-have-attribute'
11-
import {toHaveClass} from './to-have-class'
12-
import {toHaveStyle} from './to-have-style'
13-
import {toHaveFocus} from './to-have-focus'
14-
import {toHaveFormValues} from './to-have-form-values'
15-
import {toBeVisible} from './to-be-visible'
16-
import {toBeDisabled, toBeEnabled} from './to-be-disabled'
17-
import {toBeRequired} from './to-be-required'
18-
import {toBeInvalid, toBeValid} from './to-be-invalid'
19-
import {toHaveValue} from './to-have-value'
20-
import {toHaveDisplayValue} from './to-have-display-value'
21-
import {toBeChecked} from './to-be-checked'
22-
import {toBePartiallyChecked} from './to-be-partially-checked'
23-
import {toHaveDescription} from './to-have-description'
24-
import {toHaveErrorMessage} from './to-have-errormessage'
25-
26-
export {
27-
toBeInTheDOM,
28-
toBeInTheDocument,
29-
toBeEmpty,
30-
toBeEmptyDOMElement,
31-
toContainElement,
32-
toContainHTML,
33-
toHaveTextContent,
34-
toHaveAccessibleDescription,
35-
toHaveAccessibleName,
36-
toHaveAttribute,
37-
toHaveClass,
38-
toHaveStyle,
39-
toHaveFocus,
40-
toHaveFormValues,
41-
toBeVisible,
42-
toBeDisabled,
43-
toBeEnabled,
44-
toBeRequired,
45-
toBeInvalid,
46-
toBeValid,
47-
toHaveValue,
48-
toHaveDisplayValue,
49-
toBeChecked,
50-
toBePartiallyChecked,
51-
toHaveDescription,
52-
toHaveErrorMessage,
53-
}
1+
export {toBeInTheDOM} from './to-be-in-the-dom'
2+
export {toBeInTheDocument} from './to-be-in-the-document'
3+
export {toBeEmpty} from './to-be-empty'
4+
export {toBeEmptyDOMElement} from './to-be-empty-dom-element'
5+
export {toContainElement} from './to-contain-element'
6+
export {toContainHTML} from './to-contain-html'
7+
export {toHaveTextContent} from './to-have-text-content'
8+
export {toHaveAccessibleDescription} from './to-have-accessible-description'
9+
export {toHaveAccessibleErrorMessage} from './to-have-accessible-errormessage'
10+
export {toHaveAccessibleName} from './to-have-accessible-name'
11+
export {toHaveAttribute} from './to-have-attribute'
12+
export {toHaveClass} from './to-have-class'
13+
export {toHaveStyle} from './to-have-style'
14+
export {toHaveFocus} from './to-have-focus'
15+
export {toHaveFormValues} from './to-have-form-values'
16+
export {toBeVisible} from './to-be-visible'
17+
export {toBeDisabled, toBeEnabled} from './to-be-disabled'
18+
export {toBeRequired} from './to-be-required'
19+
export {toBeInvalid, toBeValid} from './to-be-invalid'
20+
export {toHaveValue} from './to-have-value'
21+
export {toHaveDisplayValue} from './to-have-display-value'
22+
export {toBeChecked} from './to-be-checked'
23+
export {toBePartiallyChecked} from './to-be-partially-checked'
24+
export {toHaveDescription} from './to-have-description'
25+
export {toHaveErrorMessage} from './to-have-errormessage'
+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {checkHtmlElement, getMessage, normalize} from './utils'
2+
3+
const ariaInvalidName = 'aria-invalid'
4+
const validStates = ['false']
5+
6+
// See `aria-errormessage` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage
7+
export function toHaveAccessibleErrorMessage(
8+
htmlElement,
9+
expectedAccessibleErrorMessage,
10+
) {
11+
checkHtmlElement(htmlElement, toHaveAccessibleErrorMessage, this)
12+
const to = this.isNot ? 'not to' : 'to'
13+
const method = this.isNot
14+
? '.not.toHaveAccessibleErrorMessage'
15+
: '.toHaveAccessibleErrorMessage'
16+
17+
// Enforce Valid Id
18+
const errormessageId = htmlElement.getAttribute('aria-errormessage')
19+
const errormessageIdInvalid = !!errormessageId && /\s+/.test(errormessageId)
20+
21+
if (errormessageIdInvalid) {
22+
return {
23+
pass: false,
24+
message: () => {
25+
return getMessage(
26+
this,
27+
this.utils.matcherHint(method, 'element'),
28+
"Expected element's `aria-errormessage` attribute to be empty or a single, valid ID",
29+
'',
30+
'Received',
31+
`aria-errormessage="${errormessageId}"`,
32+
)
33+
},
34+
}
35+
}
36+
37+
// See `aria-invalid` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
38+
const ariaInvalidVal = htmlElement.getAttribute(ariaInvalidName)
39+
const fieldValid =
40+
!htmlElement.hasAttribute(ariaInvalidName) ||
41+
validStates.includes(ariaInvalidVal)
42+
43+
// Enforce Valid `aria-invalid` Attribute
44+
if (fieldValid) {
45+
return {
46+
pass: false,
47+
message: () => {
48+
return getMessage(
49+
this,
50+
this.utils.matcherHint(method, 'element'),
51+
'Expected element to be marked as invalid with attribute',
52+
`${ariaInvalidName}="${String(true)}"`,
53+
'Received',
54+
htmlElement.hasAttribute('aria-invalid')
55+
? `${ariaInvalidName}="${htmlElement.getAttribute(ariaInvalidName)}`
56+
: null,
57+
)
58+
},
59+
}
60+
}
61+
62+
const error = normalize(
63+
htmlElement.ownerDocument.getElementById(errormessageId)?.textContent ?? '',
64+
)
65+
66+
return {
67+
pass:
68+
expectedAccessibleErrorMessage === undefined
69+
? Boolean(error)
70+
: expectedAccessibleErrorMessage instanceof RegExp
71+
? expectedAccessibleErrorMessage.test(error)
72+
: this.equals(error, expectedAccessibleErrorMessage),
73+
74+
message: () => {
75+
return getMessage(
76+
this,
77+
this.utils.matcherHint(method, 'element'),
78+
`Expected element ${to} have accessible error message`,
79+
expectedAccessibleErrorMessage ?? '',
80+
'Received',
81+
error,
82+
)
83+
},
84+
}
85+
}

‎src/to-have-errormessage.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import {checkHtmlElement, getMessage, normalize} from './utils'
1+
import {checkHtmlElement, getMessage, normalize, deprecate} from './utils'
22

33
// See aria-errormessage spec https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage
44
export function toHaveErrorMessage(htmlElement, checkWith) {
5+
deprecate('toHaveErrorMessage', 'Please use toHaveAccessibleErrorMessage.')
56
checkHtmlElement(htmlElement, toHaveErrorMessage, this)
67

78
if (

0 commit comments

Comments
 (0)
Please sign in to comment.