Material UI is an amazing open-source React component library, which follows Google’s Material Design principles. It allows rapid prototyping, components are battle-tested, accessible and customizable.

I like writing tests, it provides a safety-net. Once I make a change, which breaks any functionality, I immediately get feedback.

A few years ago I developed a freight-logistic platform using MUI. Testing went smooth. Till I reached MUI autocomplete.

await user.type(screen.getByRole("textbox", {name: /select a destination/i}), "bud")

And the test immediately fails. Why is that? It looks like there is a text input field.

For the sake of simplicity I boilerplated a simple project with Vite, implemented two form components with different <Autocomplete /> components.

My goal is to show how to use accessible selectors using testing-library and best practices to write efficient component/integration tests.

The anatomy of MUI Autocomplete

When we check the DOM, you see the tree like this (very simpified and extracted):

<div class="...">
<div class="...">
<label class="...">Select a fruit</label>
<div class="">
<input class="..." role="combobox" type="text" value="">
<div class="...">
<button class="...">
<svg class="">
<path ...></path>
</svg>
</button>
</div>
<fieldset class="...">
<legend class="...">
<span>Select a fruit</span>
</legend>
</fieldset>
</div>
</div>
</div>

The key here is: <input … role=“combobox” type=“text” …>

The type of the input field is text, so you can type in it (unlike html e.g.: select), but the role is combobox. Therefore, if you are using a11y-compatible selectors (*byRole), you need to use combobox

Project structure

The App component:

/src/App.tsx
import "./App.css";
import { FruitForm } from "./components/FruitForm";
function App() {
return <FruitForm />;
}
export default App;

The Form component:

src/components/FruitForm.tsx
import { Button } from "@mui/material";
import { submitFruit } from "../actions/fruitActions";
import { useState, type FormEvent } from "react";
import { FruitSelect } from "./FruitSelect";
import type { SelectedFruit } from "../types/fruit";
export const FruitForm = () => {
const [selectedFruit, setSelectedFruit] = useState<SelectedFruit>(null);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
submitFruit(selectedFruit);
};
return (
<form onSubmit={handleSubmit}>
<FruitSelect handleChange={setSelectedFruit} value={selectedFruit} />
<Button type="submit" variant="outlined" sx={{ mt: 2 }}>
Submit fruit
</Button>
</form>
);
};

Handling form submission:

src/actions/fruitActions.ts
import { type SelectedFruit } from "../types/fruit";
export const submitFruit = (fruit: SelectedFruit) => {
// process data (e.g.: send it to server)
console.log(fruit);
};

And most importantly the Autocomplete component wrapper:

src/components/FruitSelect.tsx
import { Autocomplete, TextField } from "@mui/material";
import { FRUITS, type SelectedFruit } from "../types/fruit";
// these are the imports from `../types/fruit` ☝️
// export const FRUITS = ["Apple", "Banana", "Cherry", "Pear"] as const;
//
// export type Fruit = (typeof FRUITS)[number];
//
// export type SelectedFruit = Fruit | null;
export const FruitSelect = ({
value,
handleChange,
}: {
value: SelectedFruit;
handleChange: (value: SelectedFruit) => void;
}) => {
return (
<Autocomplete
options={FRUITS}
value={value}
renderInput={(params) => <TextField {...params} label="Select a fruit" />}
fullWidth
onChange={(_, newValue) => handleChange(newValue)}
/>
);
};

combobox

'src/components/FruitForm.test.tsx
import { render, screen } from "@testing-library/react";
import { FruitForm } from "./FruitForm";
describe("FruitForm", () => {
it("shows a fruit select input", () => {
render(<FruitForm />);
expect(screen.getByRole("combobox", { name: /select a fruit/i }));
});
});

The key here is combobox. Just by visual it might be counterintuitive, you might want to use textbox as role matcher (I did first…).

Let’s move forward and interact with the component:

import { render, screen } from "@testing-library/react";
import { FruitForm } from "./FruitForm";
import userEvent from "@testing-library/user-event";
import * as actions from "../actions/fruitActions";
describe("FruitForm", () => {
it("shows a fruit select input", () => { ... });
it("submits selected fruit", async () => {
// set up userEvent
const user = userEvent.setup();
// create spy, so we can inspect submitFruit
const handleFruitSubmitSpy = vi.spyOn(actions, "submitFruit");
// render component
render(<FruitForm />);
// optional, get input for convenience into dedicated variable, we can use it in the next step
const fruitSelectInput = screen.getByRole("combobox", {
name: /select a fruit/i,
});
// type `app`, because we want to find `Apple`
await user.type(fruitSelectInput, "app");
// find and select option `Apple`,
const appleOption = await screen.findByRole("option", { name: /apple/i });
await user.click(appleOption);
const submitButton = screen.getByRole("button", { name: /submit/i });
// submit form
await user.click(submitButton);
// assert
expect(handleFruitSubmitSpy).toHaveBeenCalledWith("Apple");
});
});

So simple. Use combobox, even if the input field looks like a textbox But wait! Why findByRole?

findByRole

// select the option `Apple`
const appleOption = await screen.findByRole("option", { name: /apple/i });

Because MUI renders the list of options in a portal, it might cause timing issues. So the safest way is to use findByRole selector, which “waits” till the element appears in the DOM.

