react-testing-library: on change for Material UI Select component not triggered

  • react-testing-library version: 4.1.3
  • react version: 16.4.1
  • node version: 11.10.1
  • npm (or yarn) version: 6.7.0

Relevant code or config:

    const select = await waitForElement(() =>
      getByTestId("select-testid")
    );

    select.value = "testValue";
    fireEvent.change(select);
   <Select
       className={classes.select}
       onChange={this.handleSelectChange}
       value={selectedValue}
       inputProps={{
         id: "select-id",
         "data-testid": "select-id"
       }}
   >

What you did:

I am trying to fire the onChange method of the Material UI Select.

What happened:

onChange won’t fire. Also tried with

select.dispatchEvent(new Event('change', { bubbles: true }));

About this issue

  • Original URL
  • State: closed
  • Created 5 years ago
  • Reactions: 17
  • Comments: 43 (11 by maintainers)

Commits related to this issue

Most upvoted comments

I had the problem that the text I want to select is elsewhere on the page, so I needed to target the ‘dropdown’ directly. Also I wanted it as a separate function, ideally not using the getByText etc. returned by render().

// myTestUtils.js
import {within, waitForElementToBeRemoved} from '@testing-library/react';
import UserEvent from '@testing-library/user-event';

export const selectMaterialUiSelectOption = async (element, optionText) =>
    new Promise(resolve => {
        // The the button that opens the dropdown, which is a sibling of the input
        const selectButton = element.parentNode.querySelector('[role=button]');

        // Open the select dropdown
        UserEvent.click(selectButton);

        // Get the dropdown element. We don't use getByRole() because it includes <select>s too.
        const listbox = document.body.querySelector('ul[role=listbox]');

        // Click the list item
        const listItem = within(listbox).getByText(optionText);
        UserEvent.click(listItem);

        // Wait for the listbox to be removed, so it isn't visible in subsequent calls
        waitForElementToBeRemoved(() => document.body.querySelector('ul[role=listbox]')).then(
            resolve,
        );
    });

Edit: added async, since material-ui can take a tic to remove the listbox.

Thx to have share all of this information it help a lot. Since the last release on Material ui, the previous sibling element of the select input must be trigger by the “mouseDown” method from the fireEvent. onClick do not trigger anymore the option menu.

The same issue occurs for Input components as well.

fireEvent.change(input, { target: { value: "Valid input value" } }); does nothing.

As for the original post, do this:

    const select = await waitForElement(() =>
      getByTestId("select-testid")
    );

    fireEvent.change(select, {target: {value: 'testValue'}});

Good luck!

This by itself did not work. That being said, both of the following do work:

    select.value = "testValue";
    fireEvent.change(select, {target: {value: "testValue"}});

or

    select.value = "testValue";
    fireEvent.change(select);

I personally prefer the second option.

Something to note for future readers: If you are using a Material UI select, none of the above will work. You’ll have to use the native version of the Select element. For future reference: https://stackoverflow.com/questions/55184037/react-testing-library-on-change-for-material-ui-select-component

Similar to what is in codesandbox worked for me:

    const { getByText, getAllByRole, getByTestId, container } = render(
      <Form />,
    );
    const selectNode = getByTestId('select-button-text');
    const selectButton = getAllByRole('button')[0];
    expect(selectButton).not.toBeNull();
    expect(selectNode).not.toBeNull();
    UserEvent.click(selectButton);
    await waitForElement(() => getByText('Custom Text'), { container });
    const itemClickable = getByText('Custom Text');
    UserEvent.click(itemClickable);
    getByTestId('custom1');

Using Material UI 5.10.3:

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');
  });
});

Try this: https://codesandbox.io/embed/94pm1qprmo I have moved the data-testid to the inputProps-prop of the Select of ControlledOpenSelect-component also I have written a unit test for you which clicks on Thirty and ensures a onChange-props got triggered (I added the onChange to your comp).

Not sure, why it doesn’t work in the browser but when you download it and test locally the unit test is successful. I hope it helps!

It would probably be good to have a few examples for testing MaterialUI components because we regularly get reports about difficulties with that library and I don’t use it so I don’t know why it’s so uniquely difficult.

Using Material UI 5.10.3:

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');
  });
});

Thank you very much, I have done my problem with your solution

Hey @weyert , I have updated the codesandbox example and have wrote unit test for it. I would like to thank you for taking a look at this issue.

Here is the link for updated codesandbox: https://codesandbox.io/s/q94q9z1849

Please feel free to edit it or suggest improvements on it.

Thanks for sharing the snippet @davidgilbertson! I couldn’t make it work with my code, but I modified it slightly and the following works for me:

