Skip to content

Commit

Permalink
feat: Add new custom matcher toHaveDescription (#244)
Browse files Browse the repository at this point in the history
* Add toHaveDescription() matcher

* Add toHaveDescription() docs
  • Loading branch information
RoyalIcing committed May 7, 2020
1 parent 2afc2c5 commit 943a0c9
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 2 deletions.
62 changes: 60 additions & 2 deletions README.md
Expand Up @@ -68,6 +68,7 @@ clear to read and to maintain.
- [`toHaveValue`](#tohavevalue)
- [`toHaveDisplayValue`](#tohavedisplayvalue)
- [`toBeChecked`](#tobechecked)
- [`toHaveDescription`](#tohavedescription)
- [Deprecated matchers](#deprecated-matchers)
- [`toBeInTheDOM`](#tobeinthedom)
- [Inspiration](#inspiration)
Expand All @@ -86,9 +87,11 @@ should be installed as one of your project's `devDependencies`:
```
npm install --save-dev @testing-library/jest-dom
```
or

or

for installation with [yarn](https://yarnpkg.com/) package manager.

```
yarn add --dev @testing-library/jest-dom
```
Expand Down Expand Up @@ -725,7 +728,7 @@ const element = getByTestId('text-content')

expect(element).toHaveTextContent('Content')
expect(element).toHaveTextContent(/^Text Content$/) // to match the whole content
expect(element).toHaveTextContent(/content$/i) // to use case-insentive match
expect(element).toHaveTextContent(/content$/i) // to use case-insensitive match
expect(element).not.toHaveTextContent('content')
```

Expand Down Expand Up @@ -886,6 +889,60 @@ expect(ariaSwitchChecked).toBeChecked()
expect(ariaSwitchUnchecked).not.toBeChecked()
```

<hr />

### `toHaveDescription`

```typescript
toHaveDescription(text: string | RegExp)
```

This allows you to check whether the given element has a description or not.

An element gets its description via the
[`aria-describedby` attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-describedby_attribute).
Set this to the `id` of one or more other elements. These elements may be nested
inside, be outside, or a sibling of the passed in element.

Whitespace is normalized. Using multiple ids will
[join the referenced elements’ text content separated by a space](https://www.w3.org/TR/accname-1.1/#mapping_additional_nd_description).

When a `string` argument is passed through, it will perform a whole
case-sensitive match to the description 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
<button aria-label="Close" aria-describedby="description-close">
X
</button>
<div id="description-close">
Closing will discard any changes
</div>

<button>Delete</button>
```

```javascript
const closeButton = getByRole('button', {name: 'Close'})

expect(closeButton).toHaveDescription('Closing will discard any changes')
expect(closeButton).toHaveDescription(/will discard/) // to partially match
expect(closeButton).toHaveDescription(expect.stringContaining('will discard')) // to partially match
expect(closeButton).toHaveDescription(/^closing/i) // to use case-insensitive match
expect(closeButton).not.toHaveDescription('Other description')

const deleteButton = getByRole('button', {name: 'Delete'})
expect(deleteButton).not.toHaveDescription()
expect(deleteButton).toHaveDescription('') // Missing or empty description always becomes a blank string
```

## Deprecated matchers

### `toBeInTheDOM`
Expand Down Expand Up @@ -1026,6 +1083,7 @@ Thanks goes to these people ([emoji key][emojis]):

<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->

<!-- ALL-CONTRIBUTORS-LIST:END -->

This project follows the [all-contributors][all-contributors] specification.
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/helpers/test-utils.js
Expand Up @@ -5,6 +5,12 @@ function render(html) {
container.innerHTML = html
const queryByTestId = testId =>
container.querySelector(`[data-testid="${testId}"]`)

// Some tests need to look up global ids with document.getElementById()
// so we need to be inside an actual document.
document.body.innerHTML = ''
document.body.appendChild(container)

return {container, queryByTestId}
}

Expand Down
138 changes: 138 additions & 0 deletions src/__tests__/to-have-description.js
@@ -0,0 +1,138 @@
import {render} from './helpers/test-utils'

describe('.toHaveDescription', () => {
test('handles positive test cases', () => {
const {queryByTestId} = render(`
<div id="description">The description</div>
<div data-testid="single" aria-describedby="description"></div>
<div data-testid="invalid_id" aria-describedby="invalid"></div>
<div data-testid="without"></div>
`)

expect(queryByTestId('single')).toHaveDescription('The description')
expect(queryByTestId('single')).toHaveDescription(
expect.stringContaining('The'),
)
expect(queryByTestId('single')).toHaveDescription(/The/)
expect(queryByTestId('single')).toHaveDescription(
expect.stringMatching(/The/),
)
expect(queryByTestId('single')).toHaveDescription(/description/)
expect(queryByTestId('single')).not.toHaveDescription('Something else')
expect(queryByTestId('single')).not.toHaveDescription('The')

expect(queryByTestId('invalid_id')).not.toHaveDescription()
expect(queryByTestId('invalid_id')).toHaveDescription('')

expect(queryByTestId('without')).not.toHaveDescription()
expect(queryByTestId('without')).toHaveDescription('')
})

test('handles multiple ids', () => {
const {queryByTestId} = render(`
<div id="first">First description</div>
<div id="second">Second description</div>
<div id="third">Third description</div>
<div data-testid="multiple" aria-describedby="first second third"></div>
`)

expect(queryByTestId('multiple')).toHaveDescription(
'First description Second description Third description',
)
expect(queryByTestId('multiple')).toHaveDescription(
/Second description Third/,
)
expect(queryByTestId('multiple')).toHaveDescription(
expect.stringContaining('Second description Third'),
)
expect(queryByTestId('multiple')).toHaveDescription(
expect.stringMatching(/Second description Third/),
)
expect(queryByTestId('multiple')).not.toHaveDescription('Something else')
expect(queryByTestId('multiple')).not.toHaveDescription('First')
})

test('handles negative test cases', () => {
const {queryByTestId} = render(`
<div id="description">The description</div>
<div data-testid="target" aria-describedby="description"></div>
`)

expect(() =>
expect(queryByTestId('other')).toHaveDescription('The description'),
).toThrowError()

expect(() =>
expect(queryByTestId('target')).toHaveDescription('Something else'),
).toThrowError()

expect(() =>
expect(queryByTestId('target')).not.toHaveDescription('The description'),
).toThrowError()
})

test('normalizes whitespace', () => {
const {queryByTestId} = render(`
<div id="first">
Step
1
of
4
</div>
<div id="second">
And
extra
description
</div>
<div data-testid="target" aria-describedby="first second"></div>
`)

expect(queryByTestId('target')).toHaveDescription(
'Step 1 of 4 And extra description',
)
})

test('can handle multiple levels with content spread across decendants', () => {
const {queryByTestId} = render(`
<span id="description">
<span>Step</span>
<span> 1</span>
<span><span>of</span></span>
4</span>
</span>
<div data-testid="target" aria-describedby="description"></div>
`)

expect(queryByTestId('target')).toHaveDescription('Step 1 of 4')
})

test('handles extra whitespace with multiple ids', () => {
const {queryByTestId} = render(`
<div id="first">First description</div>
<div id="second">Second description</div>
<div id="third">Third description</div>
<div data-testid="multiple" aria-describedby=" first
second third
"></div>
`)

expect(queryByTestId('multiple')).toHaveDescription(
'First description Second description Third description',
)
})

test('is case-sensitive', () => {
const {queryByTestId} = render(`
<span id="description">Sensitive text</span>
<div data-testid="target" aria-describedby="description"></div>
`)

expect(queryByTestId('target')).toHaveDescription('Sensitive text')
expect(queryByTestId('target')).not.toHaveDescription('sensitive text')
})
})
2 changes: 2 additions & 0 deletions src/matchers.js
Expand Up @@ -16,6 +16,7 @@ import {toBeInvalid, toBeValid} from './to-be-invalid'
import {toHaveValue} from './to-have-value'
import {toHaveDisplayValue} from './to-have-display-value'
import {toBeChecked} from './to-be-checked'
import {toHaveDescription} from './to-have-description'

export {
toBeInTheDOM,
Expand All @@ -38,4 +39,5 @@ export {
toHaveValue,
toHaveDisplayValue,
toBeChecked,
toHaveDescription,
}
42 changes: 42 additions & 0 deletions src/to-have-description.js
@@ -0,0 +1,42 @@
import {matcherHint, printExpected, printReceived} from 'jest-matcher-utils'
import {checkHtmlElement, getMessage, normalize} from './utils'

// See algoritm: https://www.w3.org/TR/accname-1.1/#mapping_additional_nd_description
export function toHaveDescription(htmlElement, checkWith) {
checkHtmlElement(htmlElement, toHaveDescription, this)

const expectsDescription = checkWith !== undefined

const descriptionIDRaw = htmlElement.getAttribute('aria-describedby') || ''
const descriptionIDs = descriptionIDRaw.split(/\s+/).filter(Boolean)
let description = ''
if (descriptionIDs.length > 0) {
const document = htmlElement.ownerDocument
const descriptionEls = descriptionIDs
.map(descriptionID => document.getElementById(descriptionID))
.filter(Boolean)
description = normalize(descriptionEls.map(el => el.textContent).join(' '))
}

return {
pass: expectsDescription
? checkWith instanceof RegExp
? checkWith.test(description)
: this.equals(description, checkWith)
: Boolean(description),
message: () => {
const to = this.isNot ? 'not to' : 'to'
return getMessage(
matcherHint(
`${this.isNot ? '.not' : ''}.toHaveDescription`,
'element',
'',
),
`Expected the element ${to} have description`,
printExpected(checkWith),
'Received',
printReceived(description),
)
},
}
}

0 comments on commit 943a0c9

Please sign in to comment.