diff --git a/README.md b/README.md index 913cf19..d3b1bff 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ clear to read and to maintain. - [`toHaveDisplayValue`](#tohavedisplayvalue) - [`toBeChecked`](#tobechecked) - [`toBePartiallyChecked`](#tobepartiallychecked) + - [`toHaveRole`](#tohaverole) - [`toHaveErrorMessage`](#tohaveerrormessage) - [Deprecated matchers](#deprecated-matchers) - [`toBeEmpty`](#tobeempty) @@ -1189,6 +1190,47 @@ expect(inputCheckboxIndeterminate).toBePartiallyChecked()
+### `toHaveRole` + +This allows you to assert that an element has the expected +[role](https://www.w3.org/TR/html-aria/#docconformance). + +This is useful in cases where you already have access to an element via some +query other than the role itself, and want to make additional assertions +regarding its accessibility. + +The role can match either an explicit role (via the `role` attribute), or an +implicit one via the +[implicit ARIA semantics](https://www.w3.org/TR/html-aria/). + +Note: roles are matched literally by string equality, without inheriting from +the ARIA role hierarchy. As a result, querying a superclass role like 'checkbox' +will not include elements with a subclass role like 'switch'. + +```typescript +toHaveRole(expectedRole: string) +``` + +```html + +
Continue + +About +Invalid link +``` + +```javascript +expect(getByTestId('button')).toHaveRole('button') +expect(getByTestId('button-explicit')).toHaveRole('button') +expect(getByTestId('button-explicit-multiple')).toHaveRole('button') +expect(getByTestId('button-explicit-multiple')).toHaveRole('switch') +expect(getByTestId('link')).toHaveRole('link') +expect(getByTestId('link-invalid')).not.toHaveRole('link') +expect(getByTestId('link-invalid')).toHaveRole('generic') +``` + +
+ ### `toHaveErrorMessage` > This custom matcher is deprecated. Prefer diff --git a/src/__tests__/to-have-role.js b/src/__tests__/to-have-role.js new file mode 100644 index 0000000..c9e039c --- /dev/null +++ b/src/__tests__/to-have-role.js @@ -0,0 +1,107 @@ +import {render} from './helpers/test-utils' + +describe('.toHaveRole', () => { + it('matches implicit role', () => { + const {queryByTestId} = render(` +
+ +
+ `) + + const continueButton = queryByTestId('continue-button') + + expect(continueButton).not.toHaveRole('listitem') + expect(continueButton).toHaveRole('button') + + expect(() => { + expect(continueButton).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(continueButton).not.toHaveRole('button') + }).toThrow(/expected element not to have role/i) + }) + + it('matches explicit role', () => { + const {queryByTestId} = render(` +
+
Continue
+
+ `) + + const continueButton = queryByTestId('continue-button') + + expect(continueButton).not.toHaveRole('listitem') + expect(continueButton).toHaveRole('button') + + expect(() => { + expect(continueButton).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(continueButton).not.toHaveRole('button') + }).toThrow(/expected element not to have role/i) + }) + + it('matches multiple explicit roles', () => { + const {queryByTestId} = render(` +
+
Continue
+
+ `) + + const continueButton = queryByTestId('continue-button') + + expect(continueButton).not.toHaveRole('listitem') + expect(continueButton).toHaveRole('button') + expect(continueButton).toHaveRole('switch') + + expect(() => { + expect(continueButton).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(continueButton).not.toHaveRole('button') + }).toThrow(/expected element not to have role/i) + expect(() => { + expect(continueButton).not.toHaveRole('switch') + }).toThrow(/expected element not to have role/i) + }) + + // At this point, we might be testing the details of getImplicitAriaRoles, but + // it's good to have a gut check + it('handles implicit roles with multiple conditions', () => { + const {queryByTestId} = render(` +
+ Actually a valid link + Not a valid link (missing href) +
+ `) + + const validLink = queryByTestId('link-valid') + const invalidLink = queryByTestId('link-invalid') + + // valid link has role 'link' + expect(validLink).not.toHaveRole('listitem') + expect(validLink).toHaveRole('link') + + expect(() => { + expect(validLink).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(validLink).not.toHaveRole('link') + }).toThrow(/expected element not to have role/i) + + // invalid link has role 'generic' + expect(invalidLink).not.toHaveRole('listitem') + expect(invalidLink).not.toHaveRole('link') + expect(invalidLink).toHaveRole('generic') + + expect(() => { + expect(invalidLink).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(invalidLink).toHaveRole('link') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(invalidLink).not.toHaveRole('generic') + }).toThrow(/expected element not to have role/i) + }) +}) diff --git a/src/matchers.js b/src/matchers.js index f49b489..46803f3 100644 --- a/src/matchers.js +++ b/src/matchers.js @@ -7,6 +7,7 @@ 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 {toHaveRole} from './to-have-role' export {toHaveAccessibleName} from './to-have-accessible-name' export {toHaveAttribute} from './to-have-attribute' export {toHaveClass} from './to-have-class' diff --git a/src/to-have-role.js b/src/to-have-role.js new file mode 100644 index 0000000..04b85ed --- /dev/null +++ b/src/to-have-role.js @@ -0,0 +1,147 @@ +import {elementRoles} from 'aria-query' +import {checkHtmlElement, getMessage} from './utils' + +const elementRoleList = buildElementRoleList(elementRoles) + +export function toHaveRole(htmlElement, expectedRole) { + checkHtmlElement(htmlElement, toHaveRole, this) + + const actualRoles = getExplicitOrImplicitRoles(htmlElement) + const pass = actualRoles.some(el => el === expectedRole) + + return { + pass, + + message: () => { + const to = this.isNot ? 'not to' : 'to' + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.${toHaveRole.name}`, + 'element', + '', + ), + `Expected element ${to} have role`, + expectedRole, + 'Received', + actualRoles.join(', '), + ) + }, + } +} + +function getExplicitOrImplicitRoles(htmlElement) { + const hasExplicitRole = htmlElement.hasAttribute('role') + + if (hasExplicitRole) { + const roleValue = htmlElement.getAttribute('role') + + // Handle fallback roles, such as role="switch button" + // testing-library gates this behind the `queryFallbacks` flag; it is + // unclear why, but it makes sense to support this pattern out of the box + // https://testing-library.com/docs/queries/byrole/#queryfallbacks + return roleValue.split(' ').filter(Boolean) + } + + const implicitRoles = getImplicitAriaRoles(htmlElement) + + return implicitRoles +} + +function getImplicitAriaRoles(currentNode) { + for (const {match, roles} of elementRoleList) { + if (match(currentNode)) { + return [...roles] + } + } + + /* istanbul ignore next */ + return [] // this does not get reached in practice, since elements have at least a 'generic' role +} + +/** + * Transform the roles map (with required attributes and constraints) to a list + * of roles. Each item in the list has functions to match an element against it. + * + * Essentially copied over from [dom-testing-library's + * helpers](https://github.com/testing-library/dom-testing-library/blob/bd04cf95a1ed85a2238f7dfc1a77d5d16b4f59dc/src/role-helpers.js#L80) + * + * TODO: If we are truly just copying over stuff, would it make sense to move + * this to a separate package? + * + * TODO: This technique relies on CSS selectors; are those consistently + * available in all jest-dom environments? Why do other matchers in this package + * not use them like this? + */ +function buildElementRoleList(elementRolesMap) { + function makeElementSelector({name, attributes}) { + return `${name}${attributes + .map(({name: attributeName, value, constraints = []}) => { + const shouldNotExist = constraints.indexOf('undefined') !== -1 + if (shouldNotExist) { + return `:not([${attributeName}])` + } else if (value) { + return `[${attributeName}="${value}"]` + } else { + return `[${attributeName}]` + } + }) + .join('')}` + } + + function getSelectorSpecificity({attributes = []}) { + return attributes.length + } + + function bySelectorSpecificity( + {specificity: leftSpecificity}, + {specificity: rightSpecificity}, + ) { + return rightSpecificity - leftSpecificity + } + + function match(element) { + let {attributes = []} = element + + // https://github.com/testing-library/dom-testing-library/issues/814 + const typeTextIndex = attributes.findIndex( + attribute => + attribute.value && + attribute.name === 'type' && + attribute.value === 'text', + ) + + if (typeTextIndex >= 0) { + // not using splice to not mutate the attributes array + attributes = [ + ...attributes.slice(0, typeTextIndex), + ...attributes.slice(typeTextIndex + 1), + ] + } + + const selector = makeElementSelector({...element, attributes}) + + return node => { + if (typeTextIndex >= 0 && node.type !== 'text') { + return false + } + + return node.matches(selector) + } + } + + let result = [] + + for (const [element, roles] of elementRolesMap.entries()) { + result = [ + ...result, + { + match: match(element), + roles: Array.from(roles), + specificity: getSelectorSpecificity(element), + }, + ] + } + + return result.sort(bySelectorSpecificity) +} diff --git a/types/__tests__/bun/bun-custom-expect-types.test.ts b/types/__tests__/bun/bun-custom-expect-types.test.ts index 051d785..1467e91 100644 --- a/types/__tests__/bun/bun-custom-expect-types.test.ts +++ b/types/__tests__/bun/bun-custom-expect-types.test.ts @@ -94,5 +94,7 @@ customExpect(element).toHaveErrorMessage( expect.stringContaining('Invalid time'), ) +customExpect(element).toHaveRole('button') + // @ts-expect-error The types accidentally allowed any property by falling back to "any" customExpect(element).nonExistentProperty() diff --git a/types/__tests__/bun/bun-types.test.ts b/types/__tests__/bun/bun-types.test.ts index ae03a17..432dd86 100644 --- a/types/__tests__/bun/bun-types.test.ts +++ b/types/__tests__/bun/bun-types.test.ts @@ -66,6 +66,7 @@ expect(element).toHaveErrorMessage( ) expect(element).toHaveErrorMessage(/invalid time/i) expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time')) +expect(element).toHaveRole('button') expect(element).not.toBeInTheDOM() expect(element).not.toBeInTheDOM(document.body) @@ -113,6 +114,7 @@ expect(element).not.toHaveAccessibleName() expect(element).not.toBePartiallyChecked() expect(element).not.toHaveErrorMessage() expect(element).not.toHaveErrorMessage('Pikachu!') +expect(element).not.toHaveRole('button') // @ts-expect-error The types accidentally allowed any property by falling back to "any" expect(element).nonExistentProperty() diff --git a/types/__tests__/jest-globals/jest-globals-types.test.ts b/types/__tests__/jest-globals/jest-globals-types.test.ts index 645f44e..150f825 100644 --- a/types/__tests__/jest-globals/jest-globals-types.test.ts +++ b/types/__tests__/jest-globals/jest-globals-types.test.ts @@ -66,6 +66,7 @@ expect(element).toHaveErrorMessage( ) expect(element).toHaveErrorMessage(/invalid time/i) expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time')) +expect(element).toHaveRole('button') expect(element).not.toBeInTheDOM() expect(element).not.toBeInTheDOM(document.body) @@ -113,6 +114,7 @@ expect(element).not.toHaveAccessibleName() expect(element).not.toBePartiallyChecked() expect(element).not.toHaveErrorMessage() expect(element).not.toHaveErrorMessage('Pikachu!') +expect(element).not.toHaveRole('button') // @ts-expect-error The types accidentally allowed any property by falling back to "any" expect(element).nonExistentProperty() diff --git a/types/__tests__/jest/jest-types.test.ts b/types/__tests__/jest/jest-types.test.ts index 404b988..d9596c6 100644 --- a/types/__tests__/jest/jest-types.test.ts +++ b/types/__tests__/jest/jest-types.test.ts @@ -65,6 +65,7 @@ expect(element).toHaveErrorMessage( ) expect(element).toHaveErrorMessage(/invalid time/i) expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time')) +expect(element).toHaveRole('button') expect(element).not.toBeInTheDOM() expect(element).not.toBeInTheDOM(document.body) @@ -112,6 +113,7 @@ expect(element).not.toHaveAccessibleName() expect(element).not.toBePartiallyChecked() expect(element).not.toHaveErrorMessage() expect(element).not.toHaveErrorMessage('Pikachu!') +expect(element).not.toHaveRole('button') // @ts-expect-error The types accidentally allowed any property by falling back to "any" expect(element).nonExistentProperty() diff --git a/types/__tests__/vitest/vitest-types.test.ts b/types/__tests__/vitest/vitest-types.test.ts index 69f1dbc..7a90be0 100644 --- a/types/__tests__/vitest/vitest-types.test.ts +++ b/types/__tests__/vitest/vitest-types.test.ts @@ -66,6 +66,7 @@ expect(element).toHaveErrorMessage( ) expect(element).toHaveErrorMessage(/invalid time/i) expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time')) +expect(element).toHaveRole('button') expect(element).not.toBeInTheDOM() expect(element).not.toBeInTheDOM(document.body) @@ -113,6 +114,7 @@ expect(element).not.toHaveAccessibleName() expect(element).not.toBePartiallyChecked() expect(element).not.toHaveErrorMessage() expect(element).not.toHaveErrorMessage('Pikachu!') +expect(element).not.toHaveRole('button') // @ts-expect-error The types accidentally allowed any property by falling back to "any" expect(element).nonExistentProperty() diff --git a/types/matchers.d.ts b/types/matchers.d.ts index 213f94c..cdd66c1 100755 --- a/types/matchers.d.ts +++ b/types/matchers.d.ts @@ -1,3 +1,9 @@ +import {ARIARole} from 'aria-query' + +// Get autocomplete for ARIARole union types, while still supporting another string +// Ref: https://github.com/microsoft/TypeScript/issues/29729#issuecomment-567871939 +export type ByRoleMatcher = ARIARole | (string & {}) + declare namespace matchers { interface TestingLibraryMatchers { /** @@ -579,6 +585,43 @@ declare namespace matchers { * [testing-library/jest-dom#tohaveaccessiblename](https://github.com/testing-library/jest-dom#tohaveaccessiblename) */ toHaveAccessibleName(text?: string | RegExp | E): R + /** + * @description + * This allows you to assert that an element has the expected + * [role](https://www.w3.org/TR/html-aria/#docconformance). + * + * This is useful in cases where you already have access to an element via + * some query other than the role itself, and want to make additional + * assertions regarding its accessibility. + * + * The role can match either an explicit role (via the `role` attribute), or + * an implicit one via the [implicit ARIA + * semantics](https://www.w3.org/TR/html-aria/). + * + * Note: roles are matched literally by string equality, without inheriting + * from the ARIA role hierarchy. As a result, querying a superclass role + * like 'checkbox' will not include elements with a subclass role like + * 'switch'. + * + * @example + * + *
Continue + * + * About + * Invalid link + * + * expect(getByTestId('button')).toHaveRole('button') + * expect(getByTestId('button-explicit')).toHaveRole('button') + * expect(getByTestId('button-explicit-multiple')).toHaveRole('button') + * expect(getByTestId('button-explicit-multiple')).toHaveRole('switch') + * expect(getByTestId('link')).toHaveRole('link') + * expect(getByTestId('link-invalid')).not.toHaveRole('link') + * expect(getByTestId('link-invalid')).toHaveRole('generic') + * + * @see + * [testing-library/jest-dom#tohaverole](https://github.com/testing-library/jest-dom#tohaverole) + */ + toHaveRole(role: ByRoleMatcher): R /** * @description * This allows you to check whether the given element is partially checked.