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

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

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

基本的なクリックイベントから、disabled状態、各種プロパティ、非同期処理中の二重クリック防止まで、実務で必要なパターンを網羅しています。

動作環境

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

基本的なテスト

ボタンの取得

getByRole('button')でボタンを取得します。nameオプションでボタンのテキストを指定できます。

import Button from '@mui/material/Button';
import {render, screen, waitFor} from '@testing-library/react';

test('ボタンがレンダリングされる', () => {
  render(<Button>送信する</Button>);
  const button = screen.getByRole('button', {name: '送信する'});
  expect(button).toBeInTheDocument();
});

data-testidで取得することもできますが、ユーザーが実際に認識する方法(ロールやテキスト)で取得する方が推奨されます。

// 非推奨:data-testidでの取得
render(<Button data-testid="submit-button">送信</Button>);
const button = screen.getByTestId('submit-button');

// 推奨:ロールでの取得
const button = screen.getByRole('button', {name: '送信'});

クリックイベント

userEvent.click()でクリックをシミュレートします。

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

test('クリック時にonClickが呼ばれる', async () => {
  const user = userEvent.setup();
  const onClick = vi.fn();
  render(<Button onClick={onClick}>クリック</Button>);

  const button = screen.getByRole('button', {name: 'クリック'});
  await user.click(button);

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

test('複数回クリックできる', async () => {
  const user = userEvent.setup();
  const onClick = vi.fn();
  render(<Button onClick={onClick}>クリック</Button>);

  const button = screen.getByRole('button', {name: 'クリック'});
  await user.click(button);
  await user.click(button);
  await user.click(button);

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

フォーム送信ボタン

type属性によってフォームの挙動が変わります。

test('type="submit" でフォーム送信がトリガーされる', async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn(e => e.preventDefault());

  render(
    <form onSubmit={onSubmit}>
      <Button type="submit">送信</Button>
    </form>
  );

  await user.click(screen.getByRole('button', {name: '送信'}));
  expect(onSubmit).toHaveBeenCalledTimes(1);
});

test('type="button" はフォーム送信をトリガーしない', async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn(e => e.preventDefault());

  render(
    <form onSubmit={onSubmit}>
      <Button type="button">キャンセル</Button>
    </form>
  );

  await user.click(screen.getByRole('button', {name: 'キャンセル'}));
  expect(onSubmit).not.toHaveBeenCalled();
});

test('type="reset" でフォームをリセットできる', async () => {
  const user = userEvent.setup();

  render(
    <form>
      <input defaultValue="初期値" data-testid="input" />
      <Button type="reset">リセット</Button>
    </form>
  );

  const input = screen.getByTestId('input') as HTMLInputElement;
  await user.clear(input);
  await user.type(input, '変更後');
  expect(input.value).toBe('変更後');

  await user.click(screen.getByRole('button', {name: 'リセット'}));
  expect(input.value).toBe('初期値');
});

disabled状態のテスト

toBeDisabled() / toBeEnabled() を使う

test('disabled=true でボタンが無効化される', () => {
  render(<Button disabled={true}>ボタン</Button>);
  const button = screen.getByRole('button', {name: 'ボタン'});
  expect(button).toBeDisabled();
});

test('disabled=false でボタンが有効化される', () => {
  render(<Button disabled={false}>ボタン</Button>);
  const button = screen.getByRole('button', {name: 'ボタン'});
  expect(button).toBeEnabled();
});

toHaveAttribute(‘disabled’) は避ける

// ❌ これは失敗する
expect(button).toHaveAttribute('disabled', 'true');

// ✅ こちらを使う
expect(button).toBeDisabled();

HTMLのboolean属性は値を持たないため、toBeDisabled()を使いましょう。

disabled時はクリックイベントが発火しない

userEventでは、disabledのボタンをクリックできないので、注意が必要です。

test('disabled時はクリックできない', async () => {
  const user = userEvent.setup();
  const onClick = vi.fn();
  render(<Button disabled onClick={onClick}>ボタン</Button>);

  await user.click(screen.getByRole('button', {name: 'ボタン'})); // ❌ これは失敗する
  expect(onClick).not.toHaveBeenCalled();
});

プロパティのテスト

MUIのButtonはvariant、color、sizeなどのプロパティに応じてクラスが付与されます。
必要があれば、toHaveClassでチェック可能です。

variant

test('variant="contained" が適用される', () => {
  render(<Button variant="contained">Contained</Button>);
  const button = screen.getByRole('button', {name: 'Contained'});
  expect(button).toHaveClass('MuiButton-contained');
});

test('variant="outlined" が適用される', () => {
  render(<Button variant="outlined">Outlined</Button>);
  const button = screen.getByRole('button', {name: 'Outlined'});
  expect(button).toHaveClass('MuiButton-outlined');
});

test('variant="text" が適用される(デフォルト)', () => {
  render(<Button>Text</Button>);
  const button = screen.getByRole('button', {name: 'Text'});
  expect(button).toHaveClass('MuiButton-text');
});

color

test('color="primary" が適用される', () => {
  render(<Button color="primary">Primary</Button>);
  const button = screen.getByRole('button', {name: 'Primary'});
  expect(button).toHaveClass('MuiButton-colorPrimary');
});

test('color="secondary" が適用される', () => {
  render(<Button color="secondary">Secondary</Button>);
  const button = screen.getByRole('button', {name: 'Secondary'});
  expect(button).toHaveClass('MuiButton-colorSecondary');
});

test('color="error" が適用される', () => {
  render(<Button color="error">Error</Button>);
  const button = screen.getByRole('button', {name: 'Error'});
  expect(button).toHaveClass('MuiButton-colorError');
});

size

test('size="small" が適用される', () => {
  render(<Button size="small">Small</Button>);
  const button = screen.getByRole('button', {name: 'Small'});
  expect(button).toHaveClass('MuiButton-sizeSmall');
});

test('size="medium" が適用される(デフォルト)', () => {
  render(<Button size="medium">Medium</Button>);
  const button = screen.getByRole('button', {name: 'Medium'});
  expect(button).toHaveClass('MuiButton-sizeMedium');
});

test('size="large" が適用される', () => {
  render(<Button size="large">Large</Button>);
  const button = screen.getByRole('button', {name: 'Large'});
  expect(button).toHaveClass('MuiButton-sizeLarge');
});

fullWidth

test('fullWidth が適用される', () => {
  render(<Button fullWidth>Full Width</Button>);
  const button = screen.getByRole('button', {name: 'Full Width'});
  expect(button).toHaveClass('MuiButton-fullWidth');
});

startIcon / endIcon

test('startIconが表示される', () => {
  const Icon = () => <span data-testid="start-icon">🚀</span>;
  render(<Button startIcon={<Icon />}>Button</Button>);
  expect(screen.getByTestId('start-icon')).toBeInTheDocument();
});

test('endIconが表示される', () => {
  const Icon = () => <span data-testid="end-icon">→</span>;
  render(<Button endIcon={<Icon />}>Button</Button>);
  expect(screen.getByTestId('end-icon')).toBeInTheDocument();
});

非同期処理のテスト

API呼び出しなど非同期処理中はボタンを無効化し、二重クリックを防ぐのが一般的です。

テスト対象のコンポーネント

const AsyncButton: 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>
      <Button disabled={loading} onClick={handleClick}>
        {loading ? '処理中...' : '実行'}
      </Button>
      {result && <div data-testid="result">{result}</div>}
    </div>
  );
};

