92

I am trying to test the onChange event of a Select component using react-testing-library.

I grab the element using getByTestId which works great, then set the value of the element and then call fireEvent.change(select); but the onChange is never called and the state is never updated.

I have tried using both the select component itself and also by grabbing a reference to the underlying input element but neither works.

Any solutions? Or is this a know issue?

1

18 Answers 18

178

material-ui's select component uses the mouseDown event to trigger the popover menu to appear. If you use fireEvent.mouseDown that should trigger the popover and then you can click your selection within the listbox that appears. see example below.

import React from "react";
import { render, fireEvent, within } from "react-testing-library";
import Select from "@material-ui/core/Select";
import MenuItem from "@material-ui/core/MenuItem";
import Typography from "@material-ui/core/Typography";

it('selects the correct option', () => {
  const {getByRole} = render(
     <>  
       <Select fullWidth value={selectedTab} onChange={onTabChange}>
         <MenuItem value="privacy">Privacy</MenuItem>
         <MenuItem value="my-account">My Account</MenuItem>
       </Select>
       <Typography variant="h1">{/* value set in state */}</Typography>
     </>
  );
  
  fireEvent.mouseDown(getByRole('button'));

  const listbox = within(getByRole('listbox'));

  fireEvent.click(listbox.getByText(/my account/i));

  expect(getByRole('heading')).toHaveTextContent(/my account/i);
});
Sign up to request clarification or add additional context in comments.

8 Comments

