React-Testing-Library Tutorial: How to Write Tests that Works in Real Projects

React-Testing-Library Tutorial: How to Write Tests that Works in Real Projects

From introduction to workarounds

ยท

17 min read

React-Testing-Library is a common library for testing React Apps. It contains many usable APIs that make you focus on the behaviors that are relevant to users.

The library comes by default when you built your project using create-react-app. So this is typically a go-to for testing on React projects, as well as jest.

Basically, this library enables you to test React components in an intuitive way, but some understanding is needed about RTL's API. You also have to know some workaround for the issues you'll typically face in real-world projects.

In this tutorial, we start with a minimum example and then go to real-world examples to expand our understanding of RTL. Then you'll be good to go, with your own project.

What is React Testing Library?

Before start writing tests, know more about RTL.

RTL provides you with APIs that enable you to render & interact with the React DOM elements. So you can test each component you wrote in your project, and check if it works as expected.

In the typical scenarios you use it on each test script, and run those scripts with test runners like jest.

This library is intended to test your components in a way that users interact, so it doesn't focus on the implementation details. This means, this is mainly for integration tests, and this library gives you the "real confidence" for acceptance by real users, as the docs state.

The Starter Project

Let's dive into the actual testing.

First, clone the example repository. (Yarn & TypeScript required)

git clone git@github.com:yozibak/react-testing-example.git
cd react-testing-example
git switch rtl-tutorial-starter
yarn install 
yarn start

The repository is nothing special, a very simple React TODO app.

We are going to add some tests on this project. You can view the final result by switching the branch (git switch rtl-tutorial-final).

Your First Test

Let's start with a simple example. Create file below under src.

// src/App.test.tsx

import '@testing-library/jest-dom'
import { render } from '@testing-library/react'
import App from './App'

it('renders App component', () => {
  const {getByText} = render(<App />)
  expect(getByText("Simple Todo App")).toBeInTheDocument()
})

Then run this:

yarn test

