+### `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
+
+
+ `)
+
+ 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(`
+
+ `)
+
+ 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.