← Back to DevBytes

Testing Material UI Components: From Unit to E2E Tests

Testing Material UI Components: From Unit to E2E Tests

Material UI (MUI) is one of the most popular React component libraries, offering a rich set of prebuilt, accessible, and themeable components. However, as your application grows, ensuring that these components behave correctly — both in isolation and within the broader user journey — becomes critical. This tutorial walks you through a complete testing strategy for Material UI components, covering unit tests, integration tests, and end-to-end (E2E) tests.

Why Testing MUI Components Matters

Material UI components encapsulate a lot of internal logic: theming, accessibility attributes, keyboard navigation, and responsive behavior. While MUI is well-tested upstream, the way you compose, theme, and wire these components into your application introduces your own risk surface. A robust test suite helps you:

Setting Up the Testing Stack

For most React + MUI projects, the recommended stack is Jest (or Vitest) as the test runner, React Testing Library (RTL) for component-level tests, and Playwright or Cypress for E2E tests. Start by installing the necessary dependencies:

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event @testing-library/dom
npm install --save-dev @playwright/test

Make sure your test setup file imports the custom matchers from @testing-library/jest-dom:

// src/test/setup.js
import '@testing-library/jest-dom';

// Optional: mock matchMedia for components using useMediaQuery
window.matchMedia = window.matchMedia || function (query) {
  return {
    matches: false,
    media: query,
    onchange: null,
    addListener: () => {},
    removeListener: () => {},
    addEventListener: () => {},
    removeEventListener: () => {},
    dispatchEvent: () => false,
  };
};

Unit Testing Individual MUI Components

Unit tests focus on a single component in isolation. The golden rule with React Testing Library is to test behavior, not implementation details. Instead of querying by class names or internal MUI structure, query by accessible roles, labels, and text.

Example: Testing a Button Click Handler

// src/components/SubmitButton.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import SubmitButton from './SubmitButton';

const renderWithTheme = (ui) => {
  const theme = createTheme();
  return render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
};