If I have multiple <Select> how can i achieve the same ?
@YaserAliPeedikakkal If your Select has a label, you can target the Select by using getByLabelText() for the first click. The element with role="listbox" appears after the click, so unless you've added an element yourself that has role="listbox", the next query will only find the 1 popup from your targeted click. For example, with user-event: userEvent.click(getByLabelText("Select Label")); userEvent.click(within(getByRole("listbox")).getByText("Option Text"));
I wish I saw this answer 3 hours ago... Thank you so much for the straight forward answer about the click event not being used for opening the list box.
One thing that I don't like about this approach is that it is not a practical solution when you have multiple select field in a form, just looking by role='button' is very generic and doesn't differentiate it from other Select fields :(
|
36

This turns out to be super complicated when you are using Material-UI's Select with native={false} (which is the default). This is because the rendered input doesn't even have a <select> HTML element, but is instead a mix of divs, a hidden input, and some svgs. Then, when you click on the select, a presentation layer (kind of like a modal) is displayed with all of your options (which are not <option> HTML elements, by the way), and I believe it's the clicking of one of these options that triggers whatever you passed as the onChange callback to your original Material-UI <Select>

All that to say, if you are willing to use <Select native={true}>, then you'll have actual <select> and <option> HTML elements to work with, and you can fire a change event on the <select> as you would have expected.

Here is test code from a Code Sandbox which works:

import React from "react";
import { render, cleanup, fireEvent } from "react-testing-library";
import Select from "@material-ui/core/Select";

beforeEach(() => {
  jest.resetAllMocks();
});

afterEach(() => {
  cleanup();
});

it("calls onChange if change event fired", () => {
  const mockCallback = jest.fn();
  const { getByTestId } = render(
    <div>
      <Select
        native={true}
        onChange={mockCallback}
        data-testid="my-wrapper"
        defaultValue="1"
      >
        <option value="1">Option 1</option>
        <option value="2">Option 2</option>
        <option value="3">Option 3</option>
      </Select>
    </div>
  );
  const wrapperNode = getByTestId("my-wrapper")
  console.log(wrapperNode)
  // Dig deep to find the actual <select>
  const selectNode = wrapperNode.childNodes[0].childNodes[0];
  fireEvent.change(selectNode, { target: { value: "3" } });
  expect(mockCallback.mock.calls).toHaveLength(1);
});

You'll notice that you have to dig down through the nodes to find where the actual <select> is once Material-UI renders out its <Select>. But once you find it, you can do a fireEvent.change on it.

The CodeSandbox can be found here:

Edit firing change event for material-ui select

2 Comments

Thanks @Alvin Lee this is what we needed. For future reference we set the test id in the inputProps so: inputProps={{ "data-testid": "my-wrapper" }} and then didn't have to get the select node by referencing 2 child nodes.
@RobSanders Glad it worked out for you! That's a helpful tip about setting the test id rather than digging down through the child nodes. Happy coding!
15

Using *ByLabelText()

Component

// demo.js
import * as React from "react";
import Box from "@mui/material/Box";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import Select from "@mui/material/Select";
import Typography from "@mui/material/Typography";

export default function BasicSelect() {
  const [theThing, setTheThing] = React.useState("None");

  const handleChange = (event) => {
    setTheThing(event.target.value);
  };

  return (
    <Box sx={{ minWidth: 120 }}>
      <FormControl fullWidth>
        <InputLabel id="demo-simple-select-label">Choose a thing</InputLabel>
        <Select
          labelId="demo-simple-select-label"
          id="demo-simple-select"
          value={theThing}
          label="Choose a thing"
          onChange={handleChange}
        >
          <MenuItem value={"None"}>None</MenuItem>
          <MenuItem value={"Meerkat"}>Meerkat</MenuItem>
          <MenuItem value={"Marshmallow"}>Marshmallow</MenuItem>
        </Select>
      </FormControl>
      <Box sx={{ padding: 2 }}>
        <Typography>The thing is: {theThing}</Typography>
      </Box>
    </Box>
  );
}

Test

// demo.test.js
import "@testing-library/jest-dom";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Demo from "./demo";

test("When I choose a thing, then the thing changes", async () => {
  render(<Demo />);

  // Confirm default state.
  expect(await screen.findByText(/the thing is: none/i)).toBeInTheDocument();

  // Click on the MUI "select" (as found by the label).
  const selectLabel = /choose a thing/i;
  const selectEl = await screen.findByLabelText(selectLabel);

  expect(selectEl).toBeInTheDocument();

  userEvent.click(selectEl);

  // Locate the corresponding popup (`listbox`) of options.
  const optionsPopupEl = await screen.findByRole("listbox", {
    name: selectLabel
  });

  // Click an option in the popup.
  userEvent.click(within(optionsPopupEl).getByText(/marshmallow/i));

  // Confirm the outcome.
  expect(
    await screen.findByText(/the thing is: marshmallow/i)
  ).toBeInTheDocument();
});

codesandbox Note: Test doesn't run on codesandbox, but does run and pass on local.

2 Comments

It seems the key is using within.
getByLabelText is the only thing that worked for me. data-testids were not working.
10

Using Material UI 5.10.3, this is how to simulate a click on the Select component, and to subsequently grab/verify the item values, and to click one of them to trigger the underlying change event:

import { fireEvent, render, screen, within } from '@testing-library/react';
import { MenuItem, Select } from '@mui/material';

describe('MUI Select Component', () => {
  it('should have correct options an handle change', () => {
    const spyOnSelectChange = jest.fn();

    const { getByTestId } = render(
      <div>
        <Select
          data-testid={'component-under-test'}
          value={''}
          onChange={(evt) => spyOnSelectChange(evt.target.value)}
        >
          <MenuItem value="menu-a">OptionA</MenuItem>
          <MenuItem value="menu-b">OptionB</MenuItem>
        </Select>
      </div>
    );

    const selectCompoEl = getByTestId('component-under-test');

    const button = within(selectCompoEl).getByRole('button');
    fireEvent.mouseDown(button);

    const listbox = within(screen.getByRole('presentation')).getByRole(
      'listbox'
    );

    const options = within(listbox).getAllByRole('option');
    const optionValues = options.map((li) => li.getAttribute('data-value'));

    expect(optionValues).toEqual(['menu-a', 'menu-b']);

    fireEvent.click(options[1]);
    expect(spyOnSelectChange).toHaveBeenCalledWith('menu-b');
  });
});

Also posted here.

6 Comments

The only solution that worked for me. However, the DOM seems to be pretty scrambled at the end (options stay on 'screen').
If you want to use getByText: const selectCompoEl = (screen.getByText('OptionA') as HTMLElement).parentElement;
How to do this if the select component is a separate component? @Gabriel?
What do you mean by a separate component?
It's the only one that worked for me as well. The trick is in the .getByRole('button') 🤔
|
7

Here is a working example for MUI TextField with Select option.

Sandbox: https://codesandbox.io/s/stupefied-chandrasekhar-vq2x0?file=/src/__tests__/TextSelect.test.tsx:0-1668

Textfield:

import { TextField, MenuItem, InputAdornment } from "@material-ui/core";
import { useState } from "react";

export const sampleData = [
  {
    name: "Vat-19",
    value: 1900
  },
  {
    name: "Vat-0",
    value: 0
  },
  {
    name: "Vat-7",
    value: 700
  }
];

export default function TextSelect() {
  const [selected, setSelected] = useState(sampleData[0].name);

  return (
    <TextField
      id="vatSelectTextField"
      select
      label="#ExampleLabel"
      value={selected}
      onChange={(evt) => {
        setSelected(evt.target.value);
      }}
      variant="outlined"
      color="secondary"
      inputProps={{
        id: "vatSelectInput"
      }}
      InputProps={{
        startAdornment: <InputAdornment position="start">%</InputAdornment>
      }}
      fullWidth
    >
      {sampleData.map((vatOption) => (
        <MenuItem key={vatOption.name} value={vatOption.name}>
          {vatOption.name} - {vatOption.value / 100} %
        </MenuItem>
      ))}
    </TextField>
  );
}

Test:

import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { act } from "react-dom/test-utils";
import TextSelect, { sampleData } from "../MuiTextSelect/TextSelect";
import "@testing-library/jest-dom";

describe("Tests TextField Select change", () => {

  test("Changes the selected value", () => {
    const { getAllByRole, getByRole, container } = render(<TextSelect />);

    //CHECK DIV CONTAINER
    let vatSelectTextField = container.querySelector(
      "#vatSelectTextField"
    ) as HTMLDivElement;
    expect(vatSelectTextField).toBeInTheDocument();

    //CHECK DIV CONTAINER
    let vatSelectInput = container.querySelector(
      "#vatSelectInput"
    ) as HTMLInputElement;
    expect(vatSelectInput).toBeInTheDocument();
    expect(vatSelectInput.value).toEqual(sampleData[0].name);

    // OPEN
    fireEvent.mouseDown(vatSelectTextField);

    //CHECKO OPTIONS
    expect(getByRole("listbox")).not.toEqual(null);
    // screen.debug(getByRole("listbox"));

    //CHANGE
    act(() => {
      const options = getAllByRole("option");
      // screen.debug(getAllByRole("option"));
      fireEvent.mouseDown(options[1]);
      options[1].click();
    });

    //CHECK CHANGED
    vatSelectInput = container.querySelector(
      "#vatSelectInput"
    ) as HTMLInputElement;
    expect(vatSelectInput.value).toEqual(sampleData[1].name);
  });
});

/**
 * HAVE A LOOK AT
 *
 *
 * https://github.com/mui-org/material-ui/blob/master/packages/material-ui/src/Select/Select.test.js
 * (ll. 117-121)
 *
 * https://github.com/mui-org/material-ui/blob/master/packages/material-ui/src/TextField/TextField.test.js
 *
 *
 */

Comments

4

ref: typescript

I have verified this works with multiple select fields; however, for this to work be sure to create your MenuItem options like this:

<MenuItem value={value} aria-label={name}>
    {name}
</MenuItem>

Helper to click on a MUI Select.

export const clickSelect = async (element: HTMLElement, value: string) => {
  const button = await within(element).findByRole('button');
  await act(async () => {
    fireEvent.mouseDown(button);
  });

  const option = await screen.findByRole('option', {
    name: new RegExp(value, 'i'),
  });

  await act(async () => {
    fireEvent.click(option);
  });
};

In your test code:

test('test', async () => {
    //...
    const selectElement = screen.getByTestId('my-select-test-id');

    await clickSelect(selectElement, 'Option Name');
    //...
});

1 Comment

Changed first line of the clickSelect function from .findByRole('button') to .findByRole('combobox') and it started to work. Thanks!
2

This is what worked for me while using MUI 5.

      userEvent.click(screen.getByLabelText(/^foo/i));
      userEvent.click(screen.getByRole('option', {name: /^bar/i}));

2 Comments

Didn't work. More context?
const element = screen.getByTestId("country"); await userEvent.click(within(element).getByRole("button")); userEvent.click(screen.getByRole('option', {name: /^USA/i}));
2

With MUI 5.10.5 it is enough if you set your data-testid using inputProps property

<Select ... inputProps={{ 'data-testid': 'YOUR-TEST-ID-NAME' }} />

Later on you can fire event change on this element

...
const selector = comp.getByTestId('YOUR-TEST-ID-NAME');

fireEvent.change(selector, { target: { value: 'Tested value' } });

1 Comment

The trick is to use inputProps instead of attaching data-testid directly to Select. Thanks to the trick, even the expect toHaveValue does work.
2

In my case i used the mouseDown event relative to the MUI Select component to be able to trigger the popup menu

  it('can see devices tree view', async () => {
    const onChangeHandler = jest.fn();
    const { getByRole } = render(
      <Select onChange={onChangeHandler} value="0">
        <MenuItem value="TEST1" />
        <MenuItem value="TEST2" />
        <MenuItem value="TEST3" />
      </Select>,
    );
    // menu items are not visible
    screen.debug(undefined, 100_000);
    fireEvent.mouseDown(getByRole('combobox'));
    // menu items are visible
    screen.debug(undefined, 100_000);
  });

Comments

1

I had some problems with Material UI select element but at the end I found this simple solution.

const handleSubmit = jest.fn()

const renderComponent = (args?: any) => {
  const defaultProps = {
    submitError: '',
    allCurrencies: [{ name: 'CAD' }, { name: 'EUR' }],
    setSubmitError: () => jest.fn(),
    handleSubmit,
    handleClose,
  }

  const props = { ...defaultProps, ...args }
  return render(<NewAccontForm {...props} />)
}

afterEach(cleanup)

// TEST

describe('New Account Form tests', () => {
  it('submits form with corret data', async () => {
    const expectedSubmitData = {
      account_type: 'Personal',
      currency_type: 'EUR',
      name: 'MyAccount',
    }
    const { getByRole, getAllByDisplayValue } = renderComponent()
    const inputs = getAllByDisplayValue('')
    fireEvent.change(inputs[0], { target: { value: 'Personal' } })
    fireEvent.change(inputs[1], { target: { value: 'EUR' } })
    fireEvent.change(inputs[2], { target: { value: 'MyAccount' } })
    userEvent.click(getByRole('button', { name: 'Confirm' }))
    await waitFor(() => {
      expect(handleSubmit).toHaveBeenCalledWith(expectedSubmitData)
      expect(handleSubmit).toHaveBeenCalledTimes(1)
    })
  })
})

Comments

1

For people who have multiple Selects, make sure to add the name prop

          <SelectDropdown
            name="date_range"
            ...
          >
            ...
          </SelectDropdown>
          <SelectDropdown
            name="company"
            ...
          >
            ...
          </SelectDropdown> 
    // date filter
    const date_range_dropdown = getByLabelText('Date Range');
    fireEvent.mouseDown(date_range_dropdown);
    await screen.findByRole('listbox');
    fireEvent.click(
      within(screen.getByRole('listbox')).getByText(/Last 30 Days/)
    );

    // // company filter
    const company_dropdown = getByLabelText('Company');
    fireEvent.mouseDown(company_dropdown);
    fireEvent.click(within(getByRole('listbox')).getByText(/Uber/));


1 Comment

Unable to find role="listbox"
1

This is how I normally do it for MUI v5, make sure you follow accessibility guideline for select component.

render(<Component />)

const selectInput = screen.getByLabelText("your label")
expect(selectInput).toHaveTextContent("expected value")

The difference is instead of using expect(input).toHaveValue like normal input element, since the rendered input of MUI v5 Select is hidden and not linked to rendered label, we try to assert the div that was linked to the rendered label instead. Naming of variables could be better but hope this makes sense.

Comments

1

The way I solve it is by using userEvent to click on the element with the role combobox. Wait for the options rendered on the screen and fire the click event on the selected option.

it("should open the option list", async () => {
  const handleChange = jest.fn();
  const user = userEvent.setup();
  render(
    <Select value="option2" onChange={handleChange}>
      <MenuItem value={0} data-testid="option-option1">option1</MenuItem>
      <MenuItem value={1} data-testid="option-option2">option2</MenuItem>
    </Select>
  );
  user.click(screen.getByRole("combobox"));
  await waitFor(() => {
   expect(screen.getByTestId("option-option1")).toBeTruthy();
   expect(screen.getByTestId("option-option2")).toBeTruthy();
  });
  fireEvent.click(screen.getByTestId("option-option1"));
  expect(handleChange).toHaveBeenCalled();
 });

Comments

0
import * as React from "react";
import ReactDOM from 'react-dom';
import * as TestUtils from 'react-dom/test-utils';
import { } from "mocha";

import Select from "@material-ui/core/Select";
import MenuItem from "@material-ui/core/MenuItem";

let container;

beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);
});

