Skip to content

Commit

Permalink
feat: Add getByTestId utility (#10)
Browse files Browse the repository at this point in the history
* feat: Add getByTestId utility

* Updates to getByTestId documentation

* minor tweaks
  • Loading branch information
pbomb authored and Kent C. Dodds committed Mar 21, 2018
1 parent 2cf3ab6 commit 98987c8
Show file tree
Hide file tree
Showing 14 changed files with 143 additions and 51 deletions.
12 changes: 12 additions & 0 deletions .all-contributorsrc
Expand Up @@ -55,6 +55,18 @@
"contributions": [
"doc"
]
},
{
"login": "pbomb",
"name": "Matt Parrish",
"avatar_url": "https://avatars0.githubusercontent.com/u/1402095?v=4",
"profile": "https://github.com/pbomb",
"contributions": [
"bug",
"code",
"doc",
"test"
]
}
]
}
66 changes: 46 additions & 20 deletions README.md
Expand Up @@ -16,7 +16,7 @@
[![downloads][downloads-badge]][npmtrends]
[![MIT License][license-badge]][license]

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

Expand Down Expand Up @@ -86,18 +86,18 @@ test('Fetch makes an API call and displays the greeting when load-greeting is cl
}),
)
const url = '/greeting'
const {queryByTestId, container} = render(<Fetch url={url} />)
const {getByTestId, container} = render(<Fetch url={url} />)

// Act
Simulate.click(queryByTestId('load-greeting'))
Simulate.click(getByTestId('load-greeting'))

// let's wait for our mocked `get` request promise to resolve
await flushPromises()

// Assert
expect(axiosMock.get).toHaveBeenCalledTimes(1)
expect(axiosMock.get).toHaveBeenCalledWith(url)
expect(queryByTestId('greeting-text').textContent).toBe('hello there')
expect(getByTestId('greeting-text').textContent).toBe('hello there')
expect(container.firstChild).toMatchSnapshot()
})
```
Expand Down Expand Up @@ -146,18 +146,34 @@ unmount()
// your component has been unmounted and now: container.innerHTML === ''
```

#### `getByTestId`

A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) `` except
that it will throw an Error if no matching element is found. Read more about
`data-testid`s below.

```javascript
const usernameInputElement = getByTestId('username-input')
usernameInputElement.value = 'new value'
Simulate.change(usernameInputElement)
```

#### `queryByTestId`

A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) ``. Read
more about `data-testid`s below.
A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) ``
(Note: just like `querySelector`, this could return null if no matching element
is found, which may lead to harder-to-understand error messages). Read more about
`data-testid`s below.

```javascript
const usernameInputElement = queryByTestId('username-input')
// assert something doesn't exist
// (you couldn't do this with `getByTestId`)
expect(queryByTestId('username-input')).toBeNull()
```

## More on `data-testid`s

The `queryByTestId` utility is referring to the practice of using `data-testid`
The `getByTestId` and `queryByTestId` utilities refer to the practice of using `data-testid`
attributes to identify individual elements in your rendered component. This is
one of the practices this library is intended to encourage.

Expand Down Expand Up @@ -186,14 +202,14 @@ prefer to update the props of a rendered component in your test, the easiest
way to do that is:

```javascript
const {container, queryByTestId} = render(<NumberDisplay number={1} />)
expect(queryByTestId('number-display').textContent).toBe('1')
const {container, getByTestId} = render(<NumberDisplay number={1} />)
expect(getByTestId('number-display').textContent).toBe('1')

// re-render the same component with different props
// but pass the same container in the options argument.
// which will cause a re-render of the same instance (normal React behavior).
render(<NumberDisplay number={2} />, {container})
expect(queryByTestId('number-display').textContent).toBe('2')
expect(getByTestId('number-display').textContent).toBe('2')
```

