Develop and Test a UI component using React with Vitest

Develop and Test a UI component using React with Vitest

Featured on Hashnode

What we are going to build

In this post, we are going to build an accordion with React and then test it with Vitest.

This is how it looks like ⬇️

Repos

You can access the code for this example here:

Functionality

We want our accordion to have the following functionality:

  • Its contents appear on clicking on its title or the plus "+" button.

  • When it is open, the "+" sign rotates into an "x" sign.

  • The contents can be anything, e.g. a list of items, a text paragraph, links, etc.

  • When the contents exceed a predefined maximum height, only a portion of them will be displayed, while the remaining content can be accessed using the scroll bar.

Required knowledge

To comfortably follow this tutorial, you should have some basic understanding of JavaScript and React, as well as familiarity with the concept of unit testing.

For quick reference, unit testing refers to the testing methodology that involves examining individual units (an accordion component in this case), to ensure they function correctly in isolation.

⚠️ Note about styling

Please note that although I am using Tailwind to quickly add styling, this is not the main focus here. The code I am showing provides only the functionality of the accordion, this is why the Tailwind classes are omitted in the code here.

Feel free to add your own styles or use the ones provided in this example, by checking the repository link I have provided above.

To install Tailwind, I followed this guide (using PostCSS): https://tailwindcss.com/docs/installation/using-postcss

Building the accordion component

Create a React project with Vite

In this example, I am using Vite to bootstrap a new React project. Vite offers a selection of frameworks and creates only the necessary files you need to get started.

Run the following command and choose React from the available prompts:

If you are using npm:

npm create vite@latest

Or, if you are using yarn:

yarn create vite

(For more info: vitejs.dev/guide)

For this project, I chose React with Typescript.

The accordion component

Let's create a components folder under src. Inside we will create an Accordion.tsx file where we will write the code for our component.

In the Accordion.tsx, we add a React component with the name Accordion, which is also going to be the default export. The accordion needs two props to function: a title of type string and children of type ReactNode. Next to the title we add the "+" symbol, which is the button that expands the accordion.

This is the code ⬇️

import { ReactNode } from "react"

type AccordionProps = {
  title: string
  children: ReactNode
}

const Accordion = ({ title, children }: AccordionProps) => {
  return (
    <div>
      <button>
        <h3>{title}</h3>
        <p>+</p>
      </button>
      <div>{children}</div>
    </div>
  )
}

export default Accordion

Managing the state

With this code we have all the visual "ingredients" we need for an accordion, but no functionality yet. We need our component to open/close when we click either on the "+" button or the title.

To achieve that, we have to find a way to manage the state of the accordion by first storing it somewhere. For this purpose, we are going to use the React hook useState to manage the state of a boolean variable called isOpen. This will initially be false since the accordion is closed by default.

Our code should look like this ⬇️

import { ReactNode, useState } from "react"

type AccordionProps = {
  title: string
  children: ReactNode
}

const Accordion = ({ title, children }: AccordionProps) => {
  // define state - default is false (closed accordion)
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button>
        <h3>{title}</h3>
        <p>+</p>
      </button>
      <div>{children}</div>
    </div>
  )
}

export default Accordion

Updating the state when changes are triggered

The next step is to trigger changes in the accordion state. We should be able to open/close it by clicking either the title or the "+". This is why we have enclosed both in a button element. This will allow us to add the onClick attribute, which is an event handler in React that listens for click events.

We pass the function handleClick in the onClick attribute, which is responsible for toggling the current state: if the accordion is closed, clicking on it will open it and vice versa.

This is what the code looks like ⬇️

import { ReactNode, useState } from "react"

type AccordionProps = {
  title: string
  children: ReactNode
}

const Accordion = ({ title, children }: AccordionProps) => {
  const [isOpen, setIsOpen] = useState(false)

  // toggles the state value
  const handleClick = () => {
    isOpen ? setIsOpen(false) : setIsOpen(true)
  }

  return (
    <div>
      <!-- pass the `handleClick` to the `onClick` attribute -->
      <button onClick={handleClick}>
        <h3>{title}</h3>
        <p>+</p>
      </button>
      <div>{children}</div>
    </div>
  )
}

export default Accordion

Showing/hiding the content

We have effectively managed to toggle the state of the component, but we are still rendering the children in all cases. The last step is to show/hide them depending on the value of the isOpen variable.

We can do that with conditional rendering. This means that an element is displayed based on certain conditions of the state of the application. Our component becomes dynamic and responds to user interactions.

Here, we are going to use the ternary operator (condition ? expressionIfTrue : expressionIfFalse) to conditionally render the children if the accordion is open.

