Skip to content

Commit f7dc673

Browse files
authored
feat: Add toHaveRole matcher (#572)
1 parent 9787ed5 commit f7dc673

10 files changed

+350
-0
lines changed

README.md

+42
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ clear to read and to maintain.
7979
- [`toHaveDisplayValue`](#tohavedisplayvalue)
8080
- [`toBeChecked`](#tobechecked)
8181
- [`toBePartiallyChecked`](#tobepartiallychecked)
82+
- [`toHaveRole`](#tohaverole)
8283
- [`toHaveErrorMessage`](#tohaveerrormessage)
8384
- [Deprecated matchers](#deprecated-matchers)
8485
- [`toBeEmpty`](#tobeempty)
@@ -1189,6 +1190,47 @@ expect(inputCheckboxIndeterminate).toBePartiallyChecked()
11891190

11901191
<hr />
11911192

1193+
### `toHaveRole`
1194+
1195+
This allows you to assert that an element has the expected
1196+
[role](https://www.w3.org/TR/html-aria/#docconformance).
1197+
1198+
This is useful in cases where you already have access to an element via some
1199+
query other than the role itself, and want to make additional assertions
1200+
regarding its accessibility.
1201+
1202+
The role can match either an explicit role (via the `role` attribute), or an
1203+
implicit one via the
1204+
[implicit ARIA semantics](https://www.w3.org/TR/html-aria/).
1205+
1206+
Note: roles are matched literally by string equality, without inheriting from
1207+
the ARIA role hierarchy. As a result, querying a superclass role like 'checkbox'
1208+
will not include elements with a subclass role like 'switch'.
1209+
1210+
```typescript
1211+
toHaveRole(expectedRole: string)
1212+
```
1213+
1214+
```html
1215+
<button data-testid="button">Continue</button>
1216+
<div role="button" data-testid="button-explicit">Continue</button>
1217+
<button role="switch button" data-testid="button-explicit-multiple">Continue</button>
1218+
<a href="/about" data-testid="link">About</a>
1219+
<a data-testid="link-invalid">Invalid link<a/>
1220+
```
1221+
1222+
```javascript
1223+
expect(getByTestId('button')).toHaveRole('button')
1224+
expect(getByTestId('button-explicit')).toHaveRole('button')
1225+
expect(getByTestId('button-explicit-multiple')).toHaveRole('button')
1226+
expect(getByTestId('button-explicit-multiple')).toHaveRole('switch')
1227+
expect(getByTestId('link')).toHaveRole('link')
1228+
expect(getByTestId('link-invalid')).not.toHaveRole('link')
1229+
expect(getByTestId('link-invalid')).toHaveRole('generic')
1230+
```
1231+
1232+
<hr />
1233+
11921234
### `toHaveErrorMessage`
11931235

11941236
> This custom matcher is deprecated. Prefer

src/__tests__/to-have-role.js

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {render} from './helpers/test-utils'
2+
3+
describe('.toHaveRole', () => {
4+
it('matches implicit role', () => {
5+
const {queryByTestId} = render(`
6+
<div>
7+
<button data-testid="continue-button">Continue</button>
8+
</div>
9+
`)
10+
11+
const continueButton = queryByTestId('continue-button')
12+
13+
expect(continueButton).not.toHaveRole('listitem')
14+
expect(continueButton).toHaveRole('button')
15+
16+
expect(() => {
17+
expect(continueButton).toHaveRole('listitem')
18+
}).toThrow(/expected element to have role/i)
19+
expect(() => {
20+
expect(continueButton).not.toHaveRole('button')
21+
}).toThrow(/expected element not to have role/i)
22+
})
23+
24+
it('matches explicit role', () => {
25+
const {queryByTestId} = render(`
26+
<div>
27+
<div role="button" data-testid="continue-button">Continue</div>
28+
</div>
29+
`)
30+
31+
const continueButton = queryByTestId('continue-button')
32+
33+
expect(continueButton).not.toHaveRole('listitem')
34+
expect(continueButton).toHaveRole('button')
35+
36+
expect(() => {
37+
expect(continueButton).toHaveRole('listitem')
38+
}).toThrow(/expected element to have role/i)
39+
expect(() => {
40+
expect(continueButton).not.toHaveRole('button')
41+
}).toThrow(/expected element not to have role/i)
42+
})
43+
44+
it('matches multiple explicit roles', () => {
45+
const {queryByTestId} = render(`
46+
<div>
47+
<div role="button switch" data-testid="continue-button">Continue</div>
48+
</div>
49+
`)
50+
51+
const continueButton = queryByTestId('continue-button')
52+
53+
expect(continueButton).not.toHaveRole('listitem')
54+
expect(continueButton).toHaveRole('button')
55+
expect(continueButton).toHaveRole('switch')
56+
57+
expect(() => {
58+
expect(continueButton).toHaveRole('listitem')
59+
}).toThrow(/expected element to have role/i)
60+
expect(() => {
61+
expect(continueButton).not.toHaveRole('button')
62+
}).toThrow(/expected element not to have role/i)
63+
expect(() => {
64+
expect(continueButton).not.toHaveRole('switch')
65+
}).toThrow(/expected element not to have role/i)
66+
})
67+
68+
// At this point, we might be testing the details of getImplicitAriaRoles, but
69+
// it's good to have a gut check
70+
it('handles implicit roles with multiple conditions', () => {
71+
const {queryByTestId} = render(`
72+
<div>
73+
<a href="/about" data-testid="link-valid">Actually a valid link</a>
74+
<a data-testid="link-invalid">Not a valid link (missing href)</a>
75+
</div>
76+
`)
77+
78+
const validLink = queryByTestId('link-valid')
79+
const invalidLink = queryByTestId('link-invalid')
80+
81+
// valid link has role 'link'
82+
expect(validLink).not.toHaveRole('listitem')
83+
expect(validLink).toHaveRole('link')
84+
85+
expect(() => {
86+
expect(validLink).toHaveRole('listitem')
87+
}).toThrow(/expected element to have role/i)
88+
expect(() => {
89+
expect(validLink).not.toHaveRole('link')
90+
}).toThrow(/expected element not to have role/i)
91+
92+
// invalid link has role 'generic'
93+
expect(invalidLink).not.toHaveRole('listitem')
94+
expect(invalidLink).not.toHaveRole('link')
95+
expect(invalidLink).toHaveRole('generic')
96+
97+
expect(() => {
98+
expect(invalidLink).toHaveRole('listitem')
99+
}).toThrow(/expected element to have role/i)
100+
expect(() => {
101+
expect(invalidLink).toHaveRole('link')
102+
}).toThrow(/expected element to have role/i)
103+
expect(() => {
104+
expect(invalidLink).not.toHaveRole('generic')
105+
}).toThrow(/expected element not to have role/i)
106+
})
107+
})

src/matchers.js

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {toContainHTML} from './to-contain-html'
77
export {toHaveTextContent} from './to-have-text-content'
88
export {toHaveAccessibleDescription} from './to-have-accessible-description'
99
export {toHaveAccessibleErrorMessage} from './to-have-accessible-errormessage'
10+
export {toHaveRole} from './to-have-role'
1011
export {toHaveAccessibleName} from './to-have-accessible-name'
1112
export {toHaveAttribute} from './to-have-attribute'
1213
export {toHaveClass} from './to-have-class'

src/to-have-role.js

+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import {elementRoles} from 'aria-query'
2+
import {checkHtmlElement, getMessage} from './utils'
3+
4+
const elementRoleList = buildElementRoleList(elementRoles)
5+
6+
export function toHaveRole(htmlElement, expectedRole) {
7+
checkHtmlElement(htmlElement, toHaveRole, this)
8+
9+
const actualRoles = getExplicitOrImplicitRoles(htmlElement)
10+
const pass = actualRoles.some(el => el === expectedRole)
11+
12+
return {
13+
pass,
14+
15+
message: () => {
16+
const to = this.isNot ? 'not to' : 'to'
17+
return getMessage(
18+
this,
19+
this.utils.matcherHint(
20+
`${this.isNot ? '.not' : ''}.${toHaveRole.name}`,
21+
'element',
22+
'',
23+
),
24+
`Expected element ${to} have role`,
25+
expectedRole,
26+
'Received',
27+
actualRoles.join(', '),
28+
)
29+
},
30+
}
31+
}
32+
33+
function getExplicitOrImplicitRoles(htmlElement) {
34+
const hasExplicitRole = htmlElement.hasAttribute('role')
35+
36+
if (hasExplicitRole) {
37+
const roleValue = htmlElement.getAttribute('role')
38+
39+
// Handle fallback roles, such as role="switch button"
40+
// testing-library gates this behind the `queryFallbacks` flag; it is
41+
// unclear why, but it makes sense to support this pattern out of the box
42+
// https://testing-library.com/docs/queries/byrole/#queryfallbacks
43+
return roleValue.split(' ').filter(Boolean)
44+
}
45+
46+
const implicitRoles = getImplicitAriaRoles(htmlElement)
47+
48+
return implicitRoles
49+
}
50+
51+
function getImplicitAriaRoles(currentNode) {
52+
for (const {match, roles} of elementRoleList) {
53+
if (match(currentNode)) {
54+
return [...roles]
55+
}
56+
}
57+
58+
/* istanbul ignore next */
59+
return [] // this does not get reached in practice, since elements have at least a 'generic' role
60+
}
61+
62+
/**
63+
* Transform the roles map (with required attributes and constraints) to a list
64+
* of roles. Each item in the list has functions to match an element against it.
65+
*
66+
* Essentially copied over from [dom-testing-library's
67+
* helpers](https://github.com/testing-library/dom-testing-library/blob/bd04cf95a1ed85a2238f7dfc1a77d5d16b4f59dc/src/role-helpers.js#L80)
68+
*
69+
* TODO: If we are truly just copying over stuff, would it make sense to move
70+
* this to a separate package?
71+
*
72+
* TODO: This technique relies on CSS selectors; are those consistently
73+
* available in all jest-dom environments? Why do other matchers in this package
74+
* not use them like this?
75+
*/
76+
function buildElementRoleList(elementRolesMap) {
77+
function makeElementSelector({name, attributes}) {
78+
return `${name}${attributes
79+
.map(({name: attributeName, value, constraints = []}) => {
80+
const shouldNotExist = constraints.indexOf('undefined') !== -1
81+
if (shouldNotExist) {
82+
return `:not([${attributeName}])`
83+
} else if (value) {
84+
return `[${attributeName}="${value}"]`
85+
} else {
86+
return `[${attributeName}]`
87+
}
88+
})
89+
.join('')}`
90+
}
91+
92+
function getSelectorSpecificity({attributes = []}) {
93+
return attributes.length
94+
}
95+
96+
function bySelectorSpecificity(
97+
{specificity: leftSpecificity},
98+
{specificity: rightSpecificity},
99+
) {
100+
return rightSpecificity - leftSpecificity
101+
}
102+
103+
function match(element) {
104+
let {attributes = []} = element
105+
106+
// https://github.com/testing-library/dom-testing-library/issues/814
107+
const typeTextIndex = attributes.findIndex(
108+
attribute =>
109+
attribute.value &&
110+
attribute.name === 'type' &&
111+
attribute.value === 'text',
112+
)
113+
114+
if (typeTextIndex >= 0) {
115+
// not using splice to not mutate the attributes array
116+
attributes = [
117+
...attributes.slice(0, typeTextIndex),
118+
...attributes.slice(typeTextIndex + 1),
119+
]
120+
}
121+
122+
const selector = makeElementSelector({...element, attributes})
123+
124+
return node => {
125+
if (typeTextIndex >= 0 && node.type !== 'text') {
126+
return false
127+
}
128+
129+
return node.matches(selector)
130+
}
131+
}
132+
133+
let result = []
134+
135+
for (const [element, roles] of elementRolesMap.entries()) {
136+
result = [
137+
...result,
138+
{
139+
match: match(element),
140+
roles: Array.from(roles),
141+
specificity: getSelectorSpecificity(element),
142+
},
143+
]
144+
}
145+
146+
return result.sort(bySelectorSpecificity)
147+
}

types/__tests__/bun/bun-custom-expect-types.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -94,5 +94,7 @@ customExpect(element).toHaveErrorMessage(
9494
expect.stringContaining('Invalid time'),
9595
)
9696

97+
customExpect(element).toHaveRole('button')
98+
9799
// @ts-expect-error The types accidentally allowed any property by falling back to "any"
98100
customExpect(element).nonExistentProperty()

types/__tests__/bun/bun-types.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ expect(element).toHaveErrorMessage(
6666
)
6767
expect(element).toHaveErrorMessage(/invalid time/i)
6868
expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time'))
69+
expect(element).toHaveRole('button')
6970

7071
expect(element).not.toBeInTheDOM()
7172
expect(element).not.toBeInTheDOM(document.body)
@@ -113,6 +114,7 @@ expect(element).not.toHaveAccessibleName()
113114
expect(element).not.toBePartiallyChecked()
114115
expect(element).not.toHaveErrorMessage()
115116
expect(element).not.toHaveErrorMessage('Pikachu!')
117+
expect(element).not.toHaveRole('button')
116118

117119
// @ts-expect-error The types accidentally allowed any property by falling back to "any"
118120
expect(element).nonExistentProperty()

types/__tests__/jest-globals/jest-globals-types.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ expect(element).toHaveErrorMessage(
6666
)
6767
expect(element).toHaveErrorMessage(/invalid time/i)
6868
expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time'))
69+
expect(element).toHaveRole('button')
6970

7071
expect(element).not.toBeInTheDOM()
7172
expect(element).not.toBeInTheDOM(document.body)
@@ -113,6 +114,7 @@ expect(element).not.toHaveAccessibleName()
113114
expect(element).not.toBePartiallyChecked()
114115
expect(element).not.toHaveErrorMessage()
115116
expect(element).not.toHaveErrorMessage('Pikachu!')
117+
expect(element).not.toHaveRole('button')
116118

117119
// @ts-expect-error The types accidentally allowed any property by falling back to "any"
118120
expect(element).nonExistentProperty()

types/__tests__/jest/jest-types.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ expect(element).toHaveErrorMessage(
6565
)
6666
expect(element).toHaveErrorMessage(/invalid time/i)
6767
expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time'))
68+
expect(element).toHaveRole('button')
6869

6970
expect(element).not.toBeInTheDOM()
7071
expect(element).not.toBeInTheDOM(document.body)
@@ -112,6 +113,7 @@ expect(element).not.toHaveAccessibleName()
112113
expect(element).not.toBePartiallyChecked()
113114
expect(element).not.toHaveErrorMessage()
114115
expect(element).not.toHaveErrorMessage('Pikachu!')
116+
expect(element).not.toHaveRole('button')
115117

116118
// @ts-expect-error The types accidentally allowed any property by falling back to "any"
117119
expect(element).nonExistentProperty()

types/__tests__/vitest/vitest-types.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ expect(element).toHaveErrorMessage(
6666
)
6767
expect(element).toHaveErrorMessage(/invalid time/i)
6868
expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time'))
69+
expect(element).toHaveRole('button')
6970

7071
expect(element).not.toBeInTheDOM()
7172
expect(element).not.toBeInTheDOM(document.body)
@@ -113,6 +114,7 @@ expect(element).not.toHaveAccessibleName()
113114
expect(element).not.toBePartiallyChecked()
114115
expect(element).not.toHaveErrorMessage()
115116
expect(element).not.toHaveErrorMessage('Pikachu!')
117+
expect(element).not.toHaveRole('button')
116118

117119
// @ts-expect-error The types accidentally allowed any property by falling back to "any"
118120
expect(element).nonExistentProperty()

0 commit comments

Comments
 (0)