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 toHaveAttribute custom matcher (closes #43) #44

Merged
merged 5 commits into from
Apr 5, 2018
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .all-contributorsrc
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@
"contributions": [
"code"
]
},
{
"login": "gnapse",
"name": "Ernesto García",
"avatar_url": "https://avatars0.githubusercontent.com/u/15199?v=4",
"profile": "http://gnapse.github.io",
"contributions": [
"question",
"code",
"doc"
]
}
]
}
56 changes: 41 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
[![downloads][downloads-badge]][npmtrends]
[![MIT License][license-badge]][license]

[![All Contributors](https://img.shields.io/badge/all_contributors-12-orange.svg?style=flat-square)](#contributors)
[![All Contributors](https://img.shields.io/badge/all_contributors-13-orange.svg?style=flat-square)](#contributors)
[![PRs Welcome][prs-badge]][prs]
[![Code of Conduct][coc-badge]][coc]

Expand Down Expand Up @@ -82,7 +82,8 @@ facilitate testing implementation details). Read more about this in
* [Custom Jest Matchers](#custom-jest-matchers)
* [`toBeInTheDOM`](#tobeinthedom)
* [`toHaveTextContent`](#tohavetextcontent)
* [Custom Jest Matchers - Typescript](#custom-jest-matchers-typescript)
* [`toHaveAttribute`](#tohaveattribute)
* [Custom Jest Matchers - Typescript](#custom-jest-matchers---typescript)
* [`TextMatch`](#textmatch)
* [`query` APIs](#query-apis)
* [Examples](#examples)
Expand Down Expand Up @@ -138,6 +139,7 @@ test('Fetch makes an API call and displays the greeting when load-greeting is cl
expect(axiosMock.get).toHaveBeenCalledTimes(1)
expect(axiosMock.get).toHaveBeenCalledWith(url)
expect(getByTestId('greeting-text')).toHaveTextContent('hello there')
expect(getByTestId('ok-button')).toHaveAttribute('disabled')
// snapshots work great with regular DOM nodes!
expect(container.firstChild).toMatchSnapshot()
})
Expand Down Expand Up @@ -347,33 +349,55 @@ expect(getByTestId('count-value')).toHaveTextContent('2')
expect(getByTestId('count-value')).not.toHaveTextContent('21')
// ...
```

### `toHaveAttribute`

This allows you to check wether the given element has an attribute or not. You
can also optionally check that the attribute has a specific expected value.

```javascript
// add the custom expect matchers
import 'react-testing-library/extend-expect'

// ...
const {getByTestId} = render(
<button data-testid="ok-button" type="submit" disabled>
OK
</button>,
)
expect(getByTestId('ok-button')).toHaveAttribute('disabled')
expect(getByTestId('ok-button')).toHaveAttribute('type', 'submit')
expect(getByTestId('ok-button')).not.toHaveAttribute('type', 'button')
// ...
```

### Custom Jest Matchers - Typescript

When you use custom Jest Matchers with Typescript, you will need to extend the type signature of `jest.Matchers<void>`, then cast the result of `expect` accordingly. Here's a handy usage example:
When you use custom Jest Matchers with Typescript, you will need to extend the type signature of `jest.Matchers<void>`, then cast the result of `expect` accordingly. Here's a handy usage example:

```typescript
// this adds custom expect matchers
import 'react-testing-library/extend-expect';
import 'react-testing-library/extend-expect'
interface ExtendedMatchers extends jest.Matchers<void> {
toHaveTextContent: (htmlElement: string) => object;
toBeInTheDOM: () => void;
toHaveTextContent: (htmlElement: string) => object
toBeInTheDOM: () => void
}
test('renders the tooltip as expected', async () => {
const {
// getByLabelText,
getByText,
// getByTestId,
container
} = render(<Tooltip label="hello world">Child</Tooltip>);
container,
} = render(<Tooltip label="hello world">Child</Tooltip>)
// tests rendering of the child
getByText('Child');
getByText('Child')
// tests rendering of tooltip label
(expect(getByText('hello world')) as ExtendedMatchers).toHaveTextContent(
'hello world'
);
;(expect(getByText('hello world')) as ExtendedMatchers).toHaveTextContent(
'hello world',
)
// snapshots work great with regular DOM nodes!
expect(container.firstChild).toMatchSnapshot();
});
expect(container.firstChild).toMatchSnapshot()
})
```

## `TextMatch`
Expand Down Expand Up @@ -715,10 +739,12 @@ light-weight, simple, and understandable.
Thanks goes to these people ([emoji key][emojis]):

<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->

<!-- prettier-ignore -->
| [<img src="https://avatars.githubusercontent.com/u/1500684?v=3" width="100px;"/><br /><sub><b>Kent C. Dodds</b></sub>](https://kentcdodds.com)<br />[💻](https://github.com/kentcdodds/react-testing-library/commits?author=kentcdodds "Code") [📖](https://github.com/kentcdodds/react-testing-library/commits?author=kentcdodds "Documentation") [🚇](#infra-kentcdodds "Infrastructure (Hosting, Build-Tools, etc)") [⚠️](https://github.com/kentcdodds/react-testing-library/commits?author=kentcdodds "Tests") | [<img src="https://avatars1.githubusercontent.com/u/2430381?v=4" width="100px;"/><br /><sub><b>Ryan Castner</b></sub>](http://audiolion.github.io)<br />[📖](https://github.com/kentcdodds/react-testing-library/commits?author=audiolion "Documentation") | [<img src="https://avatars0.githubusercontent.com/u/8008023?v=4" width="100px;"/><br /><sub><b>Daniel Sandiego</b></sub>](https://www.dnlsandiego.com)<br />[💻](https://github.com/kentcdodds/react-testing-library/commits?author=dnlsandiego "Code") | [<img src="https://avatars2.githubusercontent.com/u/12592677?v=4" width="100px;"/><br /><sub><b>Paweł Mikołajczyk</b></sub>](https://github.com/Miklet)<br />[💻](https://github.com/kentcdodds/react-testing-library/commits?author=Miklet "Code") | [<img src="https://avatars3.githubusercontent.com/u/464978?v=4" width="100px;"/><br /><sub><b>Alejandro Ñáñez Ortiz</b></sub>](http://co.linkedin.com/in/alejandronanez/)<br />[📖](https://github.com/kentcdodds/react-testing-library/commits?author=alejandronanez "Documentation") | [<img src="https://avatars0.githubusercontent.com/u/1402095?v=4" width="100px;"/><br /><sub><b>Matt Parrish</b></sub>](https://github.com/pbomb)<br />[🐛](https://github.com/kentcdodds/react-testing-library/issues?q=author%3Apbomb "Bug reports") [💻](https://github.com/kentcdodds/react-testing-library/commits?author=pbomb "Code") [📖](https://github.com/kentcdodds/react-testing-library/commits?author=pbomb "Documentation") [⚠️](https://github.com/kentcdodds/react-testing-library/commits?author=pbomb "Tests") | [<img src="https://avatars1.githubusercontent.com/u/1288694?v=4" width="100px;"/><br /><sub><b>Justin Hall</b></sub>](https://github.com/wKovacs64)<br />[📦](#platform-wKovacs64 "Packaging/porting to new platform") |
| :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| [<img src="https://avatars1.githubusercontent.com/u/1241511?s=460&v=4" width="100px;"/><br /><sub><b>Anto Aravinth</b></sub>](https://github.com/antoaravinth)<br />[💻](https://github.com/kentcdodds/react-testing-library/commits?author=antoaravinth "Code") [⚠️](https://github.com/kentcdodds/react-testing-library/commits?author=antoaravinth "Tests") [📖](https://github.com/kentcdodds/react-testing-library/commits?author=antoaravinth "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/3462296?v=4" width="100px;"/><br /><sub><b>Jonah Moses</b></sub>](https://github.com/JonahMoses)<br />[📖](https://github.com/kentcdodds/react-testing-library/commits?author=JonahMoses "Documentation") | [<img src="https://avatars1.githubusercontent.com/u/4002543?v=4" width="100px;"/><br /><sub><b>Łukasz Gandecki</b></sub>](http://team.thebrain.pro)<br />[💻](https://github.com/kentcdodds/react-testing-library/commits?author=lgandecki "Code") [⚠️](https://github.com/kentcdodds/react-testing-library/commits?author=lgandecki "Tests") [📖](https://github.com/kentcdodds/react-testing-library/commits?author=lgandecki "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/498274?v=4" width="100px;"/><br /><sub><b>Ivan Babak</b></sub>](https://sompylasar.github.io)<br />[🐛](https://github.com/kentcdodds/react-testing-library/issues?q=author%3Asompylasar "Bug reports") [🤔](#ideas-sompylasar "Ideas, Planning, & Feedback") | [<img src="https://avatars3.githubusercontent.com/u/4439618?v=4" width="100px;"/><br /><sub><b>Jesse Day</b></sub>](https://github.com/jday3)<br />[💻](https://github.com/kentcdodds/react-testing-library/commits?author=jday3 "Code") |
| [<img src="https://avatars1.githubusercontent.com/u/1241511?s=460&v=4" width="100px;"/><br /><sub><b>Anto Aravinth</b></sub>](https://github.com/antoaravinth)<br />[💻](https://github.com/kentcdodds/react-testing-library/commits?author=antoaravinth "Code") [⚠️](https://github.com/kentcdodds/react-testing-library/commits?author=antoaravinth "Tests") [📖](https://github.com/kentcdodds/react-testing-library/commits?author=antoaravinth "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/3462296?v=4" width="100px;"/><br /><sub><b>Jonah Moses</b></sub>](https://github.com/JonahMoses)<br />[📖](https://github.com/kentcdodds/react-testing-library/commits?author=JonahMoses "Documentation") | [<img src="https://avatars1.githubusercontent.com/u/4002543?v=4" width="100px;"/><br /><sub><b>Łukasz Gandecki</b></sub>](http://team.thebrain.pro)<br />[💻](https://github.com/kentcdodds/react-testing-library/commits?author=lgandecki "Code") [⚠️](https://github.com/kentcdodds/react-testing-library/commits?author=lgandecki "Tests") [📖](https://github.com/kentcdodds/react-testing-library/commits?author=lgandecki "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/498274?v=4" width="100px;"/><br /><sub><b>Ivan Babak</b></sub>](https://sompylasar.github.io)<br />[🐛](https://github.com/kentcdodds/react-testing-library/issues?q=author%3Asompylasar "Bug reports") [🤔](#ideas-sompylasar "Ideas, Planning, & Feedback") | [<img src="https://avatars3.githubusercontent.com/u/4439618?v=4" width="100px;"/><br /><sub><b>Jesse Day</b></sub>](https://github.com/jday3)<br />[💻](https://github.com/kentcdodds/react-testing-library/commits?author=jday3 "Code") | [<img src="https://avatars0.githubusercontent.com/u/15199?v=4" width="100px;"/><br /><sub><b>Ernesto García</b></sub>](http://gnapse.github.io)<br />[💬](#question-gnapse "Answering Questions") [💻](https://github.com/kentcdodds/react-testing-library/commits?author=gnapse "Code") [📖](https://github.com/kentcdodds/react-testing-library/commits?author=gnapse "Documentation") |

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

This project follows the [all-contributors][all-contributors] specification.
Expand Down
4 changes: 2 additions & 2 deletions src/extend-expect.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import extensions from './jest-extensions'

const {toBeInTheDOM, toHaveTextContent} = extensions
expect.extend({toBeInTheDOM, toHaveTextContent})
const {toBeInTheDOM, toHaveTextContent, toHaveAttribute} = extensions
expect.extend({toBeInTheDOM, toHaveTextContent, toHaveAttribute})
73 changes: 65 additions & 8 deletions src/jest-extensions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import {matcherHint, printReceived, printExpected} from 'jest-matcher-utils' //eslint-disable-line import/no-extraneous-dependencies
//eslint-disable-next-line import/no-extraneous-dependencies
import {
matcherHint,
printReceived,
printExpected,
stringify,
RECEIVED_COLOR as receivedColor,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering here, can't we use printReceived? Is that RECEIVED_COLOR does something apart from printReceived?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. printReceived uses RECEIVED_COLOR internally, but it also stringifies the argument. But in the new code I added I wanted to show attribute-name="value" all in the received or expected color, without stringifying all of it. If I used printReceived/printExpected I'd have the whole attribute-name="value" itself surrounded by double quotes (and possibly those inner quotes escaped then).

EXPECTED_COLOR as expectedColor,
} from 'jest-matcher-utils'
import {matches} from './utils'

function getDisplayName(subject) {
Expand All @@ -9,10 +17,30 @@ function getDisplayName(subject) {
}
}

function checkHtmlElement(htmlElement) {
if (!(htmlElement instanceof HTMLElement)) {
throw new Error(
`The given subject is a ${getDisplayName(
htmlElement,
)}, not an HTMLElement`,
)
}
}

const assertMessage = (assertionName, message, received, expected) =>
`${matcherHint(`${assertionName}`, 'received', '')} \n${message}: ` +
`${printExpected(expected)} \nReceived: ${printReceived(received)}`

function printAttribute(name, value) {
return value === undefined ? name : `${name}=${stringify(value)}`
}

function getAttributeComment(name, value) {
return value === undefined
? `element.hasAttribute(${stringify(name)})`
: `element.getAttribute(${stringify(name)}) === ${stringify(value)}`
}

const extensions = {
toBeInTheDOM(received) {
getDisplayName(received)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just noticed that this actually isn't doing anything. I'm pretty sure we should assign it a value called displayName and use that somehow or remove it entirely... Probably good for another PR though.

Copy link
Member Author

@gnapse gnapse Apr 5, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I noticed that too, but I preferred no to touch it here as it's unrelated.

I do have a few code cleanup proposals for this file, that I was meaning to provide in a separate PR. For instance, the pattern of returning something different on the matchers depending on wether they passed or not seems redundant to me.

Also it'd be good to bring the other two matchers messages up to the standards that you have proposed for .toHaveAttribute.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'd love it if you could clean this file up a bit in another PR! Let's just see what we need to do to increase coverage back to 100%.

Open coverage/lcov-report/index.html in your browser to see what's left 👍

Expand Down Expand Up @@ -42,13 +70,7 @@ const extensions = {
},

toHaveTextContent(htmlElement, checkWith) {
if (!(htmlElement instanceof HTMLElement))
throw new Error(
`The given subject is a ${getDisplayName(
htmlElement,
)}, not an HTMLElement`,
)

checkHtmlElement(htmlElement)
const textContent = htmlElement.textContent
const pass = matches(textContent, htmlElement, checkWith)
if (pass) {
Expand All @@ -75,6 +97,41 @@ const extensions = {
}
}
},

toHaveAttribute(htmlElement, name, expectedValue) {
checkHtmlElement(htmlElement)
const isExpectedValuePresent = expectedValue !== undefined
const hasAttribute = htmlElement.hasAttribute(name)
const receivedValue = htmlElement.getAttribute(name)
return {
pass: isExpectedValuePresent
? hasAttribute && receivedValue === expectedValue
: hasAttribute,
message: () => {
const to = this.isNot ? 'not to' : 'to'
const receivedAttribute = receivedColor(
hasAttribute
? printAttribute(name, receivedValue)
: 'attribute was not found',
)
const expectedMsg = `Expected the element ${to} have attribute:\n ${expectedColor(
printAttribute(name, expectedValue),
)}`
const matcher = matcherHint(
Copy link
Collaborator

@antsmartian antsmartian Apr 5, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have used assertMessage method to handle the messages. Can't we re-use them? Like we have done it over here: https://github.com/gnapse/react-testing-library/blob/bfeb26aa29e2b36cbf254a6cb8dc6934e32a055e/src/jest-extensions.js#L89

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@antoaravinth Not in this case. The messages I wanted to generate do not comply with the predefined format that assertMessage uses.

BTW, not even the two already existing custom matchers used this function. It's only being used in one of them. I do not think we can expect all or even most matchers to always adhere to this expected/received constrained message format. I was checking at jest's own codebase and how they approach generating messages, and the most common functionality they have across all them is encoded in all the functions they also publish in jest-matcher-utils and that we're using here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I got it. Just wanted to check. Cool, thanks!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That being said, although I do not object myself to how the code ended up in this function for generating the message, I get it if it looks too complex, and I'm of course open to any suggestions to improve it or even the resulting messages as well. I would not like though to do so by sacrificing too much of the friendliness of the resulting messages.

This matcher, although simple in the actual condition being tested, covers a few cases (e.g. it can test if the attribute is merely present or not, or it can test also that it has a specific value). Therefore the messages for the positive and negative cases, each of these handling the two cases of the attr value being tested or not, yields four different kind of resulting messages.

`${this.isNot ? '.not' : ''}.toHaveAttribute`,
'element',
printExpected(name),
{
secondArgument: isExpectedValuePresent
? printExpected(expectedValue)
: undefined,
comment: getAttributeComment(name, expectedValue),
},
)
return `${matcher}\n\n${expectedMsg}\nReceived:\n ${receivedAttribute}`
},
}
},
}

export default extensions