afterEach(() => {
    document.body.removeChild(container);
    container = null;
});

describe("Testing Select component", () => {

    test('start empty, open and select second option', (done) => {

        //render the component
        ReactDOM.render(<Select
            displayEmpty={true}
            value={""}
            onChange={(e) => {
                console.log(e.target.value);
            }}
            disableUnderline
            classes={{
                root: `my-select-component`
            }}
        >
            <MenuItem value={""}>All</MenuItem>
            <MenuItem value={"1"}>1</MenuItem>
            <MenuItem value={"2"}>2</MenuItem>
            <MenuItem value={"3"}>3</MenuItem>
        </Select>, container);

        //open filter
        TestUtils.Simulate.click(container.querySelector('.my-select-component'));

        const secondOption = container.ownerDocument.activeElement.parentElement.querySelectorAll('li')[1];
        TestUtils.Simulate.click(secondOption);

        done();

    });
});

1 Comment

We may go down this route but am trying to avoid using TesUtils.Simulate as it is not a real event and so is not the truest test we could be doing.
0
it('Set min zoom', async () => { 
  const minZoomSelect = await waitForElement( () => component.getByTestId('min-zoom') );
  fireEvent.click(minZoomSelect.childNodes[0]);

  const select14 = await waitForElement( () => component.getByText('14') );
  expect(select14).toBeInTheDocument();

  fireEvent.click(select14);

});