[Open the tests](https://github.com/kentcdodds/react-testing-library/blob/master/src/__tests__/number-display.js)
Expand All @@ -219,14 +235,16 @@ jest.mock('react-transition-group', () => {
})

test('you can mock things with jest.mock', () => {
const {queryByTestId} = render(<HiddenMessage initialShow={true} />)
const {getByTestId, queryByTestId} = render(
<HiddenMessage initialShow={true} />,
)
expect(queryByTestId('hidden-message')).toBeTruthy() // we just care it exists
// hide the message
Simulate.click(queryByTestId('toggle-message'))
Simulate.click(getByTestId('toggle-message'))
// in the real world, the CSSTransition component would take some time
// before finishing the animation which would actually hide the message.
// So we've mocked it out for our tests to make it happen instantly
expect(queryByTestId('hidden-message')).toBeFalsy() // we just care it doesn't exist
expect(queryByTestId('hidden-message')).toBeNull() // we just care it doesn't exist
})
```

Expand All @@ -247,6 +265,14 @@ something more
Learn more about how Jest mocks work from my blog post:
["But really, what is a JavaScript mock?"](https://blog.kentcdodds.com/but-really-what-is-a-javascript-mock-10d060966f7d)

**What if I want to verify that an element does NOT exist?**

You typically will get access to rendered elements using the `getByTestId` utility. However, that function will throw an error if the element isn't found. If you want to specifically test for the absence of an element, then you should use the `queryByTestId` utility which will return the element if found or `null` if not.

```javascript
expect(queryByTestId('thing-that-does-not-exist')).toBeNull()
```

**I don't want to use `data-testid` attributes for everything. Do I have to?**

Definitely not. That said, a common reason people don't like the `data-testid`
Expand Down Expand Up @@ -286,18 +312,18 @@ Or you could include the index or an ID in your attribute:
<li data-testid={`item-${item.id}`}>{item.text}</li>
```

And then you could use the `queryByTestId`:
And then you could use the `getByTestId` utility:

```javascript
const items = [
/* your items */
]
const {queryByTestId} = render(/* your component with the items */)
const thirdItem = queryByTestId(`item-${items[2].id}`)
const {getByTestId} = render(/* your component with the items */)
const thirdItem = getByTestId(`item-${items[2].id}`)
```

**What about enzyme is "bloated with complexity and features" and "encourage poor testing
practices"**
practices"?**

Most of the damaging features have to do with encouraging testing implementation
details. Primarily, these are
Expand Down Expand Up @@ -358,8 +384,8 @@ 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://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") |
| :---: | :---: | :---: | :---: | :---: | :---: |

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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -61,4 +61,4 @@
"url": "https://github.com/kentcdodds/react-testing-library/issues"
},
"homepage": "https://github.com/kentcdodds/react-testing-library#readme"
}
}
15 changes: 15 additions & 0 deletions src/__tests__/__snapshots__/element-queries.js.snap
@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`getByTestId finds matching element 1`] = `
<span
data-testid="test-component"
/>
`;

exports[`getByTestId throws error when no matching element exists 1`] = `"Unable to find element by [data-testid=\\"unknown-data-testid\\"]"`;

exports[`queryByTestId finds matching element 1`] = `
<span
data-testid="test-component"
/>
`;
26 changes: 26 additions & 0 deletions src/__tests__/element-queries.js
@@ -0,0 +1,26 @@
import React from 'react'
import {render} from '../'

const TestComponent = () => <span data-testid="test-component" />

test('queryByTestId finds matching element', () => {
const {queryByTestId} = render(<TestComponent />)
expect(queryByTestId('test-component')).toMatchSnapshot()
})

test('queryByTestId returns null when no matching element exists', () => {
const {queryByTestId} = render(<TestComponent />)
expect(queryByTestId('unknown-data-testid')).toBeNull()
})

test('getByTestId finds matching element', () => {
const {getByTestId} = render(<TestComponent />)
expect(getByTestId('test-component')).toMatchSnapshot()
})