Let’s go async

How do I test if our Autocomplete loads options asynchronously, e.g. reads it from the DB or 3rd party service? If configured properly, there is a loading spinner and a loading text shown when Autocomplete opens its portal with the options.

The form component:

src/components/FormWithAsyncSelect.tsx
import { Button } from "@mui/material";
import { submitFruitAsyncForm } from "../actions/fruitActions";
import { useState, type FormEvent } from "react";
import type { SelectedFruit } from "../types/fruit";
import { AsyncFruitSelect } from "./AsyncFruitSelect";
export const FormWithAsyncSelect = () => {
const [selectedFruit, setSelectedFruit] = useState<SelectedFruit>(null);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
submitFruitAsyncForm(selectedFruit);
};
return (
<form onSubmit={handleSubmit}>
<AsyncFruitSelect handleChange={setSelectedFruit} value={selectedFruit} />
<Button type="submit" variant="outlined" sx={{ mt: 2 }}>
Submit fruit async
</Button>
</form>
);
};

The Autocomplete component:

src/components/AsyncFruitSelect.tsx
import { Autocomplete, CircularProgress, TextField } from "@mui/material";
import { useState } from "react";
import { FRUITS, type Fruit, type SelectedFruit } from "../types/fruit";
import { sleep } from "../lib/helpers";
type AsyncFruitSelectProps = {
value: SelectedFruit;
handleChange: (newValue: SelectedFruit) => void;
};
export const AsyncFruitSelect = ({
value,
handleChange,
}: AsyncFruitSelectProps) => {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState<readonly Fruit[] | []>([]);
const handleOpen = () => {
setOpen(true);
// if options are already there, do not fetch again
if (options.length > 0) return;
(async () => {
setLoading(true);
// simulate e.g. loading options from DB
await sleep(1000);
setLoading(false);
setOptions(FRUITS);
})();
};
return (
<Autocomplete
fullWidth
open={open}
onOpen={handleOpen}
onClose={() => setOpen(false)}
options={options}
loading={loading}
loadingText="Loading fruits..."
onChange={(_, newValue) => handleChange(newValue)}
value={value}
renderInput={(params) => (
<TextField
{...params}
label="Select fruit async"
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{loading ? (
<CircularProgress color="inherit" size={20} />
) : null}
{params.InputProps.endAdornment}
</>
),
},
}}
/>
)}
/>
);
};

And the tests:

it("shows loading spinner and loading text on first interaction", async () => {
const user = userEvent.setup();
render(<FormWithAsyncSelect />);
const fruitSelectInput = screen.getByRole("combobox", {
name: /select fruit async/i,
});
await user.click(fruitSelectInput);
expect(screen.getByRole("progressbar")).toBeInTheDocument();
expect(screen.getByText(/loading fruits/i)).toBeInTheDocument();
});
it("shows options after loading", async () => {
const user = userEvent.setup();
render(<FormWithAsyncSelect />);
const fruitSelectInput = screen.getByRole("combobox", {
name: /select fruit async/i,
});
await user.click(fruitSelectInput);
await waitForElementToBeRemoved(() => screen.queryByRole("progressbar"));
const appleOption = screen.getByRole("option", { name: /apple/i });
expect(appleOption).toBeInTheDocument();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});

First I assert the loading spinner and loading text is there, in the second I wait till the loading indicators disappear, and I can write my fruit option assertion:

await waitForElementToBeRemoved(() => screen.queryByRole("progressbar"));
const appleOption = screen.getByRole("option", { name: /apple/i });
expect(appleOption).toBeInTheDocument();

You might ask: Why cannot I simply create a variable and use it in the waitForElementToBeRemoved?

// ❌ THIS IS THE WRONG WAY, YOUR TEST WILL FAIL
const progressbar = screen.queryByRole("progressbar")
await waitForElementToBeRemoved(() => progressbar);
// test times out before reaching this point
const appleOption = screen.getByRole("option", { name: /apple/i });
expect(appleOption).toBeInTheDocument();

The callback function of waitForElementToBeRemoved runs repeatedly. When the options are loaded, and there is no progressbar element in the DOM anymore, the progressbar variable would still point to the old object in memory, therefore waitForElementToBeRemoved never resolves, your test times out.

Conclusion

MUI Autocomplete trips up a lot of developers in tests. Not because it’s broken or designed badly, but because it behaves differently from what you’d visually expect.

Three things to remember:

  1. Use combobox, not textbox. The input looks like a text field, but its ARIA role is combobox. Always query by role.
  2. Use findByRole for options. The dropdown renders in a portal, so it appears asynchronously in the DOM. getByRole will miss it — findByRole waits for it.
  3. Use waitForElementToBeRemoved with a callback when testing async loading. Pass a function, not a variable — the callback reruns until the element is gone, so it always reads the current DOM state.

Once these patterns click, testing Autocomplete becomes as straightforward as any other input. The frustration usually comes from fighting the component’s internals instead of working with its accessibility semantics.


For testing I used the following dependencies:

{
"scripts": { ... },
"dependencies": {
...
"@mui/material": "^7.3.9",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
...
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"typescript": "~5.9.3",
"vite": "^8.0.0",
"vitest": "^4.1.0"
}
}