Skip to content

Commit

Permalink
feat: New toHaveAccessibleErrorMessage better implementing the spec…
Browse files Browse the repository at this point in the history
…, 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.
  • Loading branch information
ITenthusiasm authored Jul 18, 2023
1 parent 948d90f commit d717c66
Show file tree
Hide file tree
Showing 5 changed files with 451 additions and 54 deletions.
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ clear to read and to maintain.
- [`toContainElement`](#tocontainelement)
- [`toContainHTML`](#tocontainhtml)
- [`toHaveAccessibleDescription`](#tohaveaccessibledescription)
- [`toHaveAccessibleErrorMessage`](#tohaveaccessibleerrormessage)
- [`toHaveAccessibleName`](#tohaveaccessiblename)
- [`toHaveAttribute`](#tohaveattribute)
- [`toHaveClass`](#tohaveclass)
Expand Down Expand Up @@ -561,6 +562,63 @@ expect(getByTestId('logo')).toHaveAccessibleDescription(

<hr />

### `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
<input
aria-label="Has Error"
aria-invalid="true"
aria-errormessage="error-message"
/>
<div id="error-message" role="alert">This field is invalid</div>

<input aria-label="No Error Attributes" />
<input
aria-label="Not Invalid"
aria-invalid="false"
aria-errormessage="error-message"
/>
```

```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()
```

<hr />

### `toHaveAccessibleName`

```typescript
Expand Down Expand Up @@ -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)
```
Expand Down
277 changes: 277 additions & 0 deletions src/__tests__/to-have-accessible-errormessage.js
Original file line number Diff line number Diff line change
@@ -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(`
<div>
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId} ${secondId}" />
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
<div data-testid="${secondId}" id="${secondId}" role="alert">${secondError}</div>
</div>
`)

const field = queryByTestId('input')
expect(() => expect(field).toHaveAccessibleErrorMessage())
.toThrowErrorMatchingInlineSnapshot(`
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
Expected element's \`aria-errormessage\` attribute to be empty or a single, valid ID:
Received:
<red> 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(`
<div>
<${input} data-testid="${input}" aria-invalid="${state}" aria-errormessage="${errorId}" />
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
<input data-testid="${noAriaInvalidAttribute}" aria-errormessage="${errorId}" />
</div>
`)
}

// 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(`
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
Expected element to be marked as invalid with attribute:
<green> aria-invalid="true"</>
Received:
<red> null</>
`)

expect(() => expect(field).toHaveAccessibleErrorMessage())
.toThrowErrorMatchingInlineSnapshot(`
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
Expected element to be marked as invalid with attribute:
<green> aria-invalid="true"</>
Received:
<red> 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(`
<div>
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
</div>
`)

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(`
<div>
<input data-testid="${empty}" aria-invalid="${strings.true}" aria-errormessage="${emptyErrorId}" />
<div data-testid="${emptyErrorId}" id="${emptyErrorId}" role="alert"></div>
<input data-testid="${missing}" aria-invalid="${strings.true}" aria-errormessage="${missing}-error" />
</div>
`)

const fieldWithEmptyError = queryByTestId(empty)
const fieldMissingError = queryByTestId(missing)

expect(() => expect(fieldWithEmptyError).toHaveAccessibleErrorMessage())
.toThrowErrorMatchingInlineSnapshot(`
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
Expected element to have accessible error message:
Received:
`)

expect(() => expect(fieldMissingError).toHaveAccessibleErrorMessage())
.toThrowErrorMatchingInlineSnapshot(`
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
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(`
<div>
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
</div>
`)

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(`
<div>
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
</div>
`)

const field = queryByTestId(input)
const msg = 'asdflkje2984fguyvb bnafdsasfa;lj'

expect(() => expect(field).toHaveAccessibleErrorMessage(''))
.toThrowErrorMatchingInlineSnapshot(`
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
Expected element to have accessible error message:
Received:
<red> 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(`
<dim>expect(</><red>element</><dim>).toHaveAccessibleErrorMessage(</><green>expected</><dim>)</>
Expected element to have accessible error message:
<green> /asdflkje2984fguyvb bnafdsasfa;lj/</>
Received:
<red> This field is invalid</>
`)
})

it('Normalizes the whitespace of the received error message', () => {
const {queryByTestId} = render(`
<div>
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" />
<div data-testid="${errorId}" id="${errorId}" role="alert">
Step
1
of
9000
</div>
</div>
`)

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(`
<div>
<${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId} ${secondId}" />
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
<div data-testid="${secondId}" id="${secondId}" role="alert">${secondError}</div>
</div>
`)

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(`
<div>
<${input} data-testid="${input}" aria-errormessage="${errorId}" />
<div data-testid="${errorId}" id="${errorId}" role="alert">${error}</div>
</div>
`)

const field = queryByTestId(input)
expect(field).not.toHaveAccessibleErrorMessage()
expect(field).not.toHaveAccessibleErrorMessage(error)
expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0]))
})
})
})
Loading

0 comments on commit d717c66

Please sign in to comment.