Outputs something like this:

 PASS  src/App.test.tsx
  โœ“ renders App component (19 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.368 s, estimated 1 s
Ran all test suites related to changed files.

Now you see it, tests are just some scripts and you just run it all. Then see the results.

That yarn test executes jest, and jest searches for all of the test scripts, and finds App.test.tsx, then runs this node script. In this script, you see importing of @testing-library/react, which we're going to dig into.

Basic Workflow

A typical workflow of RTL testing is like this:

  1. render the desired component you want to test
  2. Get the element you want to check on the rendered dom
  3. expect the target toBe the correct result

Now let's look back at the previous example:

// src/App.test.tsx
it('renders App component', () => {
  const {getByText} = render(<App />) // #1 render component
  const title = getByText("Simple Todo App") // #2 get element
  expect(title).toBeInTheDocument() // #3 expect-to-be assert
})

render, get, expect. This is typical workflow of testing with RTL (& jest).

#1. render the Component

First of all, you need to render the component that you want to test.

render(<YourComponent />)

You can see what's rendered by calling screen.debug. Try change the file as below.

import { render, screen } from '@testing-library/react'
import App from './App'

describe('App', () => {
  it('renders App component', () => {
    render(<App />)
    screen.debug() // shows rendered dom on the console
  })
})

Save the file, and you'll see the actual rendered DOM on your console.

screen.debug is especially useful when your test doesn't pass as expected and want to know what is exactly rendered.

#2. Get the element you want to check

Once you rendered the component, now you have to get the desired elements, such as buttons, texts, or inputs, etc. like so:

const element = getByText(/Simple Todo App/i)

getByText is the most simple method for getting an element, though RTL provides more different methods other than getByText to find the element you want (which I'll introduce later on).

#3. expect - assert the result

For the found element, you need to assert if it is correctly shown. You must have seen this line.

expect(target).toBeInTheDocument()

You'll see expect and toBe many times in the tests. What is this?

expect is included in jest. It's no different than a pure jest test like this one:

it('should sum up the nums in array', () => {
  const arr = [1,2,3]
  expect(arr.reduce((a,b) => a + b)).toBe(6)
})

On expected result, you assert it by using toBe-.

But note that toBeInTheDocument() you've seen is one of extensions (jest-dom), not included in jest. So remember importing @testing-library/jest-dom.

In testing on React projects, you'll be likely to use these assertions below:

import '@testing-library/jest-dom' // needed

.toBeInTheDocument()
.toHaveTextContent('Some Text')
.toHaveClass(1)

expect, then assert it to do something what you expect.

For all list of assertions, see the jest docs & jest-dom docs.

Write Tests for the React Todo App example

Now, let's write the actual tests for our example app.

I will cover most of the general scenarios you'll face in testing:

  • Get Element By Text
  • Find the element that shows up eventually
  • Get Element By "Role"
  • User Event
  • Ensure the element is NOT on the document
  • Get Element By Class
  • Test the hooks

Again, you can clone the example repository from here.

If you want to write tests along the instructions, git checkout rtl-tutorial-starter first.

You can see the final result by git checkout rtl-tutorial-final.

Get Element By Text

getByText, as the name implies, get by element's inner text.

If you have <div>Simple Todo App</div> on the rendered dom, you can find it by its text.

// src/App.test.tsx
import '@testing-library/jest-dom'
import { render } from '@testing-library/react'
import App from './App'

describe('App', () => {
  it('renders App component', () => {
    const { getByText } = render(<App />)
    // find element by text. /text/ for partial match, "text" for exact match. 
    // /i stands for 'ignore the upper/lower case'.
    expect(getByText(/Simple Todo App/i)).toBeInTheDocument()
  })
})

You can set the search text as partial match, as well as exact match. Generally it's concise to use partial match, but for the expected result like users submitted data, it's better to use exact match.

Find the Element that shows up Eventually

Let's move on to src/pages/Dashboard.tsx.

The app shows todo list, after the useTodoStore hook receives the user info and set initial (pseudo-fetched) todos. So initially the app won't show anything (though it's showing up before you blink).

Thus, you can't actually get the todo card immediately after the render. Try this:

// src/pages/Dashboard.test.tsx
import '@testing-library/jest-dom'
import { Dashboard } from './Dashboard'
import { MyInitialTodos } from 'store/todos'
import { render } from '@testing-library/react'

const myTodo = MyInitialTodos[0]

describe('Dashboard todos list', () => {

  it("shows initial todos", async () => {
    const {getByText} = render(<Dashboard />)
    expect(getByText(myTodo.title)).toBeInTheDocument()
  })
})

This fails because getBy throws an error once it can't find the target. So we want it to wait for the todo list show up. In this kind of case, use findBy instead:

describe('Dashboard todos list', () => {

  it("shows initial todos", async () => {
    const {findByText} = render(<Dashboard />)
    expect(await findByText(myTodo.title)).toBeInTheDocument()
  })
})

Now it should PASS. findBy returns an element promise that resolves when the element is found. Also, note that the test case uses async/await.

Get Element By "Role"

Getting elements by its text is not always the best. Because sometimes it makes tests brittle. Text often changes. And if the document has many texts and one of them includes the match, that test case would break.

So it's a good practice to get an element by its other context, and RTL encourages you to find it by "role".

// src/pages/Dashboard.test.tsx

describe('Create todo', () => {

  it('caretes/shows new todo when users submit new todo', async () => {

    const {
      getByRole, findByLabelText, findByRole, findByText
    } = render(<Dashboard />)

    // We want to find "that button with plus symbol"
    const addTodoBtn = getByRole('button', { name: '+' })

    // ...test case goes on
  })
})

So simple in this case, <button> is role="button". role is one of HTML element attributes, and generally, it is defined implicitly. And it's important to note that RTL doesn't have methods like byTagName to find <button>.

One of the philosophies behind RTL is that it tries to test the React components in a way users see the interface. Users don't know the tag, "className" or "id", they just see "This button which says 'submit'", or "That form with 'username' as label", so does React Testing Library.

role is more close to what users see. For example, <button> and <input type="button"> are both role="button" and these are both buttons for the users. So it's understandable to find the desired element it by 'specifications', not by tag or classname, which is 'implementation' that often changes, possibly breaks the test. So it's better to get elements byRole using RTL.

User Event

Let's write on the test. So we found that button to expand the form. Now we want to click it.

When you want it act users' interactions on the web, you need userEvent:


// add this import
import userEvent from '@testing-library/user-event'

describe('Create todo', () => {

  it('caretes/shows new todo when users submit new todo', async () => {

    const {
      getByRole, findByLabelText, findByRole, findByText
    } = render(<Dashboard />)

    // Act
    const addTodoBtn = getByRole('button', { name: '+' })
    userEvent.click(addTodoBtn) // act on the found element

    // Assert todo form
    const titleInput = await findByLabelText(/Describe your todo/i)
    const meemoInput = await findByLabelText(/Any notes/i)
    expect(titleInput).toBeInTheDocument()
    expect(meemoInput).toBeInTheDocument()

    // ...case continues
  })
})

By using userEvent, you can make tests act like users. What users do? Click, type, and that's almost all the interactions users do on the web. So we mimic them on the test.

Let's have it type in the form, then submit that. Complete the test case with this:

describe('Create todo', () => {

  it('caretes/shows new todo when users submit new todo', async () => {

    const {
      getByRole, findByLabelText, findByRole, findByText
    } = render(<Dashboard />)

    // Act
    const addTodoBtn = getByRole('button', { name: '+' })
    userEvent.click(addTodoBtn)

    // Assert todo form
    const titleInput = await findByLabelText(/Describe your todo/i)
    const meemoInput = await findByLabelText(/Any notes/i)
    expect(titleInput).toBeInTheDocument()
    expect(meemoInput).toBeInTheDocument()

    // Act
    const todo = { title: 'test add-todo feature', memo: 'see if it works!'}
    const submitBtn = await findByRole('button', {name: /Add/i })
    userEvent.type(titleInput, todo.title)
    userEvent.type(meemoInput, todo.memo)
    userEvent.click(submitBtn)

    // Assert
    expect(await findByRole('button', { name: '+' })).toBeInTheDocument() // form, closed 
    expect(await findByText(todo.title)).toBeInTheDocument() // new todo
  })
})

Finally the test finds the created todo and it passes. Cool.

Get Element By Class

Now test another case. We want to check if the todo can toggle the status & appearance (i.e. its class).

Basically you better find elements by role (since classes are "implementations", as I explained before), but sometimes you want to get element by its classes. Especially when you want to check the classes itself.

In this case, we find elements by classes like we do it on the web. You can actually use the DOM methods (sth like getElementsByClassName, getElementById) that you're used to. Like so:

// src/pages/Dashboard.tsx

describe('Complete todo', () => {

  it('toggles todo status when checkbox clicked', async () => {
    const {
      getAllByRole,
      container // retrieve rendered document tree
    } = render(<Dashboard />)

    // before
    const checkbox = getAllByRole('checkbox')[0]
    const todoCard = container.getElementsByClassName('todo')[0] // get it
    expect(checkbox).not.toBeChecked()
    expect(todoCard).toHaveClass('todo card') // default classes

    // check
    userEvent.click(checkbox)
    expect(checkbox).toBeChecked()
    expect(todoCard).toHaveClass('todo card completed') // classes after checking

    // revert
    userEvent.click(checkbox)
    expect(checkbox).not.toBeChecked()
    expect(todoCard).toHaveClass('todo card') // reverted
  })
})

But remember, this is just an example and this test case gets broken when the class name changes, so it may be better to change the component's implementations itself (e.g. add test-id) before writing tests (sometimes you can't, so this is a resort)