describe('SubmitButton', () => {
  it('calls onSubmit when clicked', async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn();

    renderWithTheme(<SubmitButton onSubmit={onSubmit} />);

    const button = screen.getByRole('button', { name: /submit/i });
    await user.click(button);

    expect(onSubmit).toHaveBeenCalledTimes(1);
  });

  it('is disabled when loading prop is true', () => {
    renderWithTheme(<SubmitButton loading />);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

Notice that we wrap the component in a ThemeProvider. Many MUI components rely on the theme context, so a small helper like renderWithTheme keeps your tests clean and consistent.

Testing Form Inputs and Validation

MUI text fields render native inputs under the hood, which makes them straightforward to test with RTL. The key is to interact as a real user would: focus, type, blur, and observe.

// src/components/EmailField.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EmailField from './EmailField';

describe('EmailField', () => {
  it('shows an error message for invalid email', async () => {
    const user = userEvent.setup();
    render(<EmailField />);

    const input = screen.getByRole('textbox', { name: /email/i });
    await user.type(input, 'not-an-email');
    await user.tab();

    expect(await screen.findByText(/enter a valid email/i)).toBeInTheDocument();
  });

  it('accepts a valid email without errors', async () => {
    const user = userEvent.setup();
    render(<EmailField />);

    const input = screen.getByRole('textbox', { name: /email/i });
    await user.type(input, 'user@example.com');
    await user.tab();

    expect(screen.queryByText(/enter a valid email/i)).not.toBeInTheDocument();
  });
});

Integration Testing MUI Compositions

Integration tests verify that several components work together. A common scenario is a Dialog containing a form, where focus management, backdrop clicks, and form submission all need to cooperate.

Example: Testing a Dialog Form

// src/components/SettingsDialog.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SettingsDialog from './SettingsDialog';

describe('SettingsDialog', () => {
  it('submits the form with entered values', async () => {
    const user = userEvent.setup();
    const onSave = jest.fn();

    render(<SettingsDialog open onSave={onSave} />);

    expect(screen.getByRole('dialog')).toBeInTheDocument();

    const nameInput = screen.getByLabelText(/display name/i);
    await user.type(nameInput, 'Ada Lovelace');

    const saveButton = screen.getByRole('button', { name: /save/i });
    await user.click(saveButton);

    expect(onSave).toHaveBeenCalledWith({ name: 'Ada Lovelace' });
  });

  it('closes when the cancel button is clicked', async () => {
    const user = userEvent.setup();
    const onClose = jest.fn();

    render(<SettingsDialog open onClose={onClose} />);

    await user.click(screen.getByRole('button', { name: /cancel/i }));
    expect(onClose).toHaveBeenCalled();
  });
});

Because MUI Dialog uses portals, RTL's screen queries still find the content since they query document.body by default. This means you rarely need special handling for portals.

Testing Theme and Styling

When you customize the MUI theme, you may want to verify that certain styles are applied. While you should generally avoid asserting on CSS classes, sometimes checking computed styles is justified — for example, ensuring a custom palette color is applied.

// src/theme.test.jsx
import { render } from '@testing-library/react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import Button from '@mui/material/Button';
import { Box } from '@mui/material';

describe('Custom theme', () => {
  it('applies the primary color to buttons', () => {
    const theme = createTheme({
      palette: { primary: { main: '#ff5722' } },
    });

    const { container } = render(
      <ThemeProvider theme={theme}>
        <Button variant="contained" color="primary">Click</Button>
      </ThemeProvider>
    );

    const button = container.querySelector('button');
    const styles = window.getComputedStyle(button);
    // Note: jsdom may not compute all styles; prefer visual regression for color checks.
    expect(button).toBeInTheDocument();
  });
});

For true visual verification, consider adding a visual regression tool like Chromatic or Playwright screenshot comparisons, since jsdom does not fully compute styles.

End-to-End Testing with Playwright

E2E tests run against a real browser and a running dev server. They validate complete user journeys, including routing, API calls, and MUI's interactive components like Select dropdowns and DataGrids.

Example: E2E Test for a Login Flow

// e2e/login.spec.js
import { test, expect } from '@playwright/test';

test('user can log in with valid credentials', async ({ page }) => {
  await page.goto('http://localhost:3000/login');

  // MUI TextField renders a native input; target by label
  await page.getByLabel(/email/i).fill('user@example.com');
  await page.getByLabel(/password/i).fill('correct-horse-battery-staple');

  await page.getByRole('button', { name: /sign in/i }).click();

  // Wait for navigation to the dashboard
  await expect(page).toHaveURL(/\/dashboard$/);
  await expect(page.getByText(/welcome back/i)).toBeVisible();
});

test('shows error for invalid credentials', async ({ page }) => {
  await page.goto('http://localhost:3000/login');

  await page.getByLabel(/email/i).fill('user@example.com');
  await page.getByLabel(/password/i).fill('wrong-password');
  await page.getByRole('button', { name: /sign in/i }).click();

  await expect(page.getByText(/invalid credentials/i)).toBeVisible();
});

Handling MUI Selects in E2E Tests

MUI Select uses a portal-rendered listbox, which can complicate selectors. Playwright handles this gracefully if you interact by role and option text.

// e2e/settings.spec.js
import { test, expect } from '@playwright/test';

test('user can change language preference', async ({ page }) => {
  await page.goto('http://localhost:3000/settings');

  await page.getByRole('combobox', { name: /language/i }).click();
  await page.getByRole('option', { name: /français/i }).click();

  await expect(page.getByRole('combobox', { name: /language/i })).toHaveText(/français/i);
});

Best Practices

Adding Accessibility Assertions

// src/components/NavMenu.test.jsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import NavMenu from './NavMenu';

expect.extend(toHaveNoViolations);

describe('NavMenu accessibility', () => {
  it('has no a11y violations', async () => {
    const { container } = render(<NavMenu />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

Conclusion

Testing Material UI components effectively means meeting users where they are: interacting with roles, labels, and visible text rather than implementation details. Unit tests with React Testing Library validate individual components and their props, integration tests confirm that complex compositions like dialogs and forms cooperate correctly, and E2E tests with Playwright ensure the full user journey works in a real browser. By combining these layers with accessibility checks and thoughtful theme testing, you build a safety net that lets you upgrade MUI, refactor freely, and ship with confidence — knowing that both the smallest button and the longest user flow are covered.

— Ad —

Google AdSense will appear here after approval

← Back to all articles