Testing React. Part 1: @testing-library

petrtcoi

Petr Tcoi

Posted on November 13, 2022

Testing React. Part 1: @testing-library

In my previous article I described how I set up switching the website theme via CSS. Now it's time to talk about how I wrote tests for it. The project uses @testing-library, @playwright, and storybook. In this article, we'll focus on the first library.

Testing with @testing-library

As the main library, I use @testing-library. It's quite convenient and works well for writing code in a TDD style. One of the creators of the library, Kent C. Dodds, advocates for writing tests that don't check the internal implementation of an object, but only evaluate its external behavior (black-box testing). This approach allows for more reliable tests and avoids false positive and false negative results. Therefore, I tried to follow the approaches laid out by the creators of the library. You can learn more about Kent C. Dodds' position in his [article].(https://kentcdodds.com/blog/testing-implementation-details).

Initial setup

The NextJS website provides instructions for installing and configuring the library. After installing the necessary packages and creating the jest.config.js file, you'll have a tool ready to work.

During the process, I made some minor changes to the settings for convenience.

Firstly, I added scripts to the package.json file for quickly running tests.

"scripts":{
...
"test": "jest --watch",
"coverage": "npx jest --watchAll=false --coverage"
}
Enter fullscreen mode Exit fullscreen mode

The first one is for running tests in watch mode. The second one is for analyzing the code coverage with tests.

Secondly, I created a file setupTests.js with content from a single line.

// /setupTests.js
import '@testing-library/jest-dom';
Enter fullscreen mode Exit fullscreen mode

Add it to jest.config.js. Otherwise, I would have had to start each test from this line. It looks like this:

// ./jest.config.js
...
const customJestConfig = {
  moduleDirectories: ['node_modules', '<rootDir>/'],
  testEnvironment: 'jest-environment-jsdom',
  setupFilesAfterEnv: ['./setupTests.js']
}
Enter fullscreen mode Exit fullscreen mode

Also, when running npm run coverage, the script analyzed *.stories.tsx files (there will be a separate article on setting up Storybook) and __snapshots__ files. To avoid this, let's add an explicit record of which files to analyze. Also add a specification of the file format for testing, so that Jest wouldn't try to run e2e tests when only unit-tests are needed.

