Skip to main content

ReactJS Jest Unit Testing Fundamentals

Having reached this page, it's safe to assume to already have reactjs and testing basics covered. My plan here is to highlight the complexity that can creep in when testing React-based UIs and how to manage this complexity.

Create a react app with Login page#

npx create-react-app js-react-jest-test

To make things quick and easy, I will use an existing Login Page template from Material-ui. I will first install the necessary dependencies and clean up the generated react application

cd js-react-jest-test rm src/App.css rm src/logo.svg
 yarn add @material-ui/core  yarn add @material-ui/icons

Create a new file SignIn.js in the src folder. Copy the SignIn page from this template. Replace the App.js file with this content

src/App.js
import React from 'react'import SignIn from './SignIn';
function App() {  return (    <SignIn />  );}
export default App;

Start the application to make sure there are no errors. If you run into startup issues dependencies mess, and if nothing else helps, add SKIP_PREFLIGHT_CHECK=true to an .env file in your project.

npm yarn start

Add a tests folder to the project root and keep tests here. If you run tests now, they should fail because we have altered the content of the App.js file

mkdir -p src/__tests__mv src/App.test.js src/__tests__
npm run test

You can optionally turn off watching tests using a custom script

"test:dev": "react-scripts test --watchAll=false"

This is a nice point to pause and ask what that we plan to achieve with the tests. The App.test.js test is certainly intended to be a placeholder, since the App page is really illustrative.

import { render, screen } from '@testing-library/react';import App from '../App';
test('renders learn react link', () => {  render(<App />);  const linkElement = screen.getByText(/learn react/i);  expect(linkElement).toBeInTheDocument();});

Let's fix up that test first. The first thing will be to add a data-testid attribute to the SignIn form

src/SignIn.js
<form className={classes.form} noValidate data-testid="signin-form">

Replace the existing App.test.js with the content below. The last line screen.debug() will dump the render output into the console to help visualize the results

