loading...

Testing a simple React component

ryands17 profile image Ryan Dsouza ・5 min read

In the first part of this series, we will learn how to test components with local state by testing the changes reflected in our UI.

Writing tests is important. And what better way to write them in the same way a user interacts with your app.

@testing-library/react created by Kent C. Dodds is a part of the testing library project that includes test utils for React and other UI libraries.

As Kent puts it, don't test your component state. Rather, test the UI reflected because of the changes in state as that will be the way any user interacts with your app.

This is why I am creating a series, where I will add examples with specific use cases.

The respository with the example below is here.

For the first example, we shall take a simple Checklist component that accepts a list of tasks to be displayed. We can toggle to check whether the task is completed or not and also view the count of the remaining tasks.

Note: I am using TypeScript for this project as it provides a great development experience, but you can create these examples in JavaScript as well by stripping of the types.

This is the task list component made with React Hooks. If you're not familiar with hooks, you can implement the same with class components.

import React, { useState, useMemo } from 'react'

interface Checklist {
  id: number
  text: string
  checked: boolean
}

interface ChecklistProps {
  checklistItems: Checklist[]
}

const Checklist: React.FC<ChecklistProps> = ({ checklistItems = [] }) => {
  let [checklist, updatelist] = useState<Checklist[]>(checklistItems)

  const toggleComplete = (index: number) => {
    let newChecklist = [...checklist]
    newChecklist[index].checked = !newChecklist[index].checked
    updatelist(newChecklist)
  }

  let checkedItems = useMemo(() => checklist.filter(c => c.checked).length, [
    checklist,
  ])

  return (
    <div>
      <ul className="checklist">
        {checklist.map((checkitem, index) => (
          <li key={checkitem.id} className="list-item">
            <input
              type="checkbox"
              id={checkitem.id.toString()}
              checked={checkitem.checked}
              onChange={() => toggleComplete(index)}
            />
            <label htmlFor={checkitem.id.toString()}>{checkitem.text}</label>
          </li>
        ))}
      </ul>
      <p data-testid="checked-items">
        Checked {checkedItems} of {checklist.length} items
      </p>
    </div>
  )
}

Here we are displaying our tasks in an unordered list, and below that, the completed tasks count out of the total. Now the ul tag has a role of list so we will query the list with a specific method given to us by this library.

So the first test we would write would be to test whether our list is rendering properly. And for that we will fetch the list element and assert whether it contains the same amount of tasks that we have passed.

import React from 'react'
import { render } from '@testing-library/react'
import Checklist from './Checklist'

const checklistItems = [
  {
    id: 1,
    text: 'Learn React Testing Library',
    checked: false,
  },
  {
    id: 2,
    text: 'Learn Advanced JS concepts',
    checked: false,
  },
]

test(`has rendered a the items passed correctly`, () => {
  const { getByRole } = render(<Checklist checklistItems={checklistItems} />)
  let ul = getByRole('list')
  expect(ul.children.length).toEqual(2)
})

Here we have a simple test block that has the required imports, our sample task list to be passed via props, and a render method from the testing library. This method will render our entire component and it's children, unlike the shallow method from enzyme which by it's name, does a shallow rendering of the component i.e. it skips rendering the child components.

Now render returns us a handful of methods that help us fetch the required elements present in the component. So we're fetching the ul element with the getByRole method and the role that a ul has by default is list.

At last, we use the matchers that Jest provides and we can check for the length of the list-items to be equal to the tasks list that we are providing. Now, if you run this via npm test or yarn test, your test will pass!

Note: yarn test or npm test runs in watch mode by default so as you save your tests or components, it will automatically run those and you can view the output in your terminal.

Moving on, our second test would be to assert whether the checkboxes are functional and for that we need to interact with the task item, so we need to simulate a click event. And this library has just the method for that: fireEvent.

test(`updates UI of checked item on toggling`, () => {
  const { getByLabelText } = render(
    <Checklist checklistItems={checklistItems} />
  )

  let firstItem = getByLabelText(checklistItems[0].text) as HTMLInputElement
  fireEvent.click(firstItem)
  expect(firstItem.checked).toBeTruthy()

  fireEvent.click(firstItem)
  expect(firstItem.checked).toBeFalsy()
})

Here we are rendering our Checklist component again in our test. And this is important, as it isolates our tests so that the previous test doesn't affect the next.

We fetch the first item in our task list using another utility method getByLabelText as we have used a label that will toggle our task completion, this method will find the input associated with the label.

After fetching the task, we simulate a click event on that item. Then we assert whether the checked property is truthy using the matchers that Jest provides. We then simulate the click event again to check whether the event is working properly and we successfully toggle the checkbox. On saving, if you check your terminal, the second test also passes!

Note: The latest version of @testing-library/react handles cleanup automatically after each test block so you don't need to add any cleanup logic to your tests!.

For our last test, we will verify the checked items count that we have rendered below our task list.

test(`correctly shows the checked item length`, () => {
  const { getByTestId, getByLabelText } = render(
    <Checklist checklistItems={checklistItems} />
  )
  let p = getByTestId('checked-items')

  let firstItem = getByLabelText(checklistItems[0].text) as HTMLInputElement
  fireEvent.click(firstItem)
  expect(p.textContent).toContain('1 of 2')

  let secondItem = getByLabelText(checklistItems[1].text) as HTMLInputElement
  fireEvent.click(secondItem)
  expect(p.textContent).toContain('2 of 2')
})

Note: getByTestId works just like the getElementById method in JavaScript but it matches a data-testid attribute instead of an id.

Again we render our component in the test block, fetch the list item via the getByLabelText method, and we match the text of the p tag to contain the text that we pass using the toContain matcher.

Now, on saving the file, we can see in the terminal that all our tests pass! You will see that on running yarn start or npm start, our app works perfectly. We tested our component just as how we would interact with it. This was all about testing a component with local state.

Thank you for reading.

Posted on by:

ryands17 profile

Ryan Dsouza

@ryands17

A Web Dev and Guitarist who loves JS & TS :) Always exploring new technologies and solution patterns. Have a soft spot for DevOps.

Discussion

markdown guide
 

This is great! One tip: Try to avoid data-testid and *ByTestId queries. Sometimes you can't really avoid it very well (especially for the checked-items <p> tag), but often you can: testing-library.com/docs/guide-whi...

For example, instead of getByTestId('items-list') you could do: getByRole('list') and get rid of the data-testid :)

Thanks for writing this!

 

Thank you! I shall update that in the post.

 

Hi,

That's a nice article.
I just discovered @testing-library and the tests I write seem less brittle than with enzyme.
The extension of matchers for jest (@testing-library/jest-dom) make it feel even more natural to read. You should try it.

A small advice: I know it is not meant to be a production ready code but you should not rely on index in toggleComplete because if you somehow give the user a way to reorder its tasks (say by name), the indexes won't match anymore between the rendered list and the data.
It's a bit more complicated but you should pass id for this.

const toggleComplete = (checklistIdToToggle: number) => {
  const newChecklist = checklist.map(({ id, checked, ...props }) => ({
    id,
    checked: id === checklistIdToToggle ? !checked : checked,
    ...props
  });
  updateChecklist(newChecklist);
}

Thanks for your writing.

 

True that we shouldn't rely on indexes, I will make the changes to make the code production ready. Also as for jest-dom, it's a gratet library and I have used that in the next part of this series!