Skip to content

Commit

Permalink
feat: Add toHaveErrorMessage matcher (#370)
Browse files Browse the repository at this point in the history
* feat: add toHaveErrorMessage matcher
* docs: add docs for toHaveErrorMessage
update test cases to match example
  • Loading branch information
SevenOutman committed Jun 3, 2021
1 parent c816955 commit 217fdcc
Show file tree
Hide file tree
Showing 4 changed files with 331 additions and 0 deletions.
53 changes: 53 additions & 0 deletions README.md
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
@@ -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
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
@@ -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),
)
},
}
}

0 comments on commit 217fdcc

Please sign in to comment.