export const selectMaterialUiSelectOption = async (
  container: HTMLElement,
  selectElement: HTMLElement,
  optionText: string,
) => {
  userEvent.click(selectElement);
  const listbox = await within(document.body).findByRole('listbox');
  const listItem = await within(listbox).findByText(optionText);

  userEvent.click(listItem);
  await within(container).findByText(optionText);
};

I had the problem that the text I want to select is elsewhere on the page, so I needed to target the ‘dropdown’ directly. Also I wanted it as a separate function, ideally not using the getByText etc. returned by render().

// myTestUtils.js
import {within, waitForElementToBeRemoved} from '@testing-library/react';
import UserEvent from '@testing-library/user-event';

export const selectMaterialUiSelectOption = async (element, optionText) =>
    new Promise(resolve => {
        // The the button that opens the dropdown, which is a sibling of the input
        const selectButton = element.parentNode.querySelector('[role=button]');

        // Open the select dropdown
        UserEvent.click(selectButton);

        // Get the dropdown element. We don't use getByRole() because it includes <select>s too.
        const listbox = document.body.querySelector('ul[role=listbox]');

        // Click the list item
        const listItem = within(listbox).getByText(optionText);
        UserEvent.click(listItem);

        // Wait for the listbox to be removed, so it isn't visible in subsequent calls
        waitForElementToBeRemoved(() => document.body.querySelector('ul[role=listbox]')).then(
            resolve,
        );
    });

Edit: added async, since material-ui can take a tic to remove the listbox.

If anyone else comes across this awesome example here, but is using a multi-select, one note of caution – multi-selects stay open when selecting options, so the waitForElementToBeRemoved will fail as it doesn’t close the select.

You could easily modify this to accept multiple options (if you are looking to test clicking one to many items), and then at the end prior to the waitForElementToBeRemoved, you can simulate an “escape” keypress on the listbox to close it: userEvent.type(listbox, "{esc}");

This will then properly await on the close of the listbox before proceeding.

For anyone coming across this issue like I did and is having trouble removing the list box from jsdom, Material UI uses ReactTransitionGroup under the hood for fading out the the options. You can disable the ReactTransitionGroup transitions by adding:

import { config } from 'react-transition-group';
config.disabled = true;

which solved the issue for me and removed the listbox immediately on clicking an item.

I had the problem that the text I want to select is elsewhere on the page, so I needed to target the ‘dropdown’ directly. Also I wanted it as a separate function, ideally not using the getByText etc. returned by render().

// myTestUtils.js
import {within, waitForElementToBeRemoved} from '@testing-library/react';
import UserEvent from '@testing-library/user-event';

export const selectMaterialUiSelectOption = async (element, optionText) =>
    new Promise(resolve => {
        // The the button that opens the dropdown, which is a sibling of the input
        const selectButton = element.parentNode.querySelector('[role=button]');

        // Open the select dropdown
        UserEvent.click(selectButton);

        // Get the dropdown element. We don't use getByRole() because it includes <select>s too.
        const listbox = document.body.querySelector('ul[role=listbox]');

        // Click the list item
        const listItem = within(listbox).getByText(optionText);
        UserEvent.click(listItem);

        // Wait for the listbox to be removed, so it isn't visible in subsequent calls
        waitForElementToBeRemoved(() => document.body.querySelector('ul[role=listbox]')).then(
            resolve,
        );
    });

Edit: added async, since material-ui can take a tic to remove the listbox.

Thank you @davidgilbertson, @bjunix and @zzgab! Had to change it to use ‘fireEvent’ instead of ‘userEvent’ to work for me (using material-ui/core 4.11.3):

// myTestUtils.js
import { fireEvent, within } from '@testing-library/react';

const selectMaterialUiSelectOption = async (element, optionText) => {
  // The the button that opens the dropdown, which is a sibling of the input
  const selectButton = element.parentNode.querySelector('[role=button]');

  // Open the select dropdown
  fireEvent.mouseDown(selectButton);

  // Get the dropdown element
  const listbox = await within(document.body).findByRole('listbox');

  // Click the list item
  const listItem = await within(listbox).findByText(optionText);

  fireEvent.click(listItem);
};

@sateesh-p, I had this issue as well. No fix, but I was able to just validate an onChange function callback.

@davidgilbertson, seriously thank you. This was a huge pain in the butt.

will include a <select> on the page without that role, does that sound right?

It does have a role implicitly. However, listbox is caused by a bug that is caused by an outdated dependency. Should be fixed soon.

The solution shared by @weyert looks great to me: https://codesandbox.io/s/94pm1qprmo, thanks! The non-native variation of the select component relies on click/keyboard interactions. You can’t fire a DOM change event. The input element present is only here to support HTML POST forms.

@TidyIQ for the Input component, let’s move to #359.

Thank you @weyert