Ensure the element is NOT on the Document

Lastly, we need to ensure that todo card disappears when users click 'discard' button.

Now we know how to find & interact with the element, it's easy:

// src/pages/Dashboard.test.tsx

describe('Delete todo', () => {
  it('deletes todo by pushing discard button', () => {
    const {
      getAllByRole,
      getByText,
    } = render(<Dashboard />)

    // before
    const btn = getAllByRole('button', {name: '๐Ÿ—‘'})[0] // there's multiple matches on the dom, so pick one
    expect(getByText(myTodo.title)).toBeInTheDocument()

    // Act
    userEvent.click(btn)

    // Assert
    expect(getByText(myTodo.title)).not.toBeInTheDocument()
  })
})

But this test fails. As I mentioned earlier, get tries to find the element, but throws an error immediately after the element wasn't found, even when the element is NOT supposed to be there.

Instead, use queryBy. It keeps quiet even when the element wasn't there.

expect(queryByText(myTodo.title)).not.toBeInTheDocument() // doesn't throw error

As a summary, there's 3 methods to use in RTL.

  • getBy for general cases
  • findBy for elements that shows up afterwards
  • queryBy for elements that may not be there

You can find the cheatsheet on RTL's docs

Test the hooks

Finally, we need to test our useTodoStore, the custom React hook.