// ./jest.config.js
...
const customJestConfig = {
  moduleDirectories: ['node_modules', '<rootDir>/'],
  testEnvironment: 'jest-environment-jsdom',
  setupFilesAfterEnv: ['./setupTests.js'],
  testMatch: [
    "**/*.test.*"
  ],
  collectCoverageFrom: [
    "<rootDir>/src/**/*.tsx",
    "!<rootDir>/src/**/*.stories.tsx",
    "!**/__snapshots__/**"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Writing tests

Now everything is ready to start writing the first tests. Test files are placed in the same folder as the component being tested. If it is an integration test, the corresponding file is placed in a folder one level above. And so on up to the root folder src/__tests__.

Basic tests

I consider basic tests to be tests that simply verify the fact that the required components are rendered on the page and have the expected characteristics.

For example, let's consider the <Header/> component located at the top of the website. This component has 3 elements:

  • React logo on the left
  • My name in the center
  • A "burger" to open the menu on the right.

Image description

The tests for them look like this:

// /src/components/Layout/Header/Header

import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

import Header from './Header'

describe('Header', () => {

  beforeEach(() => { render(<Header />) })

    test('It displays a link to the homepage on the page', () => {
        const linkToMainPage = screen.getByRole('link', {name: /react logo/i })
        expect(linkToMainPage).toBeVisible()
        expect(linkToMainPage).toHaveAttribute('href', '/')
    })
    test('It displays logo React', () => {
        const reactLogo = within((screen.getByRole('link', {name: /react logo/i }))).getByTestId("react logo")
        expect(reactLogo).toBeVisible()
    })

    test('It displays title in the header', () => {
        expect(screen.getByRole('heading', { name: /petr tcoi/i, level: 1 })).toBeVisible()
    })

    test('There is a burger button to open the menu', () => {
        expect(screen.getByRole('button', { name: /open menu/i })).toBeVisible()
    })

})

Enter fullscreen mode Exit fullscreen mode

The React logo is checked inside the link to the homepage. Here, I used the getByTestId method to find the logo, which the library authors strongly recommend using only in extreme cases. The first choice should always be getByRole. Using this method encourages writing more appropriate HTML code and makes the test more resistant to future changes to the object being tested.

In the case of the logo, I "cheated" because I couldn't determine a suitable role for the SVG icon. But considered the violation not critical for this situation.

For convenience, I extracted the element search into separate functions.

// /src/components/Layout/Header/Header

describe('Header', () => {

    const getLinkToMainPage = () => screen.getByRole('link', {name: /react logo/i })
    const getNavbarTitle = () => screen.getByRole('heading', { name: 'Petr Tcoi', level: 1 })
    const getBurgerButton = () => screen.getByRole('button', { name: /open menu/i })


    beforeEach(() => { render(<Header />) })

    test('A link to the homepage is displayed on the page', () => {
        expect(getLinkToMainPage()).toBeVisible()
        expect(getLinkToMainPage()).toHaveAttribute('href', '/')
    })
    test('This page includes the React logo', () => {
        const reactLogo = within((getLinkToMainPage())).getByTestId(/react logo/i )
        expect(reactLogo).toBeVisible()
    })

    test('A header with a title is displayed', () => {
        expect(getNavbarTitle()).toBeVisible()
    })

    test('There is a burger icon, which is a button for opening the menu', () => {
        expect(getNavbarTitle()).toBeVisible()
    })

})

Enter fullscreen mode Exit fullscreen mode

Thus, the text of the tests themselves looks more readable and will be easier to maintain. It will be enough to change the function for finding the element at the beginning of the file if something needs to be changed.

Testing the interaction

Let's test the "burger" functionality. To test the interaction with the component, we use the userEvent method. It works in an asynchronous mode and does not require any additional wrappers like act(), as it was before.

Image description

As this file only tests the component as it is, we only check that the button disappears when clicked. Whether the side menu appears when this happens will be tested in an integration test at a higher level.

The "burger" should close the side menu when clicked, but if the user has a very large screen, the side menu may not close and the "burger" may remain on the screen.

In the simplest case, the code for the "burger" would look like this:

// /src/components/Layout/Header/Header
 ....
      <button
        aria-label="Open menu"
        style={{visibility: menuStatus === MenuStatus.open ? false : true }}
        onClick={ () => setMenuStatus(MenuStatus.open) }
        className={styles.burgerbutton}
      >
        <BurgerMenuIcon size={ 25 } className={ "svg__button" } />
      </button>
...

Enter fullscreen mode Exit fullscreen mode

When testing, we would simply ensure that the "burger" is no longer visible:

test('The burger disappears after being clicked', async () => {
    await userEvent.click(getBurgerButton())
    expect(getBurgerButton()).not.toBeVisible()
})
Enter fullscreen mode Exit fullscreen mode

This solution is тще мукн visually appealing: the "burger" disappears immediately after being clicked and the side menu doesn't have time to close it. That is, a delay is needed before the "burger" disappears. This could be achieved by adding additional logic to the component. We could set a setTimeout delay of 1 second after clicking on the "burger" and after that second has elapsed, set the hideBurger property to true.

When testing, we would need to also set a timer after the user action is called. Something like this:

await new Promise((r) => setTimeout(r, 1000));
Enter fullscreen mode Exit fullscreen mode

To avoid complicating the component, let's solve this issue with CSS only. The code for the "burger" looks like this:

      <div
        aria-label="Open menu"
        role="button"
        data-state-hide={ menuStatus === MenuStatus.open ? "true" : "false" }
        onClick={ () => setMenuStatus(MenuStatus.open) }
      >
        <BurgerMenuIcon size={ 25 } className={ "svg__button" } />
      </div>

Enter fullscreen mode Exit fullscreen mode

The attribute "data-state-hide" has the word "state" at the beginning for clarity, so it's clear that it's responsible for a certain behavior change in the element. In this case, it means that the element should be hidden. To achieve this, we add the following styles:

/* /src/assets/styles/states.css */
[data-state-hide="true"] {
  opacity: 0;
  visibility: hidden;
  transition: opacity 1s, visibility 1s;
}

Enter fullscreen mode Exit fullscreen mode

The opacity property is used to make the "burger" disappear gradually. The visibility property is added here to prevent the cursor from changing when it hovers over the button. Now, for testing, it's enough to make sure that the "burger" receives the value true for data-state-hide.

    test('The burger has the attribute data-state-hide = false.', () => {
        expect(getBurgerButton()).toHaveAttribute('data-state-hide', 'false')
    })
    test('The burger has attribute data-state-hide = true after click', async () => {
        await userEvent.click(getBurgerButton())
        expect(getBurgerButton()).toHaveAttribute('data-state-hide', 'true')
    })
Enter fullscreen mode Exit fullscreen mode

Testing component integration

In the Layout folder, we placed a test that checks the integration of the "burger" and the Sidebar components. The hiding/showing of the Sidebar is implemented similarly to hiding the "burger" - through the data-state-** attribute. The test is quite simple and looks like this:

// /src/components/Layout/TogglePopupMenuByBurger.test.tsx
...
describe('Toggle PopupMenu by Burger ', () => {

  const getPopupMenu = () => screen.getByRole('navigation', { name: /popup menu/i })
  const getOpenMenuButton = () => screen.getByRole('button', { name: /open menu/i })
  const getCloseMenuButton = () => screen.getByRole('button', { name: /close menu/i })

  beforeEach(() => render(<Header />))

  test('Popup menu is hidden and has closed status', () => {
    expect(getPopupMenu().dataset.statePopupmenuStatus).toEqual(MenuStatus.closed)
  })

  test('Clicking on the burger changes the menu status to open', async () => {
    await userEvent.click(getOpenMenuButton())
    expect(getPopupMenu().dataset.statePopupmenuStatus).toEqual(MenuStatus.open)
  })

  test('Clicking on the ClosedIcon changes the status to closed', async () => {
    await userEvent.click(getOpenMenuButton())
    await userEvent.click(getCloseMenuButton())
    expect(getPopupMenu().dataset.statePopupmenuStatus).toEqual(MenuStatus.closed)
  })
})
Enter fullscreen mode Exit fullscreen mode

The styles that describe the PopupMenu behavior belong only to it, so they are placed in the same folder as the component itself.

/* /src/components/Layout/PopupMenu/popupmenu.module.css
...
.popupmenu[data-state-popupmenu-status="open"] {
  opacity: 100;
  margin-right: 0px;
  transition: margin var(--basic-duration), background-color var(--basic-duration);
}
...
Enter fullscreen mode Exit fullscreen mode

To test, we only need to check the attribute data-state-popupmenu-status.

Testing Theme Switching

Changing the theme on our website is done by setting the corresponding value of the data-theme attribute in the html tag.

The colors on the site are set using CSS variables and look like this:

/* /src/assets/styles/variables.css */

:root {
  --color-main: #e6e5e5;
  --color-grey: #868687;
  ...
}
[data-theme="light"] {
  --color-main: #000000;
  --color-background: #FFFFFF;
}
Enter fullscreen mode Exit fullscreen mode

The theme change is performed by an helper function:

// /src/assets/utls/setTheme.ts
import { ThemeColorSchema } from "../types/ui.type"

const setUiTheme = (theme: ThemeColorSchema) => {
  document.documentElement.setAttribute("data-theme", theme)
}

export { setUiTheme }
Enter fullscreen mode Exit fullscreen mode

With @testing-library, we cannot check the values of attributes in the tag. For this purpose, we will use the Playwright framework (which I will describe in the next article).

Here we can only check the fact that the theme change function is called and that it does so with the correct parameters.

The test file looks like this. First, we check that all parts of the switch are output.

// /src/components/Layout/PopupMenu/ThemeSwitcher/ThemeSwitcher.tsx

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

import ThemeSwitcher from './ThemeSwitcher'
import { ThemeColorSchema } from '../../../assets/types/ui.type'


const getSwitcher = () => screen.getByRole('switch', { name: /switch theme/i })
const getCheckbox = () => getSwitcher().firstChild as HTMLInputElement

describe('ThemeSwitcher', () => {

  beforeEach(() => render(<ThemeSwitcher />))

  test('Displays element swicth', () => {
    expect(getSwitcher()).toBeVisible()
  })

  test('Displays the description header for the light theme - "Light"', () => {
    expect(screen.getByText(/light/i)).toBeVisible()
  })

  test('Displays the description header for the dark theme - "Dark"', () => {
    expect(screen.getByText(/dark/i)).toBeVisible()
  })

  test('Checkbox in the initial state "checked"', () => {
    expect(getCheckbox()).toBeChecked()
  })
...
})

Enter fullscreen mode Exit fullscreen mode

After that, we add a check for the switch itself. To do this, we "mock" the theme switch function.

// /src/components/Layout/PopupMenu/ThemeSwitcher/ThemeSwitcher.tsx
...
import { setUiTheme } from '../../../assets/utils/setUiTheme'
jest.mock('../../../assets/utils/setUiTheme.ts', () => ({
  ...(jest.requireActual('../../../assets/utils/setUiTheme.ts')),
  setUiTheme: jest.fn()
}))
...

describe('ThemeSwitcher', () => {
....  
  afterEach(() => { jest.clearAllMocks() })
....
  test('Clicking on the checkbox calls the setUiTheme method with the Theme.light argument', async () => {
    userEvent.click(getCheckbox())
    await waitFor(() => { expect(getCheckbox()).not.toBeChecked() })
    expect(setUiTheme).toBeCalledTimes(1)
    expect(setUiTheme).toBeCalledWith(ThemeColorSchema.light)
  })

  test('Two clicks on the checkbox will call setUiTheme twice and the last argument will be Theme.dark', async () => {
    userEvent.click(getCheckbox())
    await waitFor(() => { expect(getCheckbox()).not.toBeChecked() })
    userEvent.click(getCheckbox())
    await waitFor(() => { expect(getCheckbox()).toBeChecked() })
    expect(setUiTheme).toBeCalledTimes(2)
    expect(setUiTheme).toBeCalledWith(ThemeColorSchema.dark)
  })
})

Enter fullscreen mode Exit fullscreen mode

The test is quite simple. Some complexity may arise when checking the checkbox: it works asynchronously, so when working with it, you need to add a waiting period when it switches. - await waitFor(() => { expect(getCheckbox()).not.toBeChecked() }).

Conclusion

The @testing-library allows you to easily create tests and is convenient to use when creating new components.

An important consequence that deserves mention is the requirement to use the getByRole method to search for objects by their roles, rather than the simpler getByTestId method at the initial stage. On the one hand, this requires additional work to determine the appropriate role of the element. On the other hand, it motivates creating a more quality and expressive document layout using the appropriate HTML tags instead of universal

.

A good example here is the use of the naive <button> tag instead of <div role="button">.

The biggest limitation of this type of testing is the render method, which "does not see" what is hidden behind a particular CSS class. Therefore, some site functionality can only be checked by indirect signs. For example, in the case of the "burger", we only check the toggling of the necessary attribute, but we cannot check how everything actually works. The same applies to changing the color scheme of the site.

To solve this problem, we can use end-to-end testing with the Playwright library. The next article will cover it.

💖 💪 🙅 🚩
petrtcoi
Petr Tcoi

Posted on November 13, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related