Skip to content

Commit 55dff99

Browse files
committed
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.
1 parent 948d90f commit 55dff99

File tree

4 files changed

+370
-1
lines changed

4 files changed

+370
-1
lines changed
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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+
expect(field).toHaveAccessibleErrorMessage(error)
171+
expect(field).toHaveAccessibleErrorMessage(
172+
new RegExp(error.slice(0, Math.floor(error.length * 0.5))),
173+
'i',
174+
)
175+
})
176+
177+
it('Fails the test if the target element DOES NOT have the error message that was SPECIFIED', () => {
178+
const {queryByTestId} = render(`
179+
<div>
180+
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
181+
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
182+
</div>
183+
`)
184+
185+
const field = queryByTestId(input)
186+
const msg = 'asdflkje2984fguyvb bnafdsasfa;lj'
187+
188+
expect(() => expect(field).toHaveAccessibleErrorMessage(''))
189+
.toThrowErrorMatchingInlineSnapshot(`
190+
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
191+
192+
Expected element to have accessible error message:
193+
194+
Received:
195+
<red> This field is invalid</>
196+
`)
197+
198+
// Assume this error is SIMILAR to the error above
199+
expect(() => expect(field).toHaveAccessibleErrorMessage(msg)).toThrow()
200+
expect(() =>
201+
expect(field).toHaveAccessibleErrorMessage(
202+
error.slice(0, Math.floor(error.length * 0.5)),
203+
),
204+
).toThrow()
205+
206+
expect(() =>
207+
expect(field).toHaveAccessibleErrorMessage(new RegExp(msg), 'i'),
208+
).toThrowErrorMatchingInlineSnapshot(`
209+
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
210+
211+
Expected element to have accessible error message:
212+
<green> /asdflkje2984fguyvb bnafdsasfa;lj/</>
213+
Received:
214+
<red> This field is invalid</>
215+
`)
216+
})
217+
218+
it('Normalizes the whitespace of the received error message', () => {
219+
const {queryByTestId} = render(`
220+
<div>
221+
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
222+
<div data-testid="${errorId}" id="${errorId}" role="alert">
223+
Step
224+
1
225+
of
226+
9000
227+
</div>
228+
</div>
229+
`)
230+
231+
const field = queryByTestId(input)
232+
expect(field).toHaveAccessibleErrorMessage('Step 1 of 9000')
233+
})
234+
})
235+
236+
// These tests for the `.not` use cases will help us cover our bases and complete test coverage
237+
describe('Negated Test Cases', () => {
238+
it("Passes the test if an invalid `id` is provided for the target element's `aria-errormessage`", () => {
239+
const secondId = 'id2'
240+
const secondError = 'LISTEN TO ME!!!'
241+
242+
const {queryByTestId} = render(`
243+
<div>
244+
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId} ${secondId}" />
245+
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
246+
<div data-testid="${secondId}" id="${secondId}" role="alert">${secondError}</div>
247+
</div>
248+
`)
249+
250+
const field = queryByTestId('input')
251+
expect(field).not.toHaveAccessibleErrorMessage()
252+
expect(field).not.toHaveAccessibleErrorMessage(error)
253+
expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0]))
254+
expect(field).not.toHaveAccessibleErrorMessage(secondError)
255+
expect(field).not.toHaveAccessibleErrorMessage(new RegExp(secondError[0]))
256+
})
257+
258+
it('Passes the test if the target element is valid according to the WAI-ARIA spec', () => {
259+
const {queryByTestId} = render(`
260+
<div>
261+
<${input} data-testid="${input}" aria-errormessage="${errorId}" />
262+
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
263+
</div>
264+
`)
265+
266+
const field = queryByTestId(input)
267+
expect(field).not.toHaveErrorMessage()
268+
expect(field).not.toHaveAccessibleErrorMessage(error)
269+
expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0]))
270+
})
271+
})
272+
})