Actually we've already tested this hook(kind of), since we used this hook on the components we tested before. but to test how hooks behave, we need to test it in a separate condition regardless of components where this hook is used.

Think it like this: Testing hooks under the components is to test if you're using the hooks correctly. Testing hooks directly is to check if the hook is implemented properly. So we also need to test hooks as 'unit'.

To test the hooks itself, you may think of just calling the hooks in the test suites, but you actually can't.

test('useTodoStore', () => {
  const { todos, createTodo } = useTodoStore() // Invalid hook call.
  createTodo({title: 'Foo', completed: false})
  expect(todos.map( t => t.title).includes('Foo')).toBe(true)
})

Because hooks only work under React components since it is basically state logic. So you need to prepare a component to test the hooks, then test it under that component:


// src/store/todos.test.tsx

import '@testing-library/jest-dom'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useTodoStore } from './todos'

const TestUseTodoStore = () => {
  const {
    todos,
    createTodo,
    updateTodo,
    deleteTodo,
  } = useTodoStore()

  const create = (title: string, memo?: string) => {
    createTodo({title, memo, completed: false})
  }

  const update = (idx:number, args:any) => {
    updateTodo({...todos[idx], ...args})
  }

  const delete_ = (idx:number) => {
    deleteTodo(todos[idx].id)
  }

  // Build a component that includes all the methods from the hooks
  // Call events by clicking the components' button. 
  return (
    <div>
      <div>Current Todos:{todos.map(t => t.title).join(',')}</div>
      <button onClick={() => create('todo 1')}
      >Create Todo 1</button>
      <button onClick={() => create('todo 2')}
      >Create Todo 2</button>
      <button onClick={() => update(0, {title:'todo 1 updated'})}
      >Update Todo 1</button>
      <button onClick={() => update(1, {title:'todo 2 updated'})}
      >Update Todo 2</button>
      <button onClick={() => delete_(0)}
      >Delete Todo 1</button>
      <button onClick={() => delete_(0)}
      >Delete Todo 2</button>
    </div>
  )
}

