diff --git a/README.md b/README.md
index 1778f8fe..7c6d3c65 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,7 @@ clear to read and to maintain.
- [`toContainElement`](#tocontainelement)
- [`toContainHTML`](#tocontainhtml)
- [`toHaveAccessibleDescription`](#tohaveaccessibledescription)
+ - [`toHaveAccessibleErrorMessage`](#tohaveaccessibleerrormessage)
- [`toHaveAccessibleName`](#tohaveaccessiblename)
- [`toHaveAttribute`](#tohaveattribute)
- [`toHaveClass`](#tohaveclass)
@@ -561,6 +562,63 @@ expect(getByTestId('logo')).toHaveAccessibleDescription(
+### `toHaveAccessibleErrorMessage`
+
+```typescript
+toHaveAccessibleErrorMessage(expectedAccessibleErrorMessage?: string | RegExp)
+```
+
+This allows you to assert that an element has the expected
+[accessible error message](https://w3c.github.io/aria/#aria-errormessage).
+
+You can pass the exact string of the expected accessible error message.
+Alternatively, you can perform a partial match by passing a regular expression
+or by using
+[expect.stringContaining](https://jestjs.io/docs/en/expect.html#expectnotstringcontainingstring)/[expect.stringMatching](https://jestjs.io/docs/en/expect.html#expectstringmatchingstring-regexp).
+
+#### Examples
+
+```html
+
+This field is invalid
+
+
+
+```
+
+```js
+// Inputs with Valid Error Messages
+expect(getByRole('textbox', {name: 'Has Error'})).toHaveAccessibleErrorMessage()
+expect(getByRole('textbox', {name: 'Has Error'})).toHaveAccessibleErrorMessage(
+ 'This field is invalid',
+)
+expect(getByRole('textbox', {name: 'Has Error'})).toHaveAccessibleErrorMessage(
+ /invalid/i,
+)
+expect(
+ getByRole('textbox', {name: 'Has Error'}),
+).not.toHaveAccessibleErrorMessage('This field is absolutely correct!')
+
+// Inputs without Valid Error Messages
+expect(
+ getByRole('textbox', {name: 'No Error Attributes'}),
+).not.toHaveAccessibleErrorMessage()
+
+expect(
+ getByRole('textbox', {name: 'Not Invalid'}),
+).not.toHaveAccessibleErrorMessage()
+```
+
+
+
### `toHaveAccessibleName`
```typescript
@@ -1069,6 +1127,10 @@ expect(inputCheckboxIndeterminate).toBePartiallyChecked()
### `toHaveErrorMessage`
+> This custom matcher is deprecated. Prefer
+> [`toHaveAccessibleErrorMessage`](#tohaveaccessibleerrormessage) instead, which
+> is more comprehensive in implementing the official spec.
+
```typescript
toHaveErrorMessage(text: string | RegExp)
```
diff --git a/src/__tests__/to-have-accessible-errormessage.js b/src/__tests__/to-have-accessible-errormessage.js
new file mode 100644
index 00000000..1586020f
--- /dev/null
+++ b/src/__tests__/to-have-accessible-errormessage.js
@@ -0,0 +1,277 @@
+import {render} from './helpers/test-utils'
+
+describe('.toHaveAccessibleErrorMessage', () => {
+ const input = 'input'
+ const errorId = 'error-id'
+ const error = 'This field is invalid'
+ const strings = {true: String(true), false: String(false)}
+
+ describe('Positive Test Cases', () => {
+ it("Fails the test if an invalid `id` is provided for the target element's `aria-errormessage`", () => {
+ const secondId = 'id2'
+ const secondError = 'LISTEN TO ME!!!'
+
+ const {queryByTestId} = render(`
+
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId} ${secondId}" />
+
${error}
+
${secondError}
+
+ `)
+
+ const field = queryByTestId('input')
+ expect(() => expect(field).toHaveAccessibleErrorMessage())
+ .toThrowErrorMatchingInlineSnapshot(`
+ expect(>element>).toHaveAccessibleErrorMessage(>expected>)>
+
+ Expected element's \`aria-errormessage\` attribute to be empty or a single, valid ID:
+
+ Received:
+ aria-errormessage="error-id id2">
+ `)
+
+ // Assume the remaining error messages are the EXACT same as above
+ expect(() =>
+ expect(field).toHaveAccessibleErrorMessage(new RegExp(error[0])),
+ ).toThrow()
+
+ expect(() => expect(field).toHaveAccessibleErrorMessage(error)).toThrow()
+ expect(() =>
+ expect(field).toHaveAccessibleErrorMessage(secondError),
+ ).toThrow()
+
+ expect(() =>
+ expect(field).toHaveAccessibleErrorMessage(new RegExp(secondError[0])),
+ ).toThrow()
+ })
+
+ it('Fails the test if the target element is valid according to the WAI-ARIA spec', () => {
+ const noAriaInvalidAttribute = 'no-aria-invalid-attribute'
+ const validFieldState = 'false'
+ const invalidFieldStates = [
+ 'true',
+ '',
+ 'grammar',
+ 'spelling',
+ 'asfdafbasdfasa',
+ ]
+
+ function renderFieldWithState(state) {
+ return render(`
+
+ <${input} data-testid="${input}" aria-invalid="${state}" aria-errormessage="${errorId}" />
+
${error}
+
+
+
+ `)
+ }
+
+ // Success Cases
+ invalidFieldStates.forEach(invalidState => {
+ const {queryByTestId} = renderFieldWithState(invalidState)
+ const field = queryByTestId('input')
+
+ expect(field).toHaveAccessibleErrorMessage()
+ expect(field).toHaveAccessibleErrorMessage(error)
+ })
+
+ // Failure Case
+ const {queryByTestId} = renderFieldWithState(validFieldState)
+ const field = queryByTestId('input')
+ const fieldWithoutAttribute = queryByTestId(noAriaInvalidAttribute)
+
+ expect(() => expect(fieldWithoutAttribute).toHaveAccessibleErrorMessage())
+ .toThrowErrorMatchingInlineSnapshot(`
+ expect(>element>).toHaveAccessibleErrorMessage(>expected>)>
+
+ Expected element to be marked as invalid with attribute:
+ aria-invalid="true">
+ Received:
+ null>
+ `)
+
+ expect(() => expect(field).toHaveAccessibleErrorMessage())
+ .toThrowErrorMatchingInlineSnapshot(`
+ expect(>element>).toHaveAccessibleErrorMessage(>expected>)>
+
+ Expected element to be marked as invalid with attribute:
+ aria-invalid="true">
+ Received:
+ aria-invalid="false>
+ `)
+
+ // Assume the remaining error messages are the EXACT same as above
+ expect(() => expect(field).toHaveAccessibleErrorMessage(error)).toThrow()
+ expect(() =>
+ expect(field).toHaveAccessibleErrorMessage(new RegExp(error, 'i')),
+ ).toThrow()
+ })
+
+ it('Passes the test if the target element has ANY recognized, non-empty error message', () => {
+ const {queryByTestId} = render(`
+
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
+
${error}
+
+ `)
+
+ const field = queryByTestId(input)
+ expect(field).toHaveAccessibleErrorMessage()
+ })
+
+ it('Fails the test if NO recognized, non-empty error message was found for the target element', () => {
+ const empty = 'empty'
+ const emptyErrorId = 'empty-error'
+ const missing = 'missing'
+
+ const {queryByTestId} = render(`
+
+ `)
+
+ const fieldWithEmptyError = queryByTestId(empty)
+ const fieldMissingError = queryByTestId(missing)
+
+ expect(() => expect(fieldWithEmptyError).toHaveAccessibleErrorMessage())
+ .toThrowErrorMatchingInlineSnapshot(`
+ expect(>element>).toHaveAccessibleErrorMessage(>expected>)>
+
+ Expected element to have accessible error message:
+
+ Received:
+
+ `)
+
+ expect(() => expect(fieldMissingError).toHaveAccessibleErrorMessage())
+ .toThrowErrorMatchingInlineSnapshot(`
+ expect(>element>).toHaveAccessibleErrorMessage(>expected>)>
+
+ Expected element to have accessible error message:
+
+ Received:
+
+ `)
+ })
+
+ it('Passes the test if the target element has the error message that was SPECIFIED', () => {
+ const {queryByTestId} = render(`
+
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
+
${error}
+
+ `)
+
+ const field = queryByTestId(input)
+ const halfOfError = error.slice(0, Math.floor(error.length * 0.5))
+
+ expect(field).toHaveAccessibleErrorMessage(error)
+ expect(field).toHaveAccessibleErrorMessage(new RegExp(halfOfError), 'i')
+ expect(field).toHaveAccessibleErrorMessage(
+ expect.stringContaining(halfOfError),
+ )
+ expect(field).toHaveAccessibleErrorMessage(
+ expect.stringMatching(new RegExp(halfOfError), 'i'),
+ )
+ })
+
+ it('Fails the test if the target element DOES NOT have the error message that was SPECIFIED', () => {
+ const {queryByTestId} = render(`
+
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
+
${error}
+
+ `)
+
+ const field = queryByTestId(input)
+ const msg = 'asdflkje2984fguyvb bnafdsasfa;lj'
+
+ expect(() => expect(field).toHaveAccessibleErrorMessage(''))
+ .toThrowErrorMatchingInlineSnapshot(`
+ expect(>element>).toHaveAccessibleErrorMessage(>expected>)>
+
+ Expected element to have accessible error message:
+
+ Received:
+ This field is invalid>
+ `)
+
+ // Assume this error is SIMILAR to the error above
+ expect(() => expect(field).toHaveAccessibleErrorMessage(msg)).toThrow()
+ expect(() =>
+ expect(field).toHaveAccessibleErrorMessage(
+ error.slice(0, Math.floor(error.length * 0.5)),
+ ),
+ ).toThrow()
+
+ expect(() =>
+ expect(field).toHaveAccessibleErrorMessage(new RegExp(msg), 'i'),
+ ).toThrowErrorMatchingInlineSnapshot(`
+ expect(>element>).toHaveAccessibleErrorMessage(>expected>)>
+
+ Expected element to have accessible error message:
+ /asdflkje2984fguyvb bnafdsasfa;lj/>
+ Received:
+ This field is invalid>
+ `)
+ })
+
+ it('Normalizes the whitespace of the received error message', () => {
+ const {queryByTestId} = render(`
+
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
+
+ Step
+ 1
+ of
+ 9000
+
+
+ `)
+
+ const field = queryByTestId(input)
+ expect(field).toHaveAccessibleErrorMessage('Step 1 of 9000')
+ })
+ })
+
+ // These tests for the `.not` use cases will help us cover our bases and complete test coverage
+ describe('Negated Test Cases', () => {
+ it("Passes the test if an invalid `id` is provided for the target element's `aria-errormessage`", () => {
+ const secondId = 'id2'
+ const secondError = 'LISTEN TO ME!!!'
+
+ const {queryByTestId} = render(`
+
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId} ${secondId}" />
+
${error}
+
${secondError}
+
+ `)
+
+ const field = queryByTestId('input')
+ expect(field).not.toHaveAccessibleErrorMessage()
+ expect(field).not.toHaveAccessibleErrorMessage(error)
+ expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0]))
+ expect(field).not.toHaveAccessibleErrorMessage(secondError)
+ expect(field).not.toHaveAccessibleErrorMessage(new RegExp(secondError[0]))
+ })
+
+ it('Passes the test if the target element is valid according to the WAI-ARIA spec', () => {
+ const {queryByTestId} = render(`
+
+ <${input} data-testid="${input}" aria-errormessage="${errorId}" />
+
${error}
+
+ `)
+
+ const field = queryByTestId(input)
+ expect(field).not.toHaveAccessibleErrorMessage()
+ expect(field).not.toHaveAccessibleErrorMessage(error)
+ expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0]))
+ })
+ })
+})
diff --git a/src/matchers.js b/src/matchers.js
index c90945d5..f49b489a 100644
--- a/src/matchers.js
+++ b/src/matchers.js
@@ -1,53 +1,25 @@
-import {toBeInTheDOM} from './to-be-in-the-dom'
-import {toBeInTheDocument} from './to-be-in-the-document'
-import {toBeEmpty} from './to-be-empty'
-import {toBeEmptyDOMElement} from './to-be-empty-dom-element'
-import {toContainElement} from './to-contain-element'
-import {toContainHTML} from './to-contain-html'
-import {toHaveTextContent} from './to-have-text-content'
-import {toHaveAccessibleDescription} from './to-have-accessible-description'
-import {toHaveAccessibleName} from './to-have-accessible-name'
-import {toHaveAttribute} from './to-have-attribute'
-import {toHaveClass} from './to-have-class'
-import {toHaveStyle} from './to-have-style'
-import {toHaveFocus} from './to-have-focus'
-import {toHaveFormValues} from './to-have-form-values'
-import {toBeVisible} from './to-be-visible'
-import {toBeDisabled, toBeEnabled} from './to-be-disabled'
-import {toBeRequired} from './to-be-required'
-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 {toBePartiallyChecked} from './to-be-partially-checked'
-import {toHaveDescription} from './to-have-description'
-import {toHaveErrorMessage} from './to-have-errormessage'
-
-export {
- toBeInTheDOM,
- toBeInTheDocument,
- toBeEmpty,
- toBeEmptyDOMElement,
- toContainElement,
- toContainHTML,
- toHaveTextContent,
- toHaveAccessibleDescription,
- toHaveAccessibleName,
- toHaveAttribute,
- toHaveClass,
- toHaveStyle,
- toHaveFocus,
- toHaveFormValues,
- toBeVisible,
- toBeDisabled,
- toBeEnabled,
- toBeRequired,
- toBeInvalid,
- toBeValid,
- toHaveValue,
- toHaveDisplayValue,
- toBeChecked,
- toBePartiallyChecked,
- toHaveDescription,
- toHaveErrorMessage,
-}
+export {toBeInTheDOM} from './to-be-in-the-dom'
+export {toBeInTheDocument} from './to-be-in-the-document'
+export {toBeEmpty} from './to-be-empty'
+export {toBeEmptyDOMElement} from './to-be-empty-dom-element'
+export {toContainElement} from './to-contain-element'
+export {toContainHTML} from './to-contain-html'
+export {toHaveTextContent} from './to-have-text-content'
+export {toHaveAccessibleDescription} from './to-have-accessible-description'
+export {toHaveAccessibleErrorMessage} from './to-have-accessible-errormessage'
+export {toHaveAccessibleName} from './to-have-accessible-name'
+export {toHaveAttribute} from './to-have-attribute'
+export {toHaveClass} from './to-have-class'
+export {toHaveStyle} from './to-have-style'
+export {toHaveFocus} from './to-have-focus'
+export {toHaveFormValues} from './to-have-form-values'
+export {toBeVisible} from './to-be-visible'
+export {toBeDisabled, toBeEnabled} from './to-be-disabled'
+export {toBeRequired} from './to-be-required'
+export {toBeInvalid, toBeValid} from './to-be-invalid'
+export {toHaveValue} from './to-have-value'
+export {toHaveDisplayValue} from './to-have-display-value'
+export {toBeChecked} from './to-be-checked'
+export {toBePartiallyChecked} from './to-be-partially-checked'
+export {toHaveDescription} from './to-have-description'
+export {toHaveErrorMessage} from './to-have-errormessage'
diff --git a/src/to-have-accessible-errormessage.js b/src/to-have-accessible-errormessage.js
new file mode 100644
index 00000000..d34d310f
--- /dev/null
+++ b/src/to-have-accessible-errormessage.js
@@ -0,0 +1,85 @@
+import {checkHtmlElement, getMessage, normalize} from './utils'
+
+const ariaInvalidName = 'aria-invalid'
+const validStates = ['false']
+
+// See `aria-errormessage` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage
+export function toHaveAccessibleErrorMessage(
+ htmlElement,
+ expectedAccessibleErrorMessage,
+) {
+ checkHtmlElement(htmlElement, toHaveAccessibleErrorMessage, this)
+ const to = this.isNot ? 'not to' : 'to'
+ const method = this.isNot
+ ? '.not.toHaveAccessibleErrorMessage'
+ : '.toHaveAccessibleErrorMessage'
+
+ // Enforce Valid Id
+ const errormessageId = htmlElement.getAttribute('aria-errormessage')
+ const errormessageIdInvalid = !!errormessageId && /\s+/.test(errormessageId)
+
+ if (errormessageIdInvalid) {
+ return {
+ pass: false,
+ message: () => {
+ return getMessage(
+ this,
+ this.utils.matcherHint(method, 'element'),
+ "Expected element's `aria-errormessage` attribute to be empty or a single, valid ID",
+ '',
+ 'Received',
+ `aria-errormessage="${errormessageId}"`,
+ )
+ },
+ }
+ }
+
+ // See `aria-invalid` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
+ const ariaInvalidVal = htmlElement.getAttribute(ariaInvalidName)
+ const fieldValid =
+ !htmlElement.hasAttribute(ariaInvalidName) ||
+ validStates.includes(ariaInvalidVal)
+
+ // Enforce Valid `aria-invalid` Attribute
+ if (fieldValid) {
+ return {
+ pass: false,
+ message: () => {
+ return getMessage(
+ this,
+ this.utils.matcherHint(method, 'element'),
+ 'Expected element to be marked as invalid with attribute',
+ `${ariaInvalidName}="${String(true)}"`,
+ 'Received',
+ htmlElement.hasAttribute('aria-invalid')
+ ? `${ariaInvalidName}="${htmlElement.getAttribute(ariaInvalidName)}`
+ : null,
+ )
+ },
+ }
+ }
+
+ const error = normalize(
+ htmlElement.ownerDocument.getElementById(errormessageId)?.textContent ?? '',
+ )
+
+ return {
+ pass:
+ expectedAccessibleErrorMessage === undefined
+ ? Boolean(error)
+ : expectedAccessibleErrorMessage instanceof RegExp
+ ? expectedAccessibleErrorMessage.test(error)
+ : this.equals(error, expectedAccessibleErrorMessage),
+
+ message: () => {
+ return getMessage(
+ this,
+ this.utils.matcherHint(method, 'element'),
+ `Expected element ${to} have accessible error message`,
+ expectedAccessibleErrorMessage ?? '',
+ 'Received',
+ error,
+ )
+ },
+ }
+}
diff --git a/src/to-have-errormessage.js b/src/to-have-errormessage.js
index a253b390..5b12e4e2 100644
--- a/src/to-have-errormessage.js
+++ b/src/to-have-errormessage.js
@@ -1,7 +1,8 @@
-import {checkHtmlElement, getMessage, normalize} from './utils'
+import {checkHtmlElement, getMessage, normalize, deprecate} from './utils'
// See aria-errormessage spec https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage
export function toHaveErrorMessage(htmlElement, checkWith) {
+ deprecate('toHaveErrorMessage', 'Please use toHaveAccessibleErrorMessage.')
checkHtmlElement(htmlElement, toHaveErrorMessage, this)
if (