MUI IconButtonテスト:基本から実践パターンまで網羅【React Testing Library】

MUI IconButtonテスト:基本から実践パターンまで網羅【React Testing Library】 React

MUI IconButtonをReact Testing Libraryでテストする方法を解説します。

aria-labelによるアクセシビリティ、Tooltipとの組み合わせ、loading状態、トグル動作など、実務で必要なパターンを網羅しています。

動作環境

  • @mui/material v6
  • @testing-library/react v16
  • @testing-library/user-event v14
  • vitest v3

基本的なテスト

ボタンの取得(aria-labelで取得)

IconButtonはテキストを持たないため、aria-labelで識別します。

import IconButton from '@mui/material/IconButton';
import DeleteIcon from '@mui/icons-material/Delete';
import {render, screen} from '@testing-library/react';

test('IconButtonがレンダリングされる', () => {
  render(
    <IconButton aria-label="削除">
      <DeleteIcon />
    </IconButton>
  );
  const button = screen.getByRole('button', {name: '削除'});
  expect(button).toBeInTheDocument();
});

クリックイベント

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

test('クリック時にonClickが呼ばれる', async () => {
  const user = userEvent.setup();
  const onClick = vi.fn();
  
  render(
    <IconButton aria-label="削除" onClick={onClick}>
      <DeleteIcon />
    </IconButton>
  );

  const button = screen.getByRole('button', {name: '削除'});
  await user.click(button);

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


aria-label(アクセシビリティ)

IconButtonはテキストを持たないため、aria-labelの設定が重要です。

aria-labelの設定と取得

test('aria-labelが設定される', () => {
  render(
    <IconButton aria-label="閉じる">
      <CloseIcon />
    </IconButton>
  );
  const button = screen.getByRole('button', {name: '閉じる'});
  expect(button).toHaveAttribute('aria-label', '閉じる');
});

aria-labelがない場合

aria-labelがなくてもgetByRole('button')で取得できますが、nameオプションで絞り込めません。複数のIconButtonがある場合に区別できなくなります。

test('aria-labelがなくてもgetByRoleで取得できる', () => {
  render(
    <IconButton>
      <CloseIcon />
    </IconButton>
  );
  // 取得はできるが、nameで絞り込めない
  const button = screen.getByRole('button');
  expect(button).toBeInTheDocument();
});

アクセシビリティの観点からも、IconButtonには必ずaria-labelを設定しましょう。
araia-label以外だと、data-testidでも識別可能です。

test('data-testidで要素を取得できる', () => {
  render(
    <IconButton aria-label="テスト" data-testid="icon-button">
      <TestIcon />
    </IconButton>,
  );
  const button = screen.getByTestId('icon-button');
  expect(button).toBeInTheDocument();
});

複数のIconButtonをaria-labelで区別

test('複数のIconButtonをaria-labelで区別できる', () => {
  render(
    <div>
      <IconButton aria-label="編集">
        <EditIcon />
      </IconButton>
      <IconButton aria-label="削除">
        <DeleteIcon />
      </IconButton>
    </div>
  );

  const editButton = screen.getByRole('button', {name: '編集'});
  const deleteButton = screen.getByRole('button', {name: '削除'});

  expect(editButton).toBeInTheDocument();
  expect(deleteButton).toBeInTheDocument();
});

disabled状態のテスト

toBeDisabled() / toBeEnabled() を使う

test('disabled=true でボタンが無効化される', () => {
  render(
    <IconButton aria-label="削除" disabled={true}>
      <DeleteIcon />
    </IconButton>
  );
  const button = screen.getByRole('button', {name: '削除'});
  expect(button).toBeDisabled();
});

test('disabled=false でボタンが有効化される', () => {
  render(
    <IconButton aria-label="削除" disabled={false}>
      <DeleteIcon />
    </IconButton>
  );
  const button = screen.getByRole('button', {name: '削除'});
  expect(button).toBeEnabled();
});

loading状態のテスト

v6.4.0からloadingプロパティが追加されました。loading中はボタンが無効化され、ローディングインジケーターが表示されます。

loading中はボタンが無効化される

test('loading=true でボタンが無効化される', () => {
  render(
    <IconButton aria-label="削除" loading={true}>
      <DeleteIcon />
    </IconButton>
  );
  const button = screen.getByRole('button', {name: '削除'});
  expect(button).toBeDisabled();
});

test('loading=false でボタンが有効化される', () => {
  render(
    <IconButton aria-label="削除" loading={false}>
      <DeleteIcon />
    </IconButton>
  );
  const button = screen.getByRole('button', {name: '削除'});
  expect(button).toBeEnabled();
});

loading中はローディングインジケーターが表示される

test('loading中はローディングインジケーターが表示される', () => {
  render(
    <IconButton aria-label="削除" loading={true}>
      <DeleteIcon />
    </IconButton>
  );
  const progress = screen.getByRole('progressbar');
  expect(progress).toBeInTheDocument();
});

test('loading=false ではローディングインジケーターは表示されない', () => {
  render(
    <IconButton aria-label="削除" loading={false}>
      <DeleteIcon />
    </IconButton>
  );
  const progress = screen.queryByRole('progressbar');
  expect(progress).not.toBeInTheDocument();
});

非同期処理のテスト

import {waitFor} from '@testing-library/react';

const AsyncIconButton: React.FC = () => {
  const [loading, setLoading] = useState(false);
  const [result, setResult] = useState<string | null>(null);

  const handleClick = async () => {
    setLoading(true);
    await new Promise(resolve => setTimeout(resolve, 100));
    setResult('完了');
    setLoading(false);
  };

  return (
    <div>
      <IconButton aria-label="実行" loading={loading} onClick={handleClick}>
        <PlayIcon />
      </IconButton>
      {result && <div data-testid="result">{result}</div>}
    </div>
  );
};

test('処理中はボタンが無効化される', async () => {
  const user = userEvent.setup();
  render(<AsyncIconButton />);

  const button = screen.getByRole('button', {name: '実行'});
  expect(button).toBeEnabled();

  await user.click(button);
  expect(button).toBeDisabled();
});

test('処理完了後、ボタンが再度有効化される(findByを使う)', async () => {
  const user = userEvent.setup();
  render(<AsyncIconButton />);

  await user.click(screen.getByRole('button', {name: '実行'}));

  // findByは要素が見つかるまで待機する
  await screen.findByTestId('result');

  expect(screen.getByRole('button', {name: '実行'})).toBeEnabled();
});

test('処理完了後、ボタンが再度有効化される(waitForを使う)', async () => {
  const user = userEvent.setup();
  render(<AsyncIconButton />);

  await user.click(screen.getByRole('button', {name: '実行'}));

  // waitForは条件が満たされるまで待機する
  await waitFor(() => {
    expect(screen.getByRole('button', {name: '実行'})).toBeEnabled();
  });
});

プロパティのテスト

color

test('color="primary" が適用される', () => {
  render(
    <IconButton aria-label="テスト" color="primary">
      <DeleteIcon />
    </IconButton>
  );
  const button = screen.getByRole('button', {name: 'テスト'});
  expect(button).toHaveClass('MuiIconButton-colorPrimary');
});

test('color="secondary" が適用される', () => {
  render(
    <IconButton aria-label="テスト" color="secondary">
      <DeleteIcon />
    </IconButton>
  );
  const button = screen.getByRole('button', {name: 'テスト'});
  expect(button).toHaveClass('MuiIconButton-colorSecondary');
});

test('color="error" が適用される', () => {
  render(
    <IconButton aria-label="テスト" color="error">
      <DeleteIcon />
    </IconButton>
  );
  const button = screen.getByRole('button', {name: 'テスト'});
  expect(button).toHaveClass('MuiIconButton-colorError');
});

size

test('size="small" が適用される', () => {
  render(
    <IconButton aria-label="テスト" size="small">
      <DeleteIcon />
    </IconButton>
  );
  const button = screen.getByRole('button', {name: 'テスト'});
  expect(button).toHaveClass('MuiIconButton-sizeSmall');
});

test('size="medium" が適用される(デフォルト)', () => {
  render(
    <IconButton aria-label="テスト" size="medium">
      <DeleteIcon />
    </IconButton>
  );
  const button = screen.getByRole('button', {name: 'テスト'});
  expect(button).toHaveClass('MuiIconButton-sizeMedium');
});

test('size="large" が適用される', () => {
  render(
    <IconButton aria-label="テスト" size="large">
      <DeleteIcon />
    </IconButton>
  );
  const button = screen.getByRole('button', {name: 'テスト'});
  expect(button).toHaveClass('MuiIconButton-sizeLarge');
});

Tooltipとの組み合わせ

IconButtonはテキストがないため、Tooltipで補足説明を表示することが多いです。

import Tooltip from '@mui/material/Tooltip';

test('ツールチップ付きIconButtonが表示される', () => {
  render(
    <Tooltip title="編集する">
      <IconButton aria-label="編集">
        <EditIcon />
      </IconButton>
    </Tooltip>,
  );
  const button = screen.getByRole('button', {name: '編集'});
  expect(button).toBeInTheDocument();
});

test('ツールチップ付きIconButtonがクリックできる', async () => {
  const user = userEvent.setup();
  const onClick = vi.fn();

  render(
    <Tooltip title="編集する">
      <IconButton aria-label="編集" onClick={onClick}>
        <EditIcon />
      </IconButton>
    </Tooltip>,
  );

  await user.click(screen.getByRole('button', {name: '編集'}));
  expect(onClick).toHaveBeenCalledTimes(1);
});

test('ホバーする前はツールチップが表示されない', () => {
  render(
    <Tooltip title="編集する">
      <IconButton aria-label="編集">
        <EditIcon />
      </IconButton>
    </Tooltip>,
  );

  const tooltip = screen.queryByRole('tooltip', {name: '編集する'});
  expect(tooltip).not.toBeInTheDocument();
});

test('ホバー時にツールチップが表示される', async () => {
  const user = userEvent.setup();

  render(
    <Tooltip title="編集する">
      <IconButton aria-label="編集">
        <EditIcon />
      </IconButton>
    </Tooltip>,
  );

  const button = screen.getByRole('button', {name: '編集'});
  await user.hover(button);

  const tooltip = await screen.findByRole('tooltip', {name: '編集する'});
  expect(tooltip).toBeInTheDocument();
});

トグル動作のテスト

お気に入りボタンなど、クリックで状態が切り替わるパターンです。

const ToggleIconButton: React.FC = () => {
  const [selected, setSelected] = useState(false);

  return (
    <IconButton
      aria-label={selected ? 'お気に入り解除' : 'お気に入り追加'}
      onClick={() => setSelected(!selected)}
      color={selected ? 'primary' : 'default'}
    >
      {selected ? <FavoriteIcon /> : <FavoriteBorderIcon />}
    </IconButton>
  );
};

test('クリックで状態がトグルされる', async () => {
  const user = userEvent.setup();
  render(<ToggleIconButton />);

  // 初期状態
  expect(screen.getByRole('button', {name: 'お気に入り追加'})).toBeInTheDocument();

  // クリックして選択
  await user.click(screen.getByRole('button', {name: 'お気に入り追加'}));

  // 選択状態になる
  expect(screen.getByRole('button', {name: 'お気に入り解除'})).toBeInTheDocument();
});

test('再度クリックで状態が元に戻る', async () => {
  const user = userEvent.setup();
  render(<ToggleIconButton />);

  // クリックして選択
  await user.click(screen.getByRole('button', {name: 'お気に入り追加'}));
  expect(screen.getByRole('button', {name: 'お気に入り解除'})).toBeInTheDocument();

  // 再度クリックして解除
  await user.click(screen.getByRole('button', {name: 'お気に入り解除'}));

  // 未選択状態に戻る
  expect(screen.getByRole('button', {name: 'お気に入り追加'})).toBeInTheDocument();
});

ポイントは、aria-labelを状態に応じて切り替えることです。これによりアクセシビリティを保ちながら、テストでも状態を識別できます。

まとめ

この記事では、MUI IconButtonをReact Testing Libraryでテストする方法を解説しました。

テストパターン一覧

パターン使用するマッチャー/クエリ
ボタンの取得getByRole('button', {name: 'aria-label'})
クリックイベントuserEvent.click() + toHaveBeenCalled()
disabled状態toBeDisabled() / toBeEnabled()
loading状態toBeDisabled() + getByRole('progressbar')
ツールチップuserEvent.hover() + findByRole('tooltip')
トグル状態aria-labelの変化で確認

ポイント

  1. IconButtonはaria-labelで識別する(アクセシビリティ上も必須)
  2. loading状態はtoBeDisabled()とローディングインジケーターで確認
  3. Tooltipはhover()後にfindByRole('tooltip')で取得
  4. トグル動作はaria-labelを状態に応じて切り替えると、テストでも識別しやすい

コメント

タイトルとURLをコピーしました