diff --git a/.all-contributorsrc b/.all-contributorsrc index 5d154811..ac1c1f6b 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1,6 +1,7 @@ { "projectName": "react-testing-library", "projectOwner": "kentcdodds", + "repoType": "github", "files": [ "README.md" ], @@ -18,6 +19,15 @@ "infra", "test" ] + }, + { + "login": "audiolion", + "name": "Ryan Castner", + "avatar_url": "https://avatars1.githubusercontent.com/u/2430381?v=4", + "profile": "http://audiolion.github.io", + "contributions": [ + "doc" + ] } ] } diff --git a/.prettierrc b/.prettierrc index 68f30bd7..fb31ee19 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,7 +4,7 @@ "useTabs": false, "semi": false, "singleQuote": true, - "trailingComma": 'all', + "trailingComma": "all", "bracketSpacing": false, "jsxBracketSameLine": false } diff --git a/README.md b/README.md index a87391f6..1854178f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![downloads][downloads-badge]][npmtrends] [![MIT License][license-badge]][license] -[![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors) +[![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat-square)](#contributors) [![PRs Welcome][prs-badge]][prs] [![Code of Conduct][coc-badge]][coc] @@ -45,6 +45,7 @@ components. It provides light utility functions on top of `react-dom` and * [`flushPromises`](#flushpromises) * [`render`](#render) * [More on `data-testid`s](#more-on-data-testids) +* [Examples](#examples) * [FAQ](#faq) * [Other Solutions](#other-solutions) * [Guiding Principles](#guiding-principles) @@ -126,6 +127,21 @@ The containing DOM node of your rendered React Element (rendered using > Tip: To get the root element of your rendered element, use `container.firstChild`. +#### `unmount` + +This will cause the rendered component to be unmounted. This is useful for +testing what happens when your component is removed from the page (like testing +that you don't leave event handlers hanging around causing memory leaks). + +> This method is a pretty small abstraction over +> `ReactDOM.unmountComponentAtNode` + +```javascript +const {container, unmount} = render() +unmount() +// your component has been unmounted and now: container.innerHTML === '' +``` + #### `queryByTestId` A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) ``. Read @@ -144,6 +160,17 @@ one of the practices this library is intended to encourage. Learn more about this practice in the blog post: ["Making your UI tests resilient to change"](https://blog.kentcdodds.com/making-your-ui-tests-resilient-to-change-d37a6ee37269) +## Examples + +You'll find examples of testing with different libraries in +[the test directory](https://github.com/kentcdodds/react-testing-library/blob/master/src/__tests__). +Some included are: + +* [`react-redux`](https://github.com/kentcdodds/react-testing-library/blob/master/src/__tests__/react-redux.js) +* [`react-router`](https://github.com/kentcdodds/react-testing-library/blob/master/src/__tests__/react-router.js) + +Feel free to contribute more! + ## FAQ **How do I update the props of a rendered component?** @@ -241,6 +268,30 @@ const allLisInDiv = container.querySelectorAll('div li') const rootElement = container.firstChild ``` +**What if I’m iterating over a list of items that I want to put the data-testid="item" attribute on. How do I distinguish them from each other?** + +You can make your selector just choose the one you want by including :nth-child in the selector. + +```javascript +const thirdLiInUl = container.querySelector('ul > li:nth-child(3)') +``` + +Or you could include the index or an ID in your attribute: + +```javascript +
  • {item.text}
  • +``` + +And then you could use the `queryByTestId`: + +```javascript +const items = [ + /* your items */ +] +const {queryByTestId} = render(/* your component with the items */) +const thirdItem = queryByTestId(`item-${items[2].id}`) +``` + **What about enzyme is "bloated with complexity and features" and "encourage poor testing practices"** @@ -303,8 +354,8 @@ Thanks goes to these people ([emoji key][emojis]): -| [
    Kent C. Dodds](https://kentcdodds.com)
    [šŸ’»](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") | -| :---: | +| [
    Kent C. Dodds](https://kentcdodds.com)
    [šŸ’»](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") | [
    Ryan Castner](http://audiolion.github.io)
    [šŸ“–](https://github.com/kentcdodds/react-testing-library/commits?author=audiolion "Documentation") | +| :---: | :---: | diff --git a/package.json b/package.json index 204da157..58881938 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,15 @@ "dependencies": {}, "devDependencies": { "axios": "^0.18.0", + "history": "^4.7.2", "kcd-scripts": "^0.36.1", "react": "^16.2.0", "react-dom": "^16.2.0", - "react-transition-group": "^2.2.1" + "react-redux": "^5.0.7", + "react-router": "^4.2.0", + "react-router-dom": "^4.2.2", + "react-transition-group": "^2.2.1", + "redux": "^3.7.2" }, "peerDependencies": { "react-dom": "*" diff --git a/src/__tests__/react-redux.js b/src/__tests__/react-redux.js new file mode 100644 index 00000000..eb9f527e --- /dev/null +++ b/src/__tests__/react-redux.js @@ -0,0 +1,108 @@ +import React from 'react' +import {createStore} from 'redux' +import {Provider, connect} from 'react-redux' +import {render, Simulate} from '../' + +// counter.js +class Counter extends React.Component { + increment = () => { + this.props.dispatch({type: 'INCREMENT'}) + } + + decrement = () => { + this.props.dispatch({type: 'DECREMENT'}) + } + + render() { + return ( +
    +

    Counter

    +
    + + {this.props.count} + +
    +
    + ) + } +} + +// normally this would be: +// export default connect(state => ({count: state.count}))(Counter) +// but for this test we'll give it a variable name +// because we're doing this all in one file +const ConnectedCounter = connect(state => ({count: state.count}))(Counter) + +// app.js +function reducer(state = {count: 0}, action) { + switch (action.type) { + case 'INCREMENT': + return { + count: state.count + 1, + } + case 'DECREMENT': + return { + count: state.count - 1, + } + default: + return state + } +} + +// normally here you'd do: +// const store = createStore(reducer) +// ReactDOM.render( +// +// +// , +// document.getElementById('root'), +// ) +// but for this test we'll umm... not do that :) + +// Now here's what your test will look like: + +// this is a handy function that I normally make available for all my tests +// that deal with connected components. +// you can provide initialState or the entire store that the ui is rendered with +function renderWithRedux( + ui, + {initialState, store = createStore(reducer, initialState)} = {}, +) { + return { + ...render({ui}), + // adding `store` to the returned utilities to allow us + // to reference it in our tests (just try to avoid using + // this to test implementation details). + store, + } +} + +test('can render with redux with defaults', () => { + const {queryByTestId} = renderWithRedux() + Simulate.click(queryByTestId('incrementer')) + expect(queryByTestId('count-value').textContent).toBe('1') +}) + +test('can render with redux with custom initial state', () => { + const {queryByTestId} = renderWithRedux(, { + initialState: {count: 3}, + }) + Simulate.click(queryByTestId('decrementer')) + expect(queryByTestId('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(, { + store, + }) + Simulate.click(queryByTestId('incrementer')) + expect(queryByTestId('count-value').textContent).toBe('1000') + Simulate.click(queryByTestId('decrementer')) + expect(queryByTestId('count-value').textContent).toBe('1000') +}) diff --git a/src/__tests__/react-router.js b/src/__tests__/react-router.js new file mode 100644 index 00000000..57c2fc83 --- /dev/null +++ b/src/__tests__/react-router.js @@ -0,0 +1,73 @@ +import React from 'react' +import {withRouter} from 'react-router' +import {Link, Route, Router, Switch} from 'react-router-dom' +import {createMemoryHistory} from 'history' +import {render, Simulate} from '../' + +const About = () =>
    You are on the about page
    +const Home = () =>
    You are home
    +const NoMatch = () =>
    No match
    + +const LocationDisplay = withRouter(({location}) => ( +
    {location.pathname}
    +)) + +function App() { + return ( +
    + + Home + + + About + + + + + + + +
    + ) +} + +// Ok, so here's what your tests might look like + +// this is a handy function that I would utilize for any component +// that relies on the router being in context +function renderWithRouter( + ui, + {route = '/', history = createMemoryHistory({initialEntries: [route]})} = {}, +) { + return { + ...render({ui}), + // adding `history` to the returned utilities to allow us + // to reference it in our tests (just try to avoid using + // this to test implementation details). + history, + } +} + +test('full app rendering/navigating', () => { + const {container, queryByTestId} = renderWithRouter() + // 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) + // 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') +}) + +test('landing on a bad page', () => { + const {container} = renderWithRouter(, { + route: '/something-that-does-not-match', + }) + // normally I'd use a data-testid, but just wanted to show this is also possible + expect(container.innerHTML).toMatch('No match') +}) + +test('rendering a component that uses withRouter', () => { + const route = '/some-route' + const {queryByTestId} = renderWithRouter(, {route}) + expect(queryByTestId('location-display').textContent).toBe(route) +}) diff --git a/src/__tests__/stopwatch.js b/src/__tests__/stopwatch.js new file mode 100644 index 00000000..e4b15c37 --- /dev/null +++ b/src/__tests__/stopwatch.js @@ -0,0 +1,59 @@ +import React from 'react' +import {render, Simulate} from '../' + +class StopWatch extends React.Component { + state = {lapse: 0, running: false} + handleRunClick = () => { + this.setState(state => { + if (state.running) { + clearInterval(this.timer) + } else { + const startTime = Date.now() - this.state.lapse + this.timer = setInterval(() => { + this.setState({lapse: Date.now() - startTime}) + }) + } + return {running: !state.running} + }) + } + handleClearClick = () => { + clearInterval(this.timer) + this.setState({lapse: 0, running: false}) + } + componentWillUnmount() { + clearInterval(this.timer) + } + render() { + const {lapse, running} = this.state + return ( +
    + {lapse}ms + + +
    + ) + } +} + +const wait = time => new Promise(resolve => setTimeout(resolve, time)) + +test('unmounts a component', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const {unmount, queryByTestId, container} = render() + Simulate.click(queryByTestId('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. + // You don't need to do this in your apps. Just rely on the fact that this works. + expect(container.innerHTML).toBe('') + // just wait to see if the interval is cleared or not + // if it's not, then we'll call setState on an unmounted component + // and get an error. + await wait() + // eslint-disable-next-line no-console + expect(console.error).not.toHaveBeenCalled() +}) diff --git a/src/index.js b/src/index.js index 7d99459d..b1258b96 100644 --- a/src/index.js +++ b/src/index.js @@ -15,6 +15,7 @@ function render(ui, {container = document.createElement('div')} = {}) { ReactDOM.render(ui, container) return { container, + unmount: () => ReactDOM.unmountComponentAtNode(container), queryByTestId: queryDivByTestId.bind(null, container), } }