テストコード

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

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

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

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

  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(<AsyncButton />);

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

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

test('処理中は二重クリックされない', async () => {
  const user = userEvent.setup();
  const onClick = vi.fn(async () => {
    await new Promise(resolve => setTimeout(resolve, 100));
  });

  const TestButton: React.FC = () => {
    const [loading, setLoading] = useState(false);
    const handleClick = async () => {
      setLoading(true);
      await onClick();
      setLoading(false);
    };
    return (
      <Button disabled={loading} onClick={handleClick}>
        {loading ? '処理中...' : '送信'}
      </Button>
    );
  };

  render(<TestButton />);
  await user.click(screen.getByRole('button', {name: '送信'}));

  // 処理中はdisabledなので再クリック不可
  expect(screen.getByRole('button', {name: '処理中...'})).toBeDisabled();
  expect(onClick).toHaveBeenCalledTimes(1);
});

findByは要素が見つかるまで、waitForは条件が満たされるまで待機します。どちらも非同期処理の完了を待つのに使えます。

まとめ

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

テストパターン一覧

パターン使用するマッチャー/クエリ
ボタンの取得getByRole('button', {name: 'テキスト'})
クリックイベントuserEvent.click() + toHaveBeenCalled()
disabled状態toBeDisabled() / toBeEnabled()
クラスの確認toHaveClass('MuiButton-xxx')
非同期処理の完了待ちfindByTestId() / findByRole()

ポイント

  1. ボタンはgetByRole('button')で取得する
  2. disabled状態はtoBeDisabled()を使う(toHaveAttributeは避ける)
  3. 非同期処理の完了待ちにはfindByクエリを使う
  4. プロパティのテストはMUIのクラス名に依存するため、必要な場合のみ実施する

コメント

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