src/matchers.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {toContainElement} from './to-contain-element'
66
import {toContainHTML} from './to-contain-html'
77
import {toHaveTextContent} from './to-have-text-content'
88
import {toHaveAccessibleDescription} from './to-have-accessible-description'
9+
import {toHaveAccessibleErrorMessage} from './to-have-accessible-errormessage'
910
import {toHaveAccessibleName} from './to-have-accessible-name'
1011
import {toHaveAttribute} from './to-have-attribute'
1112
import {toHaveClass} from './to-have-class'
@@ -32,6 +33,7 @@ export {
3233
toContainHTML,
3334
toHaveTextContent,
3435
toHaveAccessibleDescription,
36+
toHaveAccessibleErrorMessage,
3537
toHaveAccessibleName,
3638
toHaveAttribute,
3739
toHaveClass,
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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(htmlElement, expectedError) {
8+
checkHtmlElement(htmlElement, toHaveAccessibleErrorMessage, this)
9+
const to = this.isNot ? 'not to' : 'to'
10+
const method = this.isNot
11+
? '.not.toHaveAccessibleErrorMessage'
12+
: '.toHaveAccessibleErrorMessage'
13+
14+
/*
15+
* Enforce Valid Id
16+
*
17+
* NOTE: According to the spec, an invalid `id` would technically cause the element NOT to have an accessible error.
18+
* So we can't fail the `.not` test cases here.
19+
*/
20+
const errormessageId = htmlElement.getAttribute('aria-errormessage')
21+
const errormessageIdInvalid = !!errormessageId && /\s+/.test(errormessageId)
22+
23+
if (!this.isNot && errormessageIdInvalid) {
24+
return {
25+
pass: false,
26+
message: () => {
27+
return getMessage(
28+
this,
29+
this.utils.matcherHint(method, 'element'),
30+
"Expected element's `aria-errormessage` attribute to be empty or a single, valid ID",
31+
'',
32+
'Received',
33+
`aria-errormessage="${errormessageId}"`,
34+
)
35+
},
36+
}
37+
}
38+
39+
// See `aria-invalid` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
40+
const ariaInvalidVal = htmlElement.getAttribute(ariaInvalidName)
41+
const fieldValid =
42+
!htmlElement.hasAttribute(ariaInvalidName) ||
43+
validStates.includes(ariaInvalidVal)
44+
45+
/*
46+
* Enforce Valid `aria-invalid` Attribute
47+
*
48+
* NOTE: It is possible to have `aria-invalid="true"` but still lack an accessible error.
49+
* So we can't fail the `.not` test cases here.
50+
*/
51+
if (!this.isNot && fieldValid) {
52+
return {
53+
pass: false,
54+
message: () => {
55+
return getMessage(
56+
this,
57+
this.utils.matcherHint(method, 'element'),
58+
'Expected element to be marked as invalid with attribute',
59+
`${ariaInvalidName}="${String(true)}"`,
60+
'Received',
61+
htmlElement.hasAttribute('aria-invalid')
62+
? `${ariaInvalidName}="${htmlElement.getAttribute(ariaInvalidName)}`
63+
: null,
64+
)
65+
},
66+
}
67+
}
68+
69+
const document = htmlElement.ownerDocument
70+
const error =
71+
errormessageIdInvalid || fieldValid
72+
? ''
73+
: normalize(document.getElementById(errormessageId)?.textContent ?? '')
74+
75+
return {
76+
pass:
77+
expectedError === undefined
78+
? Boolean(error)
79+
: expectedError instanceof RegExp
80+
? expectedError.test(error)
81+
: this.equals(error, expectedError),
82+
83+
message: () => {
84+
return getMessage(
85+
this,
86+
this.utils.matcherHint(method, 'element'),
87+
`Expected element ${to} have accessible error message`,
88+
expectedError ?? '',
89+
'Received',
90+
error,
91+
)
92+
},
93+
}
94+
}

src/to-have-errormessage.js

Lines changed: 2 additions & 1 deletion
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)