Testing React. Part 1: @testing-library
Tags: jest, nextjs, testing, react
Published: 2022-11-13 19:33:50


In my [previous article](https://dev.to/petrtcoi/smiena-tiemy-saita-chieriez-css-pieriemiennyie-4f7) 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](https://kentcdodds.com), 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](https://nextjs.org/docs/testing#jest-and-react-testing-library) 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.

```json
"scripts":{
...
"test": "jest --watch",
"coverage": "npx jest --watchAll=false --coverage"
}
```
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.
```js
// /setupTests.js
import '@testing-library/jest-dom';
```
Add it to `jest.config.js`. Otherwise, I would have had to start each test from this line. It looks like this:

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

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.

```js
// ./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__/**"
  ]
}
```

##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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gh5wm4rjwle2v5d71hvb.png)

The tests for them look like this:

```typescript
// /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()
    })

})

```
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.

```typescript
// /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()
    })

})

```
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o96seua1jr0bher4ksed.gif)

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:

```typescript
// /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>
...

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

```typescript
test('The burger disappears after being clicked', async () => {
    await userEvent.click(getBurgerButton())
    expect(getBurgerButton()).not.toBeVisible()
})
```
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:

```typescript
await new Promise((r) => setTimeout(r, 1000));
```
To avoid complicating the component, let's solve this issue with CSS only. The code for the "burger" looks like this:
```typescript
      <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>

```
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:

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

```
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`.

```typescript
    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')
    })
```

###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:
```typescript
// /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)
  })
})
```
The styles that describe the PopupMenu behavior belong only to it, so they are placed in the same folder as the component itself.
```css
/* /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);
}
...
```
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:

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

:root {
  --color-main: #e6e5e5;
  --color-grey: #868687;
  ...
}
[data-theme="light"] {
  --color-main: #000000;
  --color-background: #FFFFFF;
}
```
The theme change is performed by an helper function:

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

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

export { setUiTheme }
```
With `@testing-library`, we cannot check the values of attributes in the <html> 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 <ThemeSwitcher /> switch are output.

```typescript
// /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()
  })
...
})

```
After that, we add a check for the switch itself. To do this, we "mock" the theme switch function.
```typescript
// /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)
  })
})

```
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 <div>.

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.