Skip to content

Add toHaveErrorMessage matcher #370

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ clear to read and to maintain.
- [`toBeChecked`](#tobechecked)
- [`toBePartiallyChecked`](#tobepartiallychecked)
- [`toHaveDescription`](#tohavedescription)
- [`toHaveErrorMessage`](#tohaveerrormessage)
- [Deprecated matchers](#deprecated-matchers)
- [`toBeInTheDOM`](#tobeinthedom)
- [Inspiration](#inspiration)
Expand Down Expand Up @@ -1042,6 +1043,58 @@ expect(deleteButton).not.toHaveDescription()
expect(deleteButton).toHaveDescription('') // Missing or empty description always becomes a blank string
```

### `toHaveErrorMessage`

```typescript
toHaveErrorMessage(text: string | RegExp)
```

This allows you to check whether the given element has an
[ARIA error message](https://www.w3.org/TR/wai-aria/#aria-errormessage) or not.

Use the `aria-errormessage` attribute to reference another element that contains
custom error message text. Multiple ids is **NOT** allowed. Authors MUST use
`aria-invalid` in conjunction with `aria-errormessage`. Leran more from
[`aria-errormessage` spec](https://www.w3.org/TR/wai-aria/#aria-errormessage).

Whitespace is normalized.

When a `string` argument is passed through, it will perform a whole
case-sensitive match to the error message text.

To perform a case-insensitive match, you can use a `RegExp` with the `/i`
modifier.

To perform a partial match, you can pass a `RegExp` or use
`expect.stringContaining("partial string")`.

#### Examples

```html
<label for="startTime"> Please enter a start time for the meeting: </label>
<input
id="startTime"
type="text"
aria-errormessage="msgID"
aria-invalid="true"
value="11:30 PM"
/>
<span id="msgID" aria-live="assertive" style="visibility:visible">
Invalid time: the time must be between 9:00 AM and 5:00 PM"
</span>
```

```javascript
const timeInput = getByLabel('startTime')

expect(timeInput).toHaveErrorMessage(
'Invalid time: the time must be between 9:00 AM and 5:00 PM',
)
expect(timeInput).toHaveErrorMessage(/invalid time/i) // to partially match
expect(timeInput).toHaveErrorMessage(expect.stringContaining('Invalid time')) // to partially match
expect(timeInput).not.toHaveErrorMessage('Pikachu!')
```

## Deprecated matchers

### `toBeInTheDOM`
Expand Down
206 changes: 206 additions & 0 deletions src/__tests__/to-have-errormessage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import {render} from './helpers/test-utils'

// eslint-disable-next-line max-lines-per-function
describe('.toHaveErrorMessage', () => {
test('resolves for object with correct aria-errormessage reference', () => {
const {queryByTestId} = render(`
<label for="startTime"> Please enter a start time for the meeting: </label>
<input data-testid="startTime" type="text" aria-errormessage="msgID" aria-invalid="true" value="11:30 PM" >
<span id="msgID" aria-live="assertive" style="visibility:visible"> Invalid time: the time must be between 9:00 AM and 5:00 PM </span>
`)

const timeInput = queryByTestId('startTime')

expect(timeInput).toHaveErrorMessage(
'Invalid time: the time must be between 9:00 AM and 5:00 PM',
)
expect(timeInput).toHaveErrorMessage(/invalid time/i) // to partially match
expect(timeInput).toHaveErrorMessage(
expect.stringContaining('Invalid time'),
) // to partially match
expect(timeInput).not.toHaveErrorMessage('Pikachu!')
})

test('works correctly on implicit invalid element', () => {
const {queryByTestId} = render(`
<label for="startTime"> Please enter a start time for the meeting: </label>
<input data-testid="startTime" type="text" aria-errormessage="msgID" aria-invalid value="11:30 PM" >
<span id="msgID" aria-live="assertive" style="visibility:visible"> Invalid time: the time must be between 9:00 AM and 5:00 PM </span>
`)

const timeInput = queryByTestId('startTime')

expect(timeInput).toHaveErrorMessage(
'Invalid time: the time must be between 9:00 AM and 5:00 PM',
)
expect(timeInput).toHaveErrorMessage(/invalid time/i) // to partially match
expect(timeInput).toHaveErrorMessage(
expect.stringContaining('Invalid time'),
) // to partially match
expect(timeInput).not.toHaveErrorMessage('Pikachu!')
})

test('rejects for valid object', () => {
const {queryByTestId} = render(`
<div id="errormessage">The errormessage</div>
<div data-testid="valid" aria-errormessage="errormessage"></div>
<div data-testid="explicitly_valid" aria-errormessage="errormessage" aria-invalid="false"></div>
`)

expect(queryByTestId('valid')).not.toHaveErrorMessage('The errormessage')
expect(() => {
expect(queryByTestId('valid')).toHaveErrorMessage('The errormessage')
}).toThrowError()

expect(queryByTestId('explicitly_valid')).not.toHaveErrorMessage(
'The errormessage',
)
expect(() => {
expect(queryByTestId('explicitly_valid')).toHaveErrorMessage(
'The errormessage',
)
}).toThrowError()
})

test('rejects for object with incorrect aria-errormessage reference', () => {
const {queryByTestId} = render(`
<div id="errormessage">The errormessage</div>
<div data-testid="invalid_id" aria-errormessage="invalid" aria-invalid="true"></div>
`)

expect(queryByTestId('invalid_id')).not.toHaveErrorMessage()
expect(queryByTestId('invalid_id')).toHaveErrorMessage('')
})

test('handles invalid element without aria-errormessage', () => {
const {queryByTestId} = render(`
<div id="errormessage">The errormessage</div>
<div data-testid="without" aria-invalid="true"></div>
`)

expect(queryByTestId('without')).not.toHaveErrorMessage()
expect(queryByTestId('without')).toHaveErrorMessage('')
})

test('handles valid element without aria-errormessage', () => {
const {queryByTestId} = render(`
<div id="errormessage">The errormessage</div>
<div data-testid="without"></div>
`)

expect(queryByTestId('without')).not.toHaveErrorMessage()
expect(() => {
expect(queryByTestId('without')).toHaveErrorMessage()
}).toThrowError()

expect(queryByTestId('without')).not.toHaveErrorMessage('')
expect(() => {
expect(queryByTestId('without')).toHaveErrorMessage('')
}).toThrowError()
})

test('handles multiple ids', () => {
const {queryByTestId} = render(`
<div id="first">First errormessage</div>
<div id="second">Second errormessage</div>
<div id="third">Third errormessage</div>
<div data-testid="multiple" aria-errormessage="first second third" aria-invalid="true"></div>
`)

expect(queryByTestId('multiple')).toHaveErrorMessage(
'First errormessage Second errormessage Third errormessage',
)
expect(queryByTestId('multiple')).toHaveErrorMessage(
/Second errormessage Third/,
)
expect(queryByTestId('multiple')).toHaveErrorMessage(
expect.stringContaining('Second errormessage Third'),
)
expect(queryByTestId('multiple')).toHaveErrorMessage(
expect.stringMatching(/Second errormessage Third/),
)
expect(queryByTestId('multiple')).not.toHaveErrorMessage('Something else')
expect(queryByTestId('multiple')).not.toHaveErrorMessage('First')
})

test('handles negative test cases', () => {
const {queryByTestId} = render(`
<div id="errormessage">The errormessage</div>
<div data-testid="target" aria-errormessage="errormessage" aria-invalid="true"></div>
`)

expect(() =>
expect(queryByTestId('other')).toHaveErrorMessage('The errormessage'),
).toThrowError()

expect(() =>
expect(queryByTestId('target')).toHaveErrorMessage('Something else'),
).toThrowError()

expect(() =>
expect(queryByTestId('target')).not.toHaveErrorMessage(
'The errormessage',
),
).toThrowError()
})

test('normalizes whitespace', () => {
const {queryByTestId} = render(`
<div id="first">
Step
1
of
4
</div>
<div id="second">
And
extra
errormessage
</div>
<div data-testid="target" aria-errormessage="first second" aria-invalid="true"></div>
`)

expect(queryByTestId('target')).toHaveErrorMessage(
'Step 1 of 4 And extra errormessage',
)
})

test('can handle multiple levels with content spread across decendants', () => {
const {queryByTestId} = render(`
<span id="errormessage">
<span>Step</span>
<span> 1</span>
<span><span>of</span></span>
4</span>
</span>
<div data-testid="target" aria-errormessage="errormessage" aria-invalid="true"></div>
`)

expect(queryByTestId('target')).toHaveErrorMessage('Step 1 of 4')
})

test('handles extra whitespace with multiple ids', () => {
const {queryByTestId} = render(`
<div id="first">First errormessage</div>
<div id="second">Second errormessage</div>
<div id="third">Third errormessage</div>
<div data-testid="multiple" aria-errormessage=" first
second third
" aria-invalid="true"></div>
`)

expect(queryByTestId('multiple')).toHaveErrorMessage(
'First errormessage Second errormessage Third errormessage',
)
})

test('is case-sensitive', () => {
const {queryByTestId} = render(`
<span id="errormessage">Sensitive text</span>
<div data-testid="target" aria-errormessage="errormessage" aria-invalid="true"></div>
`)

expect(queryByTestId('target')).toHaveErrorMessage('Sensitive text')
expect(queryByTestId('target')).not.toHaveErrorMessage('sensitive text')
})
})
2 changes: 2 additions & 0 deletions src/matchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ 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,
Expand All @@ -44,4 +45,5 @@ export {
toBeChecked,
toBePartiallyChecked,
toHaveDescription,
toHaveErrorMessage,
}
70 changes: 70 additions & 0 deletions src/to-have-errormessage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {checkHtmlElement, getMessage, normalize} from './utils'

// See aria-errormessage spec https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage
export function toHaveErrorMessage(htmlElement, checkWith) {
checkHtmlElement(htmlElement, toHaveErrorMessage, this)

if (
!htmlElement.hasAttribute('aria-invalid') ||
htmlElement.getAttribute('aria-invalid') === 'false'
) {
const not = this.isNot ? '.not' : ''

return {
pass: false,
message: () => {
return getMessage(
this,
this.utils.matcherHint(`${not}.toHaveErrorMessage`, 'element', ''),
`Expected the element to have invalid state indicated by`,
'aria-invalid="true"',
'Received',
htmlElement.hasAttribute('aria-invalid')
? `aria-invalid="${htmlElement.getAttribute('aria-invalid')}"`
: this.utils.printReceived(''),
)
},
}
}

const expectsErrorMessage = checkWith !== undefined

const errormessageIDRaw = htmlElement.getAttribute('aria-errormessage') || ''
const errormessageIDs = errormessageIDRaw.split(/\s+/).filter(Boolean)

let errormessage = ''
if (errormessageIDs.length > 0) {
const document = htmlElement.ownerDocument

const errormessageEls = errormessageIDs
.map(errormessageID => document.getElementById(errormessageID))
.filter(Boolean)

errormessage = normalize(
errormessageEls.map(el => el.textContent).join(' '),
)
}

return {
pass: expectsErrorMessage
? checkWith instanceof RegExp
? checkWith.test(errormessage)
: this.equals(errormessage, checkWith)
: Boolean(errormessage),
message: () => {
const to = this.isNot ? 'not to' : 'to'
return getMessage(
this,
this.utils.matcherHint(
`${this.isNot ? '.not' : ''}.toHaveErrorMessage`,
'element',
'',
),
`Expected the element ${to} have error message`,
this.utils.printExpected(checkWith),
'Received',
this.utils.printReceived(errormessage),
)
},
}
}