Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add toHaveDescription() #244

Merged
merged 2 commits into from May 7, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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),
)
},
}
}