test('getByTestId throws error when no matching element exists', () => {
const {getByTestId} = render(<TestComponent />)
expect(() =>
getByTestId('unknown-data-testid'),
).toThrowErrorMatchingSnapshot()
})
6 changes: 3 additions & 3 deletions src/__tests__/fetch.js
Expand Up @@ -37,16 +37,16 @@ test('Fetch makes an API call and displays the greeting when load-greeting is cl
}),
)
const url = '/greeting'
const {queryByTestId, container} = render(<Fetch url={url} />)
const {getByTestId, container} = render(<Fetch url={url} />)

// Act
Simulate.click(queryByTestId('load-greeting'))
Simulate.click(getByTestId('load-greeting'))

await flushPromises()

// Assert
expect(axiosMock.get).toHaveBeenCalledTimes(1)
expect(axiosMock.get).toHaveBeenCalledWith(url)
expect(queryByTestId('greeting-text').textContent).toBe('hello there')
expect(getByTestId('greeting-text').textContent).toBe('hello there')
expect(container.firstChild).toMatchSnapshot()
})
6 changes: 4 additions & 2 deletions src/__tests__/mock.react-transition-group.js
Expand Up @@ -39,10 +39,12 @@ jest.mock('react-transition-group', () => {
})

test('you can mock things with jest.mock', () => {
const {queryByTestId} = render(<HiddenMessage initialShow={true} />)
const {getByTestId, queryByTestId} = render(
<HiddenMessage initialShow={true} />,
)
expect(queryByTestId('hidden-message')).toBeTruthy() // we just care it exists
// hide the message
Simulate.click(queryByTestId('toggle-message'))
Simulate.click(getByTestId('toggle-message'))
// in the real world, the CSSTransition component would take some time
// before finishing the animation which would actually hide the message.
// So we've mocked it out for our tests to make it happen instantly
Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/number-display.js
Expand Up @@ -16,14 +16,14 @@ class NumberDisplay extends React.Component {
}

test('calling render with the same component on the same container does not remount', () => {
const {container, queryByTestId} = render(<NumberDisplay number={1} />)
expect(queryByTestId('number-display').textContent).toBe('1')
const {container, getByTestId} = render(<NumberDisplay number={1} />)
expect(getByTestId('number-display').textContent).toBe('1')

// re-render the same component with different props
// but pass the same container in the options argument.
// which will cause a re-render of the same instance (normal React behavior).
render(<NumberDisplay number={2} />, {container})
expect(queryByTestId('number-display').textContent).toBe('2')
expect(getByTestId('number-display').textContent).toBe('2')

expect(queryByTestId('instance-id').textContent).toBe('1')
expect(getByTestId('instance-id').textContent).toBe('1')
})
22 changes: 11 additions & 11 deletions src/__tests__/react-redux.js
Expand Up @@ -82,27 +82,27 @@ function renderWithRedux(
}

test('can render with redux with defaults', () => {
const {queryByTestId} = renderWithRedux(<ConnectedCounter />)
Simulate.click(queryByTestId('incrementer'))
expect(queryByTestId('count-value').textContent).toBe('1')
const {getByTestId} = renderWithRedux(<ConnectedCounter />)
Simulate.click(getByTestId('incrementer'))
expect(getByTestId('count-value').textContent).toBe('1')
})

test('can render with redux with custom initial state', () => {
const {queryByTestId} = renderWithRedux(<ConnectedCounter />, {
const {getByTestId} = renderWithRedux(<ConnectedCounter />, {
initialState: {count: 3},
})
Simulate.click(queryByTestId('decrementer'))
expect(queryByTestId('count-value').textContent).toBe('2')
Simulate.click(getByTestId('decrementer'))
expect(getByTestId('count-value').textContent).toBe('2')
})

test('can render with redux with custom store', () => {
// this is a silly store that can never be changed
const store = createStore(() => ({count: 1000}))
const {queryByTestId} = renderWithRedux(<ConnectedCounter />, {
const {getByTestId} = renderWithRedux(<ConnectedCounter />, {
store,
})
Simulate.click(queryByTestId('incrementer'))
expect(queryByTestId('count-value').textContent).toBe('1000')
Simulate.click(queryByTestId('decrementer'))
expect(queryByTestId('count-value').textContent).toBe('1000')
Simulate.click(getByTestId('incrementer'))
expect(getByTestId('count-value').textContent).toBe('1000')
Simulate.click(getByTestId('decrementer'))
expect(getByTestId('count-value').textContent).toBe('1000')
})
8 changes: 4 additions & 4 deletions src/__tests__/react-router.js
Expand Up @@ -49,11 +49,11 @@ function renderWithRouter(
}

test('full app rendering/navigating', () => {
const {container, queryByTestId} = renderWithRouter(<App />)
const {container, getByTestId} = renderWithRouter(<App />)
// normally I'd use a data-testid, but just wanted to show this is also possible
expect(container.innerHTML).toMatch('You are home')
const leftClick = {button: 0}
Simulate.click(queryByTestId('about-link'), leftClick)
Simulate.click(getByTestId('about-link'), leftClick)
// normally I'd use a data-testid, but just wanted to show this is also possible
expect(container.innerHTML).toMatch('You are on the about page')
})
Expand All @@ -68,6 +68,6 @@ test('landing on a bad page', () => {

test('rendering a component that uses withRouter', () => {
const route = '/some-route'
const {queryByTestId} = renderWithRouter(<LocationDisplay />, {route})
expect(queryByTestId('location-display').textContent).toBe(route)
const {getByTestId} = renderWithRouter(<LocationDisplay />, {route})
expect(getByTestId('location-display').textContent).toBe(route)
})
4 changes: 2 additions & 2 deletions src/__tests__/shallow.react-transition-group.js
Expand Up @@ -35,15 +35,15 @@ jest.mock('react-transition-group', () => {
})

test('you can mock things with jest.mock', () => {
const {queryByTestId} = render(<HiddenMessage initialShow={true} />)
const {getByTestId} = render(<HiddenMessage initialShow={true} />)
const context = expect.any(Object)
const children = expect.any(Object)
const defaultProps = {children, timeout: 1000, className: 'fade'}
expect(CSSTransition).toHaveBeenCalledWith(
{in: true, ...defaultProps},
context,
)
Simulate.click(queryByTestId('toggle-message'))
Simulate.click(getByTestId('toggle-message'))
expect(CSSTransition).toHaveBeenCalledWith(
{in: true, ...defaultProps},
expect.any(Object),
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/stopwatch.js
Expand Up @@ -43,8 +43,8 @@ const wait = time => new Promise(resolve => setTimeout(resolve, time))

test('unmounts a component', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {})
const {unmount, queryByTestId, container} = render(<StopWatch />)
Simulate.click(queryByTestId('start-stop-button'))
const {unmount, getByTestId, container} = render(<StopWatch />)
Simulate.click(getByTestId('start-stop-button'))
unmount()
// hey there reader! You don't need to have an assertion like this one
// this is just me making sure that the unmount function works.
Expand Down
14 changes: 12 additions & 2 deletions src/index.js
Expand Up @@ -7,16 +7,26 @@ function select(id) {
}

// we may expose this eventually
function queryDivByTestId(div, id) {
function queryByTestId(div, id) {
return div.querySelector(select(id))
}

// we may expose this eventually
function getByTestId(div, id) {
const el = queryByTestId(div, id)
if (!el) {
throw new Error(`Unable to find element by ${select(id)}`)
}
return el
}

function render(ui, {container = document.createElement('div')} = {}) {
ReactDOM.render(ui, container)
return {
container,
unmount: () => ReactDOM.unmountComponentAtNode(container),
queryByTestId: queryDivByTestId.bind(null, container),
queryByTestId: queryByTestId.bind(null, container),
getByTestId: getByTestId.bind(null, container),
}
}

Expand Down

0 comments on commit 98987c8

Please sign in to comment.