Testing Select Components with React Testing Library
Updated on · 7 min read|Testing Select components in React can be challenging due to factors such as simulating user interactions, handling asynchronous behavior, variability in implementation, the complexity of nested components, and accessibility concerns. React Testing Library simplifies this process, but it doesn't eliminate all the challenges.
In this post, we'll explore some of the best practices for testing Select components with React Testing Library. We'll begin by writing tests for a wrapper on top of a native HTML select
, and then proceed to test the components using Select components from the popular react-select library. The final code is available on GitHub.
Setting up
Before writing tests, we'll create a sample React app and install the react-select
package.
bashnpx create-react-app test-select
bashnpx create-react-app test-select
bashnpm i react-select
bashnpm i react-select
Testing native HTML select
We'll begin by testing a component that acts as a wrapper for the native HTML select element. Having such a component is quite common, as it abstracts the logic of rendering options.
We'll place all the code inside the components folder. We start by creating a Select.js file there with our first component.
js// src/components/Select.js export const Select = ({ options, ...props }) => { return ( <select {...props}> {options.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> ); };
js// src/components/Select.js export const Select = ({ options, ...props }) => { return ( <select {...props}> {options.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select> ); };
This component accepts an array of options and any additional props. It then renders a select
element with dynamically generated option
elements based on the provided options.
With the component in place, we're ready to write tests for it. We'll create a Select.test.js file for this purpose.
js// src/components/Select.test.js import { render, screen } from "@testing-library/react"; import { Select } from "./Select"; const animals = [ { value: "dog", label: "Dog" }, { value: "cat", label: "Cat" }, { value: "lion", label: "Lion" }, { value: "tiger", label: "Tiger" }, { value: "elephant", label: "Elephant" }, { value: "giraffe", label: "Giraffe" }, { value: "zebra", label: "Zebra" }, { value: "penguin", label: "Penguin" }, { value: "panda", label: "Panda" }, { value: "koala", label: "Koala" }, ]; describe("Native select wrapper", () => { it("should render with default value selected", () => { render(<Select options={animals} defaultValue={"cat"} />); expect(screen.getByRole("combobox")).toHaveValue("cat"); expect(screen.getByRole("option", { name: "Cat" }).selected).toBe(true); }); });
js// src/components/Select.test.js import { render, screen } from "@testing-library/react"; import { Select } from "./Select"; const animals = [ { value: "dog", label: "Dog" }, { value: "cat", label: "Cat" }, { value: "lion", label: "Lion" }, { value: "tiger", label: "Tiger" }, { value: "elephant", label: "Elephant" }, { value: "giraffe", label: "Giraffe" }, { value: "zebra", label: "Zebra" }, { value: "penguin", label: "Penguin" }, { value: "panda", label: "Panda" }, { value: "koala", label: "Koala" }, ]; describe("Native select wrapper", () => { it("should render with default value selected", () => { render(<Select options={animals} defaultValue={"cat"} />); expect(screen.getByRole("combobox")).toHaveValue("cat"); expect(screen.getByRole("option", { name: "Cat" }).selected).toBe(true); }); });
In this test, we verify that the component correctly displays the default value and that the selected value is properly updated on change. Note that we use the combobox
role to match the select
element, as it is its implicit ARIA role when the multiple
attribute is absent. Additionally, we also ensure that the Cat
option has a selected
attribute, as it's the default selection.
If you're wondering why we're using the getByRole
query to get the select
element instead of, for example, getByText
, this article provides a detailed
explanation for such reasoning, along with other tips for writing React
Testing Library tests: Improving React Testing Library
Tests.
Next, we'll test whether selecting a value works correctly.
js// src/components/Select.test.js import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Select } from "./Select"; const animals = [ { value: "dog", label: "Dog" }, { value: "cat", label: "Cat" }, { value: "lion", label: "Lion" }, { value: "tiger", label: "Tiger" }, { value: "elephant", label: "Elephant" }, { value: "giraffe", label: "Giraffe" }, { value: "zebra", label: "Zebra" }, { value: "penguin", label: "Penguin" }, { value: "panda", label: "Panda" }, { value: "koala", label: "Koala" }, ]; function setup(jsx) { return { user: userEvent.setup(), ...render(jsx), }; } describe("Native select wrapper", () => { it("should render with default value selected", () => { setup(<Select options={animals} defaultValue={"cat"} />); expect(screen.getByRole("combobox")).toHaveValue("cat"); expect(screen.getByRole("option", { name: "Cat" }).selected).toBe(true); }); it("should select correct value on change", async () => { const { user } = setup(<Select options={animals} defaultValue={"cat"} />); await user.selectOptions(screen.getByRole("combobox"), "zebra"); expect(screen.getByRole("combobox")).toHaveValue("zebra"); expect(screen.getByRole("option", { name: "Zebra" }).selected).toBe(true); }); });
js// src/components/Select.test.js import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Select } from "./Select"; const animals = [ { value: "dog", label: "Dog" }, { value: "cat", label: "Cat" }, { value: "lion", label: "Lion" }, { value: "tiger", label: "Tiger" }, { value: "elephant", label: "Elephant" }, { value: "giraffe", label: "Giraffe" }, { value: "zebra", label: "Zebra" }, { value: "penguin", label: "Penguin" }, { value: "panda", label: "Panda" }, { value: "koala", label: "Koala" }, ]; function setup(jsx) { return { user: userEvent.setup(), ...render(jsx), }; } describe("Native select wrapper", () => { it("should render with default value selected", () => { setup(<Select options={animals} defaultValue={"cat"} />); expect(screen.getByRole("combobox")).toHaveValue("cat"); expect(screen.getByRole("option", { name: "Cat" }).selected).toBe(true); }); it("should select correct value on change", async () => { const { user } = setup(<Select options={animals} defaultValue={"cat"} />); await user.selectOptions(screen.getByRole("combobox"), "zebra"); expect(screen.getByRole("combobox")).toHaveValue("zebra"); expect(screen.getByRole("option", { name: "Zebra" }).selected).toBe(true); }); });
We use the user-event library to simulate user interactions. First, we write a utility setup
function that renders the test and sets up the userEvent library. Next, we use its built-in selectOptions
method, which works well for native select
elements, and verify that the value changes after selecting an option.
If you want to learn about testing other form components with React Testing Library, you may find this article helpful: Testing React Hook Form With React Testing Library.
Testing react-select with React Testing Library
Testing native select
elements is relatively straightforward. However, they're not as common in React. Developers often need features like asynchronous option loading, custom display options, and the ability to create custom options. One popular library that addresses these use cases is react-select. It's a powerful library that provides React Select components for various situations, making it highly versatile. However, testing such Select components can be challenging due to their custom logic and differences from native elements.
Let's explore how we can test the default Select
and AsyncSelect
components using React Testing Library.
Testing synchronous Select
While we could test the react-select
component in the same way we tested the native select
- as a standalone component, it's easier to do so within a form. This approach simplifies querying for the component and validating data changes.
Let's create this ReactSelectForm
component.
js// src/components/ReactSelectForm.js import Select from "react-select"; export const ReactSelectForm = (selectProps) => { const animals = [ { value: "dog", label: "Dog" }, { value: "cat", label: "Cat" }, { value: "lion", label: "Lion" }, { value: "tiger", label: "Tiger" }, { value: "elephant", label: "Elephant" }, { value: "giraffe", label: "Giraffe" }, { value: "zebra", label: "Zebra" }, { value: "penguin", label: "Penguin" }, { value: "panda", label: "Panda" }, { value: "koala", label: "Koala" }, ]; return ( <form aria-label={"animal form"}> <label htmlFor={"animals"}>Animals</label> <Select name={"animals"} inputId={"animals"} options={animals} {...selectProps} /> </form> ); };
js// src/components/ReactSelectForm.js import Select from "react-select"; export const ReactSelectForm = (selectProps) => { const animals = [ { value: "dog", label: "Dog" }, { value: "cat", label: "Cat" }, { value: "lion", label: "Lion" }, { value: "tiger", label: "Tiger" }, { value: "elephant", label: "Elephant" }, { value: "giraffe", label: "Giraffe" }, { value: "zebra", label: "Zebra" }, { value: "penguin", label: "Penguin" }, { value: "panda", label: "Panda" }, { value: "koala", label: "Koala" }, ]; return ( <form aria-label={"animal form"}> <label htmlFor={"animals"}>Animals</label> <Select name={"animals"} inputId={"animals"} options={animals} {...selectProps} /> </form> ); };
A few things are worth noting here. Firstly, we add a descriptive aria-label
to the form
so we can query it in the tests and check for form values. We could also use data-testid
, but aria-label
provides an accessible name to the form. This gives it an implicit ARIA role attribute of form
, allowing us to use the getByRole("form")
query in the tests.
Another modification is the addition of a label
to the Select
. Since react-select
stores the selected value inside an input
at the base level, we can associate this label with the input by providing an inputId
prop, which matches the label's htmlFor
attribute. This enables us to use the getByLabelText
query to match the component and also improves the field's accessibility. If we had to test the component without a label, in isolation, we would need to use the getByText
query or wrap the Select
in a div
with a data-testid
to match it, which is not ideal.
Lastly, we add the name
prop to the Select
so its value is stored in the form.
Now, we're ready to write the tests.
js// src/components/ReactSelectForm.test.js import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ReactSelectForm } from "./ReactSelectForm"; function setup(jsx) { return { user: userEvent.setup(), ...render(jsx), }; } describe("ReactSelectForm", () => { it("should render with default value selected", () => { setup(<ReactSelectForm defaultValue={{ value: "cat", label: "Cat" }} />); expect(screen.getByText("Cat")).toBeInTheDocument(); expect(screen.getByRole("form")).toHaveFormValues({ animals: "cat" }); }); });
js// src/components/ReactSelectForm.test.js import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ReactSelectForm } from "./ReactSelectForm"; function setup(jsx) { return { user: userEvent.setup(), ...render(jsx), }; } describe("ReactSelectForm", () => { it("should render with default value selected", () => { setup(<ReactSelectForm defaultValue={{ value: "cat", label: "Cat" }} />); expect(screen.getByText("Cat")).toBeInTheDocument(); expect(screen.getByRole("form")).toHaveFormValues({ animals: "cat" }); }); });
As before, we'll start by verifying that the default value is displayed correctly and that it's set on the form.
Next, we'll test whether selecting a new value works properly. This is a tricky part since the selectOptions
helper won't work here. There are a few ways to test this, and we'll explore two of the most common ones.
The first option is to manually open the select element and click the option we want to select, just as a user would interact with it. We can do this by focusing on the select element, simulating the pressing of the arrow down button, and finally clicking on the option to be selected. Alternatively, we can click on the Select
container, which will focus on it and open the menu.
js// src/components/ReactSelectForm.test.js it("should select correct value on change", async () => { const { user } = setup( <ReactSelectForm defaultValue={{ value: "cat", label: "Cat" }} />, ); // Alternative: await user.click(screen.getByLabelText("Animals")); screen.getByLabelText("Animals").focus(); await user.keyboard("[ArrowDown]"); await user.click(screen.getByText("Zebra")); expect(screen.getByRole("form")).toHaveFormValues({ animals: "zebra" }); });
js// src/components/ReactSelectForm.test.js it("should select correct value on change", async () => { const { user } = setup( <ReactSelectForm defaultValue={{ value: "cat", label: "Cat" }} />, ); // Alternative: await user.click(screen.getByLabelText("Animals")); screen.getByLabelText("Animals").focus(); await user.keyboard("[ArrowDown]"); await user.click(screen.getByText("Zebra")); expect(screen.getByRole("form")).toHaveFormValues({ animals: "zebra" }); });
The test passes, however, we notice a lot of warnings in the terminal:
markdownWarning: An update to Select inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...):
markdownWarning: An update to Select inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...):
It turns out that react-select
performs some state setting behind the scenes, which isn't completely resolved when we query the elements. In React Testing Library versions before 13, it was possible to fix this warning by using findBy*
as the first query. However, this no longer works starting from version 13, possibly due to the need for compliance with React 18. There's an open GitHub issue related to this.
To fix the warnings, we could wrap all the event calls in a separate act
function. However, we can simplify this process by using the waitFor
utility helper to wait for all asynchronous calls to be resolved before querying the elements.
js// src/components/ReactSelectForm.test.js import { screen, waitFor } from "@testing-library/react"; it("should select correct value on change", async () => { const { user } = setup( <ReactSelectForm defaultValue={{ value: "cat", label: "Cat" }} />, ); await waitFor(async () => { // await user.click(screen.getByLabelText("Animals")); screen.getByLabelText("Animals").focus(); await user.keyboard("[ArrowDown]"); await user.click(screen.getByText("Zebra")); }); expect(screen.getByRole("form")).toHaveFormValues({ animals: "zebra" }); });
js// src/components/ReactSelectForm.test.js import { screen, waitFor } from "@testing-library/react"; it("should select correct value on change", async () => { const { user } = setup( <ReactSelectForm defaultValue={{ value: "cat", label: "Cat" }} />, ); await waitFor(async () => { // await user.click(screen.getByLabelText("Animals")); screen.getByLabelText("Animals").focus(); await user.keyboard("[ArrowDown]"); await user.click(screen.getByText("Zebra")); }); expect(screen.getByRole("form")).toHaveFormValues({ animals: "zebra" }); });
Using react-select-event
So far, we've been manually selecting the value from our Select component. The good news is that there's a library that simplifies this process - react-select-event.
Let's update our test using this library to see the difference.
js// src/components/ReactSelectForm.test.js import { select } from "react-select-event"; it("should select correct value on change", async () => { setup(<ReactSelectForm defaultValue={{ value: "cat", label: "Cat" }} />); await waitFor(() => select(screen.getByLabelText("Animals"), "Zebra")); expect(screen.getByRole("form")).toHaveFormValues({ animals: "zebra" }); });
js// src/components/ReactSelectForm.test.js import { select } from "react-select-event"; it("should select correct value on change", async () => { setup(<ReactSelectForm defaultValue={{ value: "cat", label: "Cat" }} />); await waitFor(() => select(screen.getByLabelText("Animals"), "Zebra")); expect(screen.getByRole("form")).toHaveFormValues({ animals: "zebra" }); });
This library abstracts away all the manual logic for selecting a value from the Select
component. We also need to wrap the select
call in waitFor
to prevent the act()
warnings. Additionally, we can create a utility function for further abstraction.
jsconst selectOptions = async (input, options) => { await waitFor(() => select(input, options)); };
jsconst selectOptions = async (input, options) => { await waitFor(() => select(input, options)); };
Another benefit of using react-select-event
is that it supports selecting multiple elements using the same API.
js// src/components/ReactSelectForm.test.js it("should work with multi-select", async () => { setup(<ReactSelectForm isMulti />); await selectOptions(screen.getByLabelText("Animals"), ["Zebra", "Lion"]); expect(screen.getByRole("form")).toHaveFormValues({ animals: ["zebra", "lion"], }); });
js// src/components/ReactSelectForm.test.js it("should work with multi-select", async () => { setup(<ReactSelectForm isMulti />); await selectOptions(screen.getByLabelText("Animals"), ["Zebra", "Lion"]); expect(screen.getByRole("form")).toHaveFormValues({ animals: ["zebra", "lion"], }); });
With this, we have the basic tests for our Select components.
Testing asynchronous react-select
Testing async react-select is a bit different compared to the default react-select components due to the need to wait for the select options to load before querying the elements.
To demonstrate this testing approach, let's create a new ReactAsyncSelectForm
component.
js// src/components/ReactAsyncSelectForm.js import Select from "react-select/async"; export const ReactAsyncSelectForm = (selectProps) => { const loadOptions = () => { return new Promise((resolve) => { resolve([ { value: "dog", label: "Dog" }, { value: "cat", label: "Cat" }, { value: "lion", label: "Lion" }, { value: "tiger", label: "Tiger" }, { value: "elephant", label: "Elephant" }, { value: "giraffe", label: "Giraffe" }, { value: "zebra", label: "Zebra" }, { value: "penguin", label: "Penguin" }, { value: "panda", label: "Panda" }, { value: "koala", label: "Koala" }, ]); }); }; return ( <form aria-label={"animal form"}> <label htmlFor={"animals"}>Animals</label> <Select name={"animals"} inputId={"animals"} loadOptions={loadOptions} defaultOptions {...selectProps} /> </form> ); };
js// src/components/ReactAsyncSelectForm.js import Select from "react-select/async"; export const ReactAsyncSelectForm = (selectProps) => { const loadOptions = () => { return new Promise((resolve) => { resolve([ { value: "dog", label: "Dog" }, { value: "cat", label: "Cat" }, { value: "lion", label: "Lion" }, { value: "tiger", label: "Tiger" }, { value: "elephant", label: "Elephant" }, { value: "giraffe", label: "Giraffe" }, { value: "zebra", label: "Zebra" }, { value: "penguin", label: "Penguin" }, { value: "panda", label: "Panda" }, { value: "koala", label: "Koala" }, ]); }); }; return ( <form aria-label={"animal form"}> <label htmlFor={"animals"}>Animals</label> <Select name={"animals"} inputId={"animals"} loadOptions={loadOptions} defaultOptions {...selectProps} /> </form> ); };
The main differences here are that we use the async Select version from react-select
and that we use a Promise to simulate the async loading of the options. We also add the defaultOptions
prop to initiate options fetching when the component loads.
Now, we can add the first test.
js// src/components/ReactAsyncSelectForm.test.js import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ReactAsyncSelectForm } from "./ReactAsyncSelectForm"; function setup(jsx) { return { user: userEvent.setup(), ...render(jsx), }; } describe("ReactAsyncSelectForm", () => { it("should render with default value selected", async () => { setup( <ReactAsyncSelectForm defaultValue={{ value: "cat", label: "Cat" }} />, ); expect(await screen.findByText("Cat")).toBeInTheDocument(); expect(screen.getByRole("form")).toHaveFormValues({ animals: "cat" }); }); });
js// src/components/ReactAsyncSelectForm.test.js import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ReactAsyncSelectForm } from "./ReactAsyncSelectForm"; function setup(jsx) { return { user: userEvent.setup(), ...render(jsx), }; } describe("ReactAsyncSelectForm", () => { it("should render with default value selected", async () => { setup( <ReactAsyncSelectForm defaultValue={{ value: "cat", label: "Cat" }} />, ); expect(await screen.findByText("Cat")).toBeInTheDocument(); expect(screen.getByRole("form")).toHaveFormValues({ animals: "cat" }); }); });
The biggest difference when using async select is that we cannot use synchronous methods (i.e., getBy*
) to query the elements. We need to wait for the async options to load and the component's state to update accordingly. For this, we can either wrap the queries in the waitFor
function or use findBy*
for the first query, as we did in this test. This will wait for the async actions to complete, after which we can query the elements normally.
The rest of the tests will be identical to the synchronous version since we're already using the waitFor
wrapper there.
jsimport { render, screen, waitFor } from "@testing-library/react"; import { select } from "react-select-event"; import userEvent from "@testing-library/user-event"; import { ReactAsyncSelectForm } from "./ReactAsyncSelectForm"; function setup(jsx) { return { user: userEvent.setup(), ...render(jsx), }; } const selectOptions = async (input, options) => { await waitFor(() => select(input, options)); }; describe("ReactAsyncSelectForm", () => { it("should render with default value selected", async () => { setup( <ReactAsyncSelectForm defaultValue={{ value: "cat", label: "Cat" }} />, ); expect(await screen.findByText("Cat")).toBeInTheDocument(); expect(screen.getByRole("form")).toHaveFormValues({ animals: "cat" }); }); it("should select correct value on change", async () => { setup( <ReactAsyncSelectForm defaultValue={{ value: "cat", label: "Cat" }} />, ); await selectOptions(screen.getByLabelText("Animals"), "Zebra"); expect(screen.getByRole("form")).toHaveFormValues({ animals: "zebra" }); }); it("should work with multi-select", async () => { setup(<ReactAsyncSelectForm inputId={"animals"} isMulti />); await selectOptions(screen.getByLabelText("Animals"), ["Zebra", "Lion"]); expect(screen.getByRole("form")).toHaveFormValues({ animals: ["zebra", "lion"], }); }); });
jsimport { render, screen, waitFor } from "@testing-library/react"; import { select } from "react-select-event"; import userEvent from "@testing-library/user-event"; import { ReactAsyncSelectForm } from "./ReactAsyncSelectForm"; function setup(jsx) { return { user: userEvent.setup(), ...render(jsx), }; } const selectOptions = async (input, options) => { await waitFor(() => select(input, options)); }; describe("ReactAsyncSelectForm", () => { it("should render with default value selected", async () => { setup( <ReactAsyncSelectForm defaultValue={{ value: "cat", label: "Cat" }} />, ); expect(await screen.findByText("Cat")).toBeInTheDocument(); expect(screen.getByRole("form")).toHaveFormValues({ animals: "cat" }); }); it("should select correct value on change", async () => { setup( <ReactAsyncSelectForm defaultValue={{ value: "cat", label: "Cat" }} />, ); await selectOptions(screen.getByLabelText("Animals"), "Zebra"); expect(screen.getByRole("form")).toHaveFormValues({ animals: "zebra" }); }); it("should work with multi-select", async () => { setup(<ReactAsyncSelectForm inputId={"animals"} isMulti />); await selectOptions(screen.getByLabelText("Animals"), ["Zebra", "Lion"]); expect(screen.getByRole("form")).toHaveFormValues({ animals: ["zebra", "lion"], }); }); });
Conclusion
Testing Select components in React is an essential part of the development process to ensure their functionality, behavior, and accessibility. By using React Testing Library, we can write effective tests that cover various scenarios and handle complexities associated with both native select elements and custom Select components from libraries like react-select.
By following the steps outlined in this post, you'll be able to effectively test native selects, synchronous and asynchronous react-select components, and maintain best practices for accessibility.