import { render, screen } from '@testing-library/react';import App from '../App';
test('renders signin form', () => {  render(<App />);  const pricingPage = screen.getByTestId("signin-form");  expect(pricingPage).toBeInTheDocument();  screen.debug(); //dump screen content in consile});

In the console logs, you will notice that the App component and its child SignIn component are both rendered entirely, so it's easy to verify that the form with test it signin-form does indeed exist

Copy App.test.js and name the copy SignIn.test.js. Change the dependency to point to SignIn.js as well. Running tests again will produce the exact same results

import { render, screen } from '@testing-library/react';import SignIn from '../SignIn';
test('renders signin form', () => {  render(<SignIn />);  const signInForm = screen.getByTestId("signin-form");  expect(signInForm).toBeInTheDocument();  screen.debug(); //dump screen content in consile});

Update SignIn to handle user input

import React, { useState } from 'react';...const [form, setForm] = useState({    email: '',    password: ''})
const handleChange = e => {    const { id, value } = e.target;    setForm({ ...form, [id]: value })}...value={form.email}onChange={handleChange}...value={form.password}onChange={handleChange}

Note that you can shorten your test cycle time by targeting only the specific file to test. For example in thes case, I will simply test the SignIn component alone

npm run test:dev -- __tests__/SignIn.test.js

To test user interaction, you need to send key strokes to the input element being tested. The @testing-library/react library provides funcrions to do this. It's worth noting that although the screen import is the recommended way of querying elements in a test, it's not always possible to target a specific element, and in that case, falling back to querying the rendered document directly is the way to go. In addition you can wrap the tests inside a describe block and use the cleanup import to reset the rendered document before each test. screen is just a convenient shortcut for document.body

import { render, screen, fireEvent, cleanup } from '@testing-library/react';...describe('test rendering sign in page', () => {
  afterEach(cleanup)
  test('renders signin form', () => {    render(<SignIn />);    const signInForm = screen.getByTestId("signin-form");    expect(signInForm).toBeInTheDocument();  });
  test('entering email address updates the input\'s value', async () => {    await render(<SignIn />)    const input = document.querySelector('#email')    fireEvent.change(input, { target: { value: 'steve@email.com' } })    expect(input.value).toBe('steve@email.com')  })})

The next user interaction you can test is the form submit. Since the state of the form needs to be sent outside the form, the I'll add a property to the _SignIn component which will recieve this state. We can use this to verrify the submit operation.

export default function SignIn({ onSignIn }) ...const handleSubmit = e => {    e.preventDefault()    onSignIn(form)}...<Button    type="submit"    fullWidth    variant="contained"    color="primary"    className={classes.submit}    onClick={handleSubmit}>

For the test, I will pass a prop to the rendered SignIn component, and test the received value

test('submiting form without any input values sends the form\'s empty state', async () => {    let formData = {}    const onSignIn = (data) => ({...formData, ...data})    await render(<SignIn onSignIn={onSignIn}/>)    const button = screen.getByRole('button', {type: 'submit'})    fireEvent.click(button)    expect(formData).toMatchObject({})})
  test('submiting form after adding input values sends the form\'s completed state', async () => {    let formData = {}    const onSignIn = (data) => {      formData = {...formData, ...data}    }
    await render(<SignIn onSignIn={onSignIn}/>)    const button = screen.getByRole('button', {type: 'submit'})
    //now input some form data    const email = document.querySelector('#email')    fireEvent.change(email, { target: { value: 'steve@email.com' } })
    const password = document.querySelector('#password')    fireEvent.change(password, { target: { value: 'secret' } })
    fireEvent.click(button)    expect(formData).toMatchObject({      email: email.value,      password: password.value    })})

I will add another template page and then update the App component to be a router so that I can expolore a few other considerations whentesting React components. This time I will use the Pricing template which you can download and add to the project

src/App.js
import React from 'react'import {  BrowserRouter as Router,  Switch,  Route,} from "react-router-dom";import SignIn from './SignIn';import Pricing from './Pricing';
function App({ onSignIn }) {  return (    <Router>      <Switch>        <Route path="/login">          <SignIn onSignIn={onSignIn} />        </Route>        <Route path="/">          <Pricing />        </Route>      </Switch>    </Router>  );}
export default App;

On examining App.js a bit more closely, you will notice that the concerns of the SignIn component start to leak into App.js. Specifically, I'm talking about the onSignIn property. You can easily imagine the coupling if there were more components, and also more properties to pass down. The App component would quickly turn into a noisy jungle of who-knows-what.

A philisophy I have used with a lot of success is to seperate the rendering concerns of a compoennt from its associates external attributes and functions using the decorator or container pattern, very much like how react-redux does. This keeps the tests non-brittle and the dependent functions can be mocked out easily. I will illustrate how later on in the article

If you ran the tests again now, you will notice that App.test.js fails. This is because the default page rendered is the Pricing page, and so the test cannot finr the sign-form data attribute. Let's fix this first - for convinience, add a pricing-page data attribute to the Pricingcomponent

<Container maxWidth="md" component="main" data-testid="pricing-page">

The update the App.test.js test

test('renders the pricing page', () => {  render(<App />);  const pricingPage = screen.getByTestId("pricing-page");  expect(pricingPage).toBeInTheDocument();});

But wait a minute....I want the landing page to be the SignIn page. This can be achieved by using some state external to the App component, for example by using the useContext hook. For now, I will use the useState hook inside App to kick things off. And actually this presents a great opportunity for using a containercomponent. For illustration, I will move BrowserRouter further up the DOM tree away from the App component and have it in index.js' file instead. Create a new file in _src/container/ folder and name it App.container.js

src/container/App.container.js
import React, { useState } from 'react'import App from '../App'
const initialState = {    signedIn: false,    startRoute: '/login'}
export function handleSignIn (login) {    console.log('login email is', login.email)}
export default function AppContainer() {    const [app, setApp] = useState(initialState)
    const onSignIn = login => {        setApp({ ...app, signedIn: login.email })        handleSignIn(login)    }
    return <App app={app} onSignIn={onSignIn} />}

Now I'll make the necessary tweak to index.js to reflect this change

index.js
import React from 'react';import ReactDOM from 'react-dom';import './index.css';import App from './container/App.container';import { BrowserRouter } from 'react-router-dom'
ReactDOM.render(  <React.StrictMode>    <BrowserRouter>      <App />    </BrowserRouter>  </React.StrictMode>,  document.getElementById('root')

The App.js component will also require some tweaks to reflect this change as well

src/App.js
import React from 'react'import {  Switch,  Route,  Redirect} from "react-router-dom";import SignIn from './SignIn';import Pricing from './Pricing';
export default function App({ app: { signedIn, startRoute }, onSignIn }) {
  return (    <Switch>      <Route path="/login">        {signedIn ? <Redirect to="/" /> : <SignIn onSignIn={onSignIn} />}      </Route>      <Route path="/">        {!signedIn ? <Redirect to={startRoute} /> : <Pricing />}      </Route>    </Switch>  );}

If you run the application, you will see that the landing page is now the SignIn page, and upon submitting the login details, you will be taken to the Pricing page. However, if you run the tests, they will fail, and it's easy to guess why. The Appcomponent is not properly initialized

โ— renders the pricing page
    TypeError: Cannot read property 'signedIn' of undefined

So let's fix that in the test. The App component needs some app and onSignIn property, so let's give it exactly that

"src/__tests__/App.test.js
import { render, screen } from '@testing-library/react';import App from '../App';
describe('Testing rendering of App component', () => {  test('renders the pricing page', () => {    const onSignIn = (login) => console.log('login attempt using:', login)    const state = {      signedIn: false,      startRoute: '/login'    }
    render(<App app={state} onSignIn={onSignIn} />);    const pricingPage = screen.getByTestId("pricing-page");    expect(pricingPage).toBeInTheDocument();  });})

Now if you run the test, there's a different error, which is related to react-router

console.error      Error: Uncaught [Error: Invariant failed: You should not use <Switch> outside a <Router>]

This error typically means that you need to wrap your test component is a Router implementation. In this case, I'll use a MemoryRouter from the react-router-domlibrary

render(  <MemoryRouter>    <App app={state} onSignIn={onSignIn} />  </MemoryRouter>);

Now if you run the test again, there's a different but familiar error, which is related to the landing page

 FAIL  src/__tests__/App.test.js  โ— Testing rendering of App component โ€บ renders the pricing page
    TestingLibraryElementError: Unable to find an element by: [data-testid="pricing-page"]

This is actually a good sign now that the landing page must be the SignIn page. To veriyf this, I will add a similar test, but with the initial state having a different value

import { render, screen, cleanup } from '@testing-library/react';import { MemoryRouter } from 'react-router-dom';import App from '../App';
describe('Testing rendering of App component', () => {
  afterEach(cleanup)
  test('the landing page is signin form when signedIn is false', async () => {    const onSignIn = (login) => console.log('login attempt using:', login)    let state = {      signedIn: false,      startRoute: '/login'    }
    render(      <MemoryRouter>        <App app={state} onSignIn={onSignIn} />      </MemoryRouter>    );
    const signInForm = screen.getByTestId("signin-form");    expect(signInForm).toBeInTheDocument();  })
  test('the landing page is pricing page when signedIn is true', () => {    const onSignIn = (login) => console.log('login attempt using:', login)    let state = {      signedIn: true,      startRoute: '/login'    }
    render(      <MemoryRouter>        <App app={state} onSignIn={onSignIn} />      </MemoryRouter>    );
    const pricingPage = screen.getByTestId("pricing-page");    expect(pricingPage).toBeInTheDocument();  });})

The beauty of seperating the rendered component from its functional attribute keeps your tests pretty stable. Now you can easily test the container component but mocking out the rendered component. Let me show how - for this use case, install a new test dependency

npm install --save-dev @testing-library/react-hooks

Create a new test file in the src/tests/container/ folder and name it App.container.test.js. Add the content below

import { act } from '@testing-library/react-hooks'import AppContainer from '../../container/App.container';
describe('Testing rendering of App container component', () => {
    test('AppContainer renders without a blow up', async () => {        let component;        await act(async () => {            component = <AppContainer />        })
        expect(component.props).toMatchObject({})    })})

Although the test is very simplistic, it still captures the spirit of seperation of concerns.