Comments

0

I have done with multiple Select in one page, try this one:

import { render, fireEvent, within } from '@testing-library/react'

 it('Should trigger select-xxx methiod', () => {
  const { getByTestId, getByRole: getByRoleParent } = component
  const element = getByTestId('select-xxx');
  const { getByRole } = within(element)
  const select = getByRole('button')
  fireEvent.mouseDown(select);
  const list = within(getByRoleParent('listbox')) // get list opened by trigger fireEvent
  fireEvent.click(list.getByText(/just try/i)); //select by text
})

Comments

0

I tried the other solutions and they didn't work. In my case I had to use hidden:true in order to find the options.

screen.getAllByRole('option', {hidden:true});

This is my test code:

    <Select onChange={handleChange} data-testid="copyWeekEndingDateControl">
      <MenuItem value="None">None</MenuItem>
      <MenuItem value="2026-01-05">2026-01-05</MenuItem>
    </Select>

it('Checkbox is enabled if the user selects a valid item in select', async () => {
    renderComponent(defaultProps);
    const user = userEvent.setup();
    const copyWeekEndingDateSelect = await screen.findByTestId("copyWeekEndingDateControl");
    const combobox = within(copyWeekEndingDateSelect).getByRole("combobox");
    await user.click(combobox);
    const options = screen.getAllByRole('option', {hidden:true});
    expect(options).toHaveLength(2);
    expect(options[0].getAttribute('data-value')).toBe('None');
    expect(options[1].getAttribute('data-value')).toBe('2026-01-05');
    await user.click(options[1]);
});

Comments

-1

Shortest Solution

import userEvent from '@testing-library/user-event';


await userEvent.click(screen.getByRole('combobox'))

or whatever else that is role or data-testid of your select

USE await AND userEvent

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.