test('useTodoStore', () => {
  render(<TestUseTodoStore />)
  const current = screen.getByText(/Current Todos:/)
  const createTodo1 = screen.getByText(/Create Todo 1/)
  const createTodo2 = screen.getByText(/Create Todo 2/)
  const updateTodo1 = screen.getByText(/Update Todo 1/)
  const updateTodo2 = screen.getByText(/Update Todo 2/)
  const deleteTodo1 = screen.getByText(/Delete Todo 1/)
  const deleteTodo2 = screen.getByText(/Delete Todo 2/)

  // initial state
  expect(current).toHaveTextContent('Current Todos:')

  // create 1
  userEvent.click(createTodo1)
  expect(current).toHaveTextContent('Current Todos:todo 1')

  // create 2
  userEvent.click(createTodo2)
  expect(current).toHaveTextContent('Current Todos:todo 1,todo 2')

  // update 1
  userEvent.click(updateTodo1)
  expect(current).toHaveTextContent('Current Todos:todo 1 updated,todo 2')

  // update 2
  userEvent.click(updateTodo2)
  expect(current).toHaveTextContent('Current Todos:todo 1 updated,todo 2 updated')

  // delete 1
  userEvent.click(deleteTodo1)
  expect(current).toHaveTextContent('Current Todos:todo 2 updated')

  // delete 2
  userEvent.click(deleteTodo2)
  expect(current).toHaveTextContent('Current Todos:')
})

Generally this strategy works fine. But in some scenarios where your hook is complicated and it's hard to trigger all the hook events by just writing "testing-component". In that case you may consider using something like @testing-library/react-hooks.

Write Tests on Real-World Apps

The tests we wrote are very basic. The app itself is simplified for introduction.

So once you started writing tests on real world projects, you'll find some tricky things to do:

  • Custom renderer
  • Check the URL path in react-router-dom
  • Render page component while giving parameter path
  • Provide context API
  • Mocking functions

You can learn about these topic by switching branch: git checkout rtl-tests.

You can also write tests from scratch: git checkout starter.

This time, todo app includes pages using react-router-dom and top level context by React.createContext. These things require some setups for testing....

Custom Renderer

You definitely will want to create custom render function while building tests.

Because some components only work under the specific context, like <Context.Provider> and <Router />.

Consider below test case.

// src/components/Header.test.tsx
describe('Header', () => {
  it('shows "Log In" without auth', () => {
    const { getByText } = render(<Header />)
    expect(getByText(/Welcome! Please log in/i)).toBeInTheDocument()
  })
})

This test fails. This is because of two reasons:

  • It tries to call useContext in <Header />, but it can't find the <AuthContext /> in the rendering context
  • It tries to call useNavigate in <Header />, but it can't find the <Router /> in the rendering context

This kind of situation is often the case when you are using router or context or other providers in your project, so it's preferable to create a reusable rendering function.

// src/__utils__/testRender.tsx

import { render } from '@testing-library/react'
import { createMemoryHistory, MemoryHistory } from 'history';
import { useEffect } from 'react';
import { Route, Router, Routes } from 'react-router-dom'
import { AuthContext, AuthInfo, useAuthStore } from "store/auth";
import { TodoContext, useTodoStore } from "store/todos";

const Test = ({children, history, user}: {
  children: JSX.Element
  history: MemoryHistory
  user?: AuthInfo
}) => {

  const authStore = useAuthStore()
  const todoStore = useTodoStore(authStore.user)

  useEffect(() => {
    if(user && !authStore.user) {
      authStore.login(user)
    }
  }, [user, authStore])

  return (
    <AuthContext.Provider value={authStore}>
      <TodoContext.Provider value={todoStore}>
        <Router location={history.location} navigator={history}>
          {children}
        </Router>
      </TodoContext.Provider>
    </AuthContext.Provider>
  )
}

interface TestRenderInfo {
  path: string, user?: AuthInfo, paramsPath?: string
}

export const renderWithProvider = (
  ui: JSX.Element, 
  { path = '/', user, paramsPath }: TestRenderInfo
) => {
  const history = createMemoryHistory({ initialEntries: [paramsPath || path]})
  return {
    history,
    ...render(
      <Test user={user} history={history}>
        {ui}
      </Test>
    )
  }
}

/**
 * render single component which needs to be in the routes in the context
 */
