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


コメント