We achieve that with the line of code we have added under the button element ⬇️

import { ReactNode, useState } from "react"

type AccordionProps = {
  title: string
  children: ReactNode
}

const Accodion = ({ title, children }: AccordionProps) => {
  const [isOpen, setIsOpen] = useState(false)

  const handleClick = () => {
    isOpen ? setIsOpen(false) : setIsOpen(true)
  }

  return (
    <div>
      <button onClick={handleClick}>
        <h3>{title}</h3>
        <p>+</p>
      </button>
      <!-- conditionally render the children -->
      {isOpen ? <div>{children}</div> : null}
    </div>
  )
}

export default Accordion

Accessibility

To make our component more accessible with assistive technologies, we can add the aria-expanded, aria-controls, as well as role attributes to the button element.

The aria-expanded is going to hold whatever value the state is, whereas the aria-controls the id of the div that hosts the children node ⬇️

import { ReactNode, useState } from "react"

type AccordionProps = {
  title: string
  children: ReactNode
}

const Accordion = ({ title, children }: AccordionProps) => {
  const [isOpen, setIsOpen] = useState(false)

  const handleClick = () => {
    isOpen ? setIsOpen(false) : setIsOpen(true)
  }

  return (
    <div>
      <!-- aria attributes for the button -->
      <button 
        role="button"
        aria-expanded={isOpen}
        aria-controls="accordion-content"
        onClick={handleClick}>
        <h3>{title}</h3>
        <p>+</p>
      </button>
      <!-- aria attributes for the contents -->
      {isOpen ? <div id="accordion-content" data-testid="children">{children}</div> : null}
    </div>
  )
}

export default Accordion

💡Notice how we also added the data-testid attribute for the children div, which we are going to use later during testing.

Congratulations 🎉 We now have a functional accordion component, which we can use in different parts of our application.

Testing with Vitest

I choose Vitest for unit testing as it is fast, efficient and provides a very good user experience. If you are familiar with Jest, the learning curve is very fast but opposite to Jest, setting up and configuring things is easier (I really like that I can avoid the Babel configuration hell 🥲)

In case you are already using Vite for a project, things become even simpler since they both share the same configuration file vite.config.ts. This increases the reliability of the tests, which are performed in the same environment as the code.

Installation

Depending on whether you are using npm or yarn for your project you can use one of the two following commands to install the packages (dev dependencies) we are going to need during testing:

npm install -D vitest @testing-library/jest-dom @testing-library/react jsdom

Or ...

yarn add -D vitest @testing-library/jest-dom @testing-library/react jsdom

Configure the testing environment

We need to set up a few things before jumping into writing our tests.

First, let's create a folder with the name test under src. Inside this, we add a file with the name setup.ts:

In this file just add the following line ⬇️

import "@testing-library/jest-dom";

This will ensure that we have access to methods like toBeInTheDocument later on, when we are going to check specific behaviours from our dropdown.

The vite.config.ts file

This is the file where we can add settings to configure Vite and Vitest. It should contain the following code:

/// <reference types="vitest" />
/// <reference types="vite/client" />

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: "./src/test/setup.ts",
    css: true,
  },
});

When you bootstrapped the React project, this file should contain everything you see above, except for the test object, which is added here.

This provides the necessary settings to test our code:

  • globals: Setting this to true ensures that we have global access to functions like describe, it, expect, etc. that we are going to use shortly in out tests. Without this setting, we have to import them in each test file separately, like so: import {describe, it} from ‘vitest’

  • environment: Since we are testing a DOM component, we need to perform the tests in an environment that simulates the browser, so we set this to jsdom. The default is node.js.

  • setupFiles: points to the setup.ts file we created earlier

  • css: ensures that the CSS is being parsed correctly (e.g. when we need to check if an element has a hidden attribute). This setting can potentially slow down running your tests a bit.

How to run the tests

We can add this line in the script section of the package.json file ⬇️

"test": "vitest --reporter verbose --environment jsdom"

This enables us to run the tests via npm run test or yarn test later!

Let's test!

Now that our environment is set up, we are ready to write our first test. Let's add an Accordion.test.tsx file in the components folder, like so:

Notice how the test file has the same name as the component we want to test with the addition of the .test.tsx suffix.

In the newly created file, we need to import some functions from the @testing-library/react we installed earlier, as well as the Accordion component we want to test.

import { render, fireEvent, screen } from "@testing-library/react";

import Accordion from "./Accordion";

❗️Remember we have added the setting globals: true in our vite.config.ts file, so we don't have to import any vitest functions here. If we hadn't though, we should have added an extra line at the top of the previous code, importing them in:

import { describe, it, expect } from "vitest";
import { render, fireEvent, screen } from "@testing-library/react";

import Accordion from "./Accordion";

Structure of the testing code

First, we will use the describe function to group our tests together. This will create a test suite for all tests related to the accordion component. Inside describe, we are going to call many it functions (it is an alias for test - both can be used interchangeably), defining individual tests.

import { render, fireEvent, screen } from "@testing-library/react";

import Accordion from "./Accordion";

describe("Accordion component", () => {
  it("describes an individual test", () => {});
}

All subsequent tests are going to be it calls inside the suite determined by the describe function.

Test 1: Accordion should render with a title

In this test, we should first render the Accordion and then check if it has a title. The code for that looks like this ⬇️

import { render, fireEvent, screen } from "@testing-library/react";

import Accordion from "./Accordion";

describe("Accordion component", () => {
  it("renders with title", () => {
    // Given
    const title = "Fruits";
    const accordionElement = (
      <Accordion title={title}>
        <div></div>
      </Accordion>
    );

    // When
    render(accordionElement);

    // Then
    const accordionTitle = screen.getByText(new RegExp(title, "i"));
    expect(accordionTitle).toBeDefined();
  });
}

I use the Given-When-Then pattern to write structured and readable tests. Let's see what happens here in steps:

  1. We store in variables the an <Accordion /> element as well as the title we want to pass into it.

  2. We then render the accordion in the testing environment. We use the render function provided by the @testing-library/react.

  3. We now want to access the element that holds the component's title. To do that we call the screen function which gives us access to querying the virtual document body. We then attach the .getByText() method passing the title we created the accordion with as an argument.

  4. Lastly, we want to make sure that the title is defined, which means that is present in the DOM. The expect is an assertion function that takes the accordionTitle value to be tested as an argument. The toBeDefined is the matcher that checks if the provided value is indeed defined.

⚡️ We can run now our first test by typing the command npm run test or yarn test in our terminal and see it pass.

Let's try to change the last matcher to toBeUndefined and then run the test again. This is the result now ⬇️

The test is failing here. The expect(accordionTitle) part of the test has evaluated to an h3 element, which is obviously not undefined. To make our test pass again, we can just revert this last change.

Test 2: Accordion should be closed by default

Similar to the previous case, in order to test whether the accordion is closed by default, we must render it first. This time, we will pass a list of items as children.

If we examine our implementation, we can see that when the isOpen value is false, the children would be null after rendering. This implies that if we attempt to query any items from the list, we should not be able to access anything until we open the accordion.

Here's how the code looks when incorporating this logic ⬇️

it("should be closed by default", () => {
    // Given
    const accordionElement = (
      <Accordion title="Fruits">
        <ul>
          <li>Strawberries</li>
          <li>Kiwis</li>
          <li>Peaches</li>
        </ul>
      </Accordion>
    );

    // When
    render(accordionElement);

    // Then
    const listElement = screen.queryByText(/Kiwis/i);
    expect(listElement).toBeNull();
  });

As you might have noticed, in this example we use the method queryByText as opposed to the getByText we used in the first test. The difference between the two is subtle and can be a bit confusing. Both will return the matching node of a query if they are successful, but only queryByText will return null (as opposed to throwing an error) if no elements match. This is very useful in the case of this test where we want to assert an element (like Kiwis) that is not present.

Test 3: The accordion should open on clicking its title, "+" symbol or anywhere in between

Once again, we will start by rendering a different accordion component here first. What is different this time is that we have to trigger a click event on the button element that contains both the title and the "+" symbol.

Luckily, @testing-library/react offers the fireEvent function that lets us simulate DOM events. After we select the button with the getByRole method (remember we have passed the role attribute in our button implementation), we will then pass it into the click method of the fireEvent function. This will trigger a change in the accordion state, which will reveal its contents. If everything works correctly, we should be able to query them successfully and expect them to be present in the DOM.

This is the code that performs this logic ⬇️

  it("should open on clicking anywhere between the title and the + (them included)", () => {
    // Given
    const accordionElement = (
      <Accordion title="Tasks">
        <ul>
          <li>Reading</li>
          <li>Cleaning</li>
        </ul>
      </Accordion>
    );

    // When
    render(accordionElement);

    // Then
    const button = screen.getByRole("button");
    fireEvent.click(button);
    expect(screen.getByText(/Reading/i)).toBeDefined();
  });

Test 4: Should be able to scroll if content exceeds a certain height

In some cases, the content of the accordion can be extremely long, which can potentially cause layout issues. For that reason, I have added the Tailwind class max-h-[300px], so that the children container cannot exceed 300px. I have also added scroll behaviour, so we can access the content by scrolling, with the classes overflow-y-auto and overscroll-contain.

This is the expected behaviour in case we have a very long paragraph ⬇️

Testing this can be a bit trickier and there might be a few different ways to do that. Here, we are going to make use of the .getComputedStyle method that returns an object containing the values of all CSS properties of an element, after applying active style sheets and resolving any basic computation those values may contain.

Let's first have a look at the code this time ⬇️

  it("should provide scroll option if content is more than 300px high", () => {
    // Given
    const maxHeight = "300px";
    const accordionElement = (
      <Accordion title="Long paragraph">
        <p>
          Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam porttitor
          convallis odio, a vehicula turpis laoreet non. In hac habitasse platea
          dictumst. Pellentesque ultricies a eros et rhoncus. Nunc varius lacus
          ut felis ultricies blandit. Proin feugiat consectetur est, vitae
          dictum ipsum. Vestibulum blandit, orci id lobortis aliquam, quam lacus
          euismod ex, eget tristique ante diam vel dui. Phasellus in tempor
          erat. Aliquam erat volutpat. In nec tortor velit. Phasellus feugiat
          ante nec nulla tempor, eu aliquam est placerat. Pellentesque quis
          lacus consequat, tristique ex vel, dignissim nunc. Nulla ac est
          consectetur, mattis ante nec, viverra magna. Nulla sit amet mattis
          diam. Sed placerat imperdiet nisl eget ultricies. Nam ut enim id justo
          lacinia rhoncus scelerisque sit amet turpis. Quisque vel euismod elit.
          Donec dapibus elit ipsum, quis rutrum ligula vulputate non.
          Suspendisse a dignissim odio, in molestie tellus. Maecenas purus
          tellus, aliquam at velit vel, efficitur tristique metus. Donec porta
          in lectus non porttitor. Nulla et ultrices velit, quis hendrerit dui.
          Phasellus dui eros, ultrices a nisl quis, facilisis suscipit libero.
          Donec volutpat dapibus leo. Duis eu tempor urna, et sodales nisl.
          Curabitur semper vitae ante a ullamcorper. Etiam a aliquet est, vel
          ornare est.
        </p>
      </Accordion>
    );

    // When
    render(accordionElement);

    // Then
    const titleButton = screen.getByText(/Long paragraph/i);
    fireEvent.click(titleButton);
    const AccordionChildren = screen.getByTestId(/children/i);
    const computedStyle = getComputedStyle(AccordionChildren);
    expect(computedStyle.maxHeight).toBe(maxHeight);
    expect(computedStyle.overflowY).toBe("auto");
  });

In this test, we render an accordion component, the content of which is the long paragraph you can see in the gif above. We fire a click event which reveals its content.

At this point the accordion is open, so we can query its content by using the .getByTestId method (remember, the children have a data-testid attribute). What we expect is the height not to exceed 300px and the scroll bar to appear. We can access the CSS properties of the accordion content by using the .getComputedStyle method. Through the returned object we can evaluate if the properties maxHeight and overflowY have the expected values.

Test 5: Accordion should have proper accessibility attributes

Lastly, let's write a test that checks our component for basic accessibility attributes. The process of rendering the component and selecting the open/close button is the same as in the previous tests.

What we want to check here is if the button has an aria-expanded attribute equal to false by default (since the accordion is closed on the initial state). For this one, we are going to use the .toHaveAttribute method.

Additionally, we want to make sure our button has the aria-controls attribute, that associates it with its content. For this one, we are using the .getAttribute method.

This is how this code looks like ⬇️

  it("has proper accessibility attributes", () => {
    // Given
    const accordionElement = (
      <Accordion title="Tasks">
        <ul>
          <li>Reading</li>
          <li>Cleaning</li>
        </ul>
      </Accordion>
    );

    // When
    render(accordionElement);

    // Then
    // Find the accordion toggle button
    const button = screen.getByRole("button");
    // Check that the button has the correct aria-expanded attribute
    expect(button).toHaveAttribute("aria-expanded", "false");
    // Check that the button has the correct aria-controls attribute
    const contentId = button.getAttribute("aria-controls");
    expect(contentId).toBeTruthy();
  });

Running all the tests

After running all of our tests one final time, we observe that everything passes successfully, ensuring smooth functionality for our accordion 🙂✨

Wrapping up

Well done! You have officially reached the end of this tutorial :) Hopefully, this has helped you gain a better understanding of testing in the context of React components. Please feel free to explore more tests that can ensure the quality and robustness of your apps. As always, you are welcome to leave comments with suggestions, questions, corrections, and any other feedback you find useful.

Thank you for reading!