diff --git a/README.md b/README.md index e69ece0f..86a2267a 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 + + + + Invalid time: the time must be between 9:00 AM and 5:00 PM" + +``` + +```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` diff --git a/src/__tests__/to-have-errormessage.js b/src/__tests__/to-have-errormessage.js new file mode 100644 index 00000000..68817653 --- /dev/null +++ b/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(` + + + Invalid time: the time must be between 9:00 AM and 5:00 PM + `) + + 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(` + + + Invalid time: the time must be between 9:00 AM and 5:00 PM + `) + + 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(` +
The errormessage
+
+
+ `) + + 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(` +
The errormessage
+
+ `) + + expect(queryByTestId('invalid_id')).not.toHaveErrorMessage() + expect(queryByTestId('invalid_id')).toHaveErrorMessage('') + }) + + test('handles invalid element without aria-errormessage', () => { + const {queryByTestId} = render(` +
The errormessage
+
+ `) + + expect(queryByTestId('without')).not.toHaveErrorMessage() + expect(queryByTestId('without')).toHaveErrorMessage('') + }) + + test('handles valid element without aria-errormessage', () => { + const {queryByTestId} = render(` +
The errormessage
+
+ `) + + 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(` +
First errormessage
+
Second errormessage
+
Third errormessage
+
+ `) + + 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(` +
The errormessage
+
+ `) + + 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(` +
+ Step + 1 + of + 4 +
+
+ And + extra + errormessage +
+
+ `) + + expect(queryByTestId('target')).toHaveErrorMessage( + 'Step 1 of 4 And extra errormessage', + ) + }) + + test('can handle multiple levels with content spread across decendants', () => { + const {queryByTestId} = render(` + + Step + 1 + of + 4 + +
+ `) + + expect(queryByTestId('target')).toHaveErrorMessage('Step 1 of 4') + }) + + test('handles extra whitespace with multiple ids', () => { + const {queryByTestId} = render(` +
First errormessage
+
Second errormessage
+
Third errormessage
+
+ `) + + expect(queryByTestId('multiple')).toHaveErrorMessage( + 'First errormessage Second errormessage Third errormessage', + ) + }) + + test('is case-sensitive', () => { + const {queryByTestId} = render(` + Sensitive text +
+ `) + + expect(queryByTestId('target')).toHaveErrorMessage('Sensitive text') + expect(queryByTestId('target')).not.toHaveErrorMessage('sensitive text') + }) +}) diff --git a/src/matchers.js b/src/matchers.js index 36896d07..1dbbec77 100644 --- a/src/matchers.js +++ b/src/matchers.js @@ -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, @@ -44,4 +45,5 @@ export { toBeChecked, toBePartiallyChecked, toHaveDescription, + toHaveErrorMessage, } diff --git a/src/to-have-errormessage.js b/src/to-have-errormessage.js new file mode 100644 index 00000000..a253b390 --- /dev/null +++ b/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), + ) + }, + } +}