export const renderWithProviderAndRoutes = (
  ui: JSX.Element, 
  { path, user, paramsPath }: TestRenderInfo
) => {
  return renderWithProvider(
    <Routes>
      <Route path=''>
        <Route path={path} element={ui} />
        <Route path='*' element={<div />}/>
      </Route>
    </Routes>, 
    {path, user, paramsPath}
  )
}

By this "custom render", we can

  • Control over the initial url path, and check the location by returned history
  • Control over the auth info on AuthContext

You can use this custom renderer every time you need to wrap the component with providers:

// src/components/Header.test.tsx

import '@testing-library/jest-dom'
import { Paths } from 'Pages'
import { renderWithProviderAndRoutes } from '__utils__/testRender'
import { Header } from './Header'
import { Users } from 'store/auth'

describe('Header', () => {
  it('shows "Log In" without auth', () => {
    const {getByText, getByRole} = renderWithProviderAndRoutes(<Header />, { path: Paths.login })
    expect(getByText(/Welcome! Please log in/i)).toBeInTheDocument()
    expect(getByRole('button', {name: 'Log In'})).toBeInTheDocument()
  })
})

Now the test should "PASS".

This is a little bit confusing part about testing with RTL, but once you get used to this strategy, there's few more to consider. Just write tests for each components using this custom renderer.

The possible question, would be why this is needed even though you can render the whole <App /> at the top. Yes, you can still check the element on the dom by rendering <App />, but it's not flexible for the different test cases. You can't control over the initial props or initial path, and testing often requires these 'conditions'. So we need to make a custom render solution in the first place.

You can learn more about custom render on the RTL docs and should give it a read.

Check the URL path

We want to ensure the URL path changes every time users click on the link or button that sends users to another path. This feature is implemented by react-router-dom, and here's one issue: Testing dom supported by render is not an actual browser, how can we assert the path?

This is what we actually solved by custom-renderer. We provided MemoryHistory like so:

const history = createMemoryHistory({ initialEntries: [paramsPath || path]})

The custom renderer returns history object and you can assert the path on it. For example, the case at src/pages/Dashboard.tsx:

describe('View detail', () => {
  it('sends users to detail view when the todo title is clicked', () => {
    const {
      getByText,
      history
    } = renderWithProviderAndRoutes(<Dashboard />, { path: Paths.dashboard, user })  

    userEvent.click(getByText(myTodo.title))

    expect(history.location.pathname).toBe(`${Paths.detail}/${myTodo.id}`)
  })
})

By having control on the <Router /> that wraps the component, we can easily assert the path.

Render page component while giving parameter path

The <Detail /> component requires path parameter. detail/1 resolves to detail/:id. So you need to provide both when rendering it.

// src/pages/Detail.test.tsx
const myTodoPath = `${Paths.detail}/${myTodo.id}`
renderWithProviderAndRoutes(<Detail />, { path: `${Paths.detail}/:id`, user, paramsPath: myTodoPath })

You can check the implementation detail at src/__utils__/testRenderer.tsx.

Mocking functions

Sometimes you need to mock functions where you can't actually call that function.

For example, in the jsdom environment you can't use window.alert since it's not actually a browser, so you are going to check if that function is called.

// src/pages/Login.test.tsx

// mock function
window.alert = jest.fn()

// ...and assert window.alert is called
expect(window.alert).toHaveBeenCalledTimes(1)

This strategy applies for other API calling methods where it's not available, or you want to ignore that function and test the component as "unit", independent from other function implementations.

You might get some idea to mock functions by looking jest docs.

Conclusion

React Testing Library provides a great way to test your React app in the context of users' interaction, as close as to real UX.

By using RTL (as well as jest), you can cover most of the Unit/Integration tests for your project.

But still, it's not tested on the actual browser and it has limitations on some specific scenarios. There you need End-to-End tests.

For e2e tests, you need to install other libraries like Selenium, and that's the topic for the next post.

ย