mcpField
J
jest-react-testing

jest-react-testing

github.com/manutejテスト・品質621.2k 回インストール39 週間前に更新
リポジトリ

jest-react-testing について

React 開発者向けの Jest と React Testing Library のテストガイドです。コンポーネント、フック、統合テストを扱い、ユーザーの操作やアクセシビリティを重視します。

主な機能

  1. 01コンポーネントと操作のテスト
  2. 02アクセシビリティ重視のクエリ
  3. 03非同期処理とタイマーのテスト
  4. 04モジュール、API、依存関係のモック
  5. 05カスタムフックと統合テスト
  6. 06Jest の設定とアサーション

活用シーン

  1. 01フォームとコンポーネント操作の検証
  2. 02非同期データ読み込みのテスト
  3. 03カスタムフックの動作確認
  4. 04コンポーネントツリーの統合テスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/manutej/luxor-claude-marketplace --skill jest-react-testing

インストール後、Claude Code で/jest-react-testingを入力して読み込まれたか確認します。

同梱ファイルEXAMPLES.mdREADME.md

詳細

Jest React テスト

Jest と React Testing Library を使用して React アプリケーションをテストするための包括的なスキルです。基本的なコンポーネントテストから、モック、非同期テスト、カスタムフックのテスト、統合テスト戦略などの高度なパターンまで、幅広く扱います。

このスキルを使う場面

次の場合にこのスキルを使用してください。

  • Jest と React Testing Library で React コンポーネントをテストする
  • React プロジェクト向けに Jest の設定を行う
  • コンポーネント、フック、ユーティリティの単体テストを作成する
  • ユーザー操作とコンポーネントの動作をテストする
  • モジュール、関数、API 呼び出し、外部依存関係をモックする
  • 非同期処理(API 呼び出し、タイマー、プロミス)をテストする
  • カスタム React フックをテストする
  • 複雑なコンポーネントツリーの統合テストを作成する
  • 失敗するテストをデバッグする、またはテストカバレッジを改善する
  • テストのベストプラクティスとパターンに従う

基本概念

テストの理念

React Testing Library は、次の指針に基づいています。

  • 実装ではなくユーザーの振る舞いをテストする:ユーザーがアプリを操作する方法に近いテストを作成します
  • アクセシビリティを優先する:アクセシブルなコンポーネントを促すクエリ(getByRole、getByLabelText)を使用します
  • 実装の詳細のテストを避ける:状態、プロパティ、内部メソッドを直接テストしないでください
  • 保守しやすいテスト:コードのリファクタリング時ではなく、動作が変わったときに失敗するテストを作成します
  • カバレッジより信頼性を重視する:100%のカバレッジではなく、信頼性を高めるテストに注力します
主なテスト概念
  1. クエリ:要素を見つけるメソッド(getBy、queryBy、findBy)
  2. ユーザーイベント:ユーザー操作をシミュレートすること(クリック、入力、選択)
  3. 非同期テスト:非同期処理を含むコンポーネントをテストすること
  4. モック:依存関係を制御されたテストダブルに置き換えること
  5. アサーション:マッチャーを使って期待される結果を検証すること

Jest の設定

Jest の基本設定

jest.config.js(JavaScript プロジェクト):

/** @type {import('jest').Config} */
const config = {
  // Test environment for DOM testing
  testEnvironment: 'jsdom',

  // Setup files after environment
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],

  // Module paths
  moduleDirectories: ['node_modules', 'src'],

  // Transform files with babel-jest
  transform: {
    '^.+\\.(js|jsx)$': 'babel-jest',
  },

  // Module name mapper for static assets and CSS
  moduleNameMapper: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
    '\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
  },

  // Coverage configuration
  collectCoverageFrom: [
    'src/**/*.{js,jsx}',
    '!src/index.js',
    '!src/**/*.test.{js,jsx}',
    '!src/**/__tests__/**',
  ],

  // Coverage thresholds
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

module.exports = config;

jest.config.js(TypeScript プロジェクト):

import type {Config} from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],

  moduleDirectories: ['node_modules', 'src'],

  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },

  moduleNameMapper: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
    '\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.ts',
    '^@/(.*)$': '<rootDir>/src/$1',
  },

  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/index.tsx',
    '!src/**/*.test.{ts,tsx}',
    '!src/**/__tests__/**',
    '!src/**/*.d.ts',
  ],

  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

export default config;
セットアップファイル

src/setupTests.js:

// Add custom jest matchers from jest-dom
import '@testing-library/jest-dom';

// Extend expect with jest-extended matchers (optional)
import * as matchers from 'jest-extended';
expect.extend(matchers);

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
  constructor() {}
  disconnect() {}
  observe() {}
  takeRecords() {
    return [];
  }
  unobserve() {}
};

// Suppress console errors in tests (optional)
const originalError = console.error;
beforeAll(() => {
  console.error = (...args) => {
    if (
      typeof args[0] === 'string' &&
      args[0].includes('Warning: ReactDOM.render')
    ) {
      return;
    }
    originalError.call(console, ...args);
  };
});

afterAll(() => {
  console.error = originalError;
});

// Reset mocks after each test
afterEach(() => {
  jest.clearAllMocks();
});
ファイルのモック

mocks/fileMock.js:

module.exports = 'test-file-stub';

mocks/styleMock.js:

module.exports = {};

React Testing Library のクエリ

クエリの種類

React Testing Library には、次の 3 種類のクエリがあります。

  1. getBy:要素を返すか、エラーをスローします(存在するはずの要素に使用します)
  2. queryBy:要素または null を返します(存在しない場合もある要素に使用します)
  3. findBy:要素に解決されるプロミスを返します(非同期で表示される要素に使用します)
クエリの優先順位

推奨されるクエリの順序(アクセシビリティ重視):

  1. getByRole:最もアクセシブルなクエリ

    getByRole('button', { name: /submit/i })
    getByRole('heading', { level: 1 })
    getByRole('textbox', { name: /username/i })
    
  2. getByLabelText:ラベルが付いたフォームフィールドに使用します

    getByLabelText(/email address/i)
    getByLabelText('Password')
    
  3. getByPlaceholderText:プレースホルダーがある入力欄に使用します

    getByPlaceholderText(/search/i)
    
  4. getByText:テキストのある非インタラクティブ要素に使用します

    getByText(/welcome/i)
    getByText('Error: Invalid credentials')
    
  5. getByDisplayValue:値を持つフォーム要素に使用します

    getByDisplayValue('John Doe')
    
  6. getByAltText:代替テキストのある画像に使用します

    getByAltText(/profile picture/i)
    
  7. getByTitle:title 属性のある要素に使用します

    getByTitle(/close/i)
    
  8. getByTestId:ほかのクエリが使えない場合の最終手段です

    getByTestId('custom-element')
    
クエリのバリエーション
// Single element queries
screen.getByRole('button')      // Throws if not found or multiple found
screen.queryByRole('button')    // Returns null if not found
await screen.findByRole('button') // Async, waits up to 1000ms

// Multiple element queries
screen.getAllByRole('listitem')      // Throws if none found
screen.queryAllByRole('listitem')    // Returns [] if none found
await screen.findAllByRole('listitem') // Async version

コンポーネントのテスト戦略

基本的なコンポーネントテスト
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';

describe('Greeting Component', () => {
  it('renders greeting message', () => {
    render(<Greeting name="Alice" />);

    expect(screen.getByText(/hello, alice/i)).toBeInTheDocument();
  });

  it('renders default greeting when no name provided', () => {
    render(<Greeting />);

    expect(screen.getByText(/hello, guest/i)).toBeInTheDocument();
  });
});
ユーザー操作のテスト
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter Component', () => {
  it('increments counter on button click', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    const button = screen.getByRole('button', { name: /increment/i });
    const count = screen.getByText(/count: 0/i);

    expect(count).toBeInTheDocument();

    await user.click(button);

    expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
  });

  it('decrements counter on button click', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={5} />);

    const decrementBtn = screen.getByRole('button', { name: /decrement/i });

    await user.click(decrementBtn);

    expect(screen.getByText(/count: 4/i)).toBeInTheDocument();
  });
});
フォームのテスト
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm Component', () => {
  it('submits form with username and password', async () => {
    const user = userEvent.setup();
    const handleSubmit = jest.fn();

    render(<LoginForm onSubmit={handleSubmit} />);

    const usernameInput = screen.getByLabelText(/username/i);
    const passwordInput = screen.getByLabelText(/password/i);
    const submitButton = screen.getByRole('button', { name: /submit/i });

    await user.type(usernameInput, 'testuser');
    await user.type(passwordInput, 'password123');
    await user.click(submitButton);

    expect(handleSubmit).toHaveBeenCalledTimes(1);
    expect(handleSubmit).toHaveBeenCalledWith({
      username: 'testuser',
      password: 'password123',
    });
  });

  it('shows validation errors for empty fields', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={jest.fn()} />);

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

    await user.click(submitButton);

    expect(screen.getByText(/username is required/i)).toBeInTheDocument();
    expect(screen.getByText(/password is required/i)).toBeInTheDocument();
  });
});
条件付きレンダリングのテスト
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

describe('UserProfile Component', () => {
  it('shows loading state when loading', () => {
    render(<UserProfile loading={true} />);

    expect(screen.getByText(/loading/i)).toBeInTheDocument();
    expect(screen.queryByRole('heading')).not.toBeInTheDocument();
  });

  it('shows user data when loaded', () => {
    const user = {
      name: 'John Doe',
      email: '[email protected]',
    };

    render(<UserProfile loading={false} user={user} />);

    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
    expect(screen.getByRole('heading', { name: /john doe/i })).toBeInTheDocument();
    expect(screen.getByText(/[email protected]/i)).toBeInTheDocument();
  });

  it('shows error message when error occurs', () => {
    render(<UserProfile loading={false} error="Failed to load user" />);

    expect(screen.getByText(/failed to load user/i)).toBeInTheDocument();
    expect(screen.queryByRole('heading')).not.toBeInTheDocument();
  });
});

モックのパターン

モジュールのモック

自動モック:

// __mocks__/axios.js
export default {
  get: jest.fn(),
  post: jest.fn(),
  put: jest.fn(),
  delete: jest.fn(),
};

テストでの使用方法:

import axios from 'axios';
import { UserService } from './UserService';

jest.mock('axios');

describe('UserService', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('fetches users successfully', async () => {
    const mockUsers = [{ id: 1, name: 'Alice' }];
    axios.get.mockResolvedValue({ data: mockUsers });

    const users = await UserService.getUsers();

    expect(axios.get).toHaveBeenCalledWith('/api/users');
    expect(users).toEqual(mockUsers);
  });

  it('handles fetch error', async () => {
    axios.get.mockRejectedValue(new Error('Network Error'));

    await expect(UserService.getUsers()).rejects.toThrow('Network Error');
  });
});
関数のモック
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

describe('Button Component', () => {
  it('calls onClick handler when clicked', async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();

    render(<Button onClick={handleClick}>Click Me</Button>);

    await user.click(screen.getByRole('button'));

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

  it('calls onClick with event object', async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();

    render(<Button onClick={handleClick}>Click Me</Button>);

    await user.click(screen.getByRole('button'));

    expect(handleClick).toHaveBeenCalledWith(
      expect.objectContaining({
        type: 'click',
      })
    );
  });
});
MSW(Mock Service Worker)を使った API 呼び出しのモック

MSW のセットアップ:

// src/mocks/handlers.js
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
      ])
    );
  }),

  rest.post('/api/users', async (req, res, ctx) => {
    const { name, email } = await req.json();

    return res(
      ctx.status(201),
      ctx.json({
        id: 3,
        name,
        email,
      })
    );
  }),
];

サーバーのセットアップ:

// src/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

setupTests.js での設定:

import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

テストでの使用方法:

import { render, screen, waitFor } from '@testing-library/react';
import { server } from './mocks/server';
import { rest } from 'msw';
import { UserList } from './UserList';

describe('UserList Component', () => {
  it('fetches and displays users', async () => {
    render(<UserList />);

    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.getByText(/alice/i)).toBeInTheDocument();
      expect(screen.getByText(/bob/i)).toBeInTheDocument();
    });
  });

  it('handles server error', async () => {
    server.use(
      rest.get('/api/users', (req, res, ctx) => {
        return res(
          ctx.status(500),
          ctx.json({ message: 'Internal Server Error' })
        );
      })
    );

    render(<UserList />);

    await waitFor(() => {
      expect(screen.getByText(/error loading users/i)).toBeInTheDocument();
    });
  });
});
コンテキストのモック
import { render, screen } from '@testing-library/react';
import { AuthContext } from './AuthContext';
import { ProtectedComponent } from './ProtectedComponent';

const mockAuthContext = (overrides = {}) => ({
  user: { id: 1, name: 'Test User' },
  isAuthenticated: true,
  login: jest.fn(),
  logout: jest.fn(),
  ...overrides,
});

describe('ProtectedComponent', () => {
  it('renders content for authenticated user', () => {
    const contextValue = mockAuthContext();

    render(
      <AuthContext.Provider value={contextValue}>
        <ProtectedComponent />
      </AuthContext.Provider>
    );

    expect(screen.getByText(/welcome, test user/i)).toBeInTheDocument();
  });

  it('renders login prompt for unauthenticated user', () => {
    const contextValue = mockAuthContext({
      user: null,
      isAuthenticated: false,
    });

    render(
      <AuthContext.Provider value={contextValue}>
        <ProtectedComponent />
      </AuthContext.Provider>
    );

    expect(screen.getByText(/please log in/i)).toBeInTheDocument();
  });
});
子コンポーネントのモック
import { render, screen } from '@testing-library/react';
import { ParentComponent } from './ParentComponent';

// Mock the child component
jest.mock('./ChildComponent', () => ({
  ChildComponent: ({ title, onAction }) => (
    <div>
      <h2>{title}</h2>
      <button onClick={onAction}>Mocked Action</button>
    </div>
  ),
}));

describe('ParentComponent', () => {
  it('renders with mocked child', () => {
    render(<ParentComponent />);

    expect(screen.getByText(/mocked action/i)).toBeInTheDocument();
  });
});

非同期テストのパターン

waitFor を使ったテスト
import { render, screen, waitFor } from '@testing-library/react';
import { AsyncComponent } from './AsyncComponent';

describe('AsyncComponent', () => {
  it('loads and displays data', async () => {
    render(<AsyncComponent />);

    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
    });
  });

  it('waits for specific condition', async () => {
    render(<AsyncComponent />);

    await waitFor(
      () => {
        expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
      },
      { timeout: 3000 }
    );
  });
});
findBy クエリを使ったテスト
import { render, screen } from '@testing-library/react';
import { DataFetcher } from './DataFetcher';

describe('DataFetcher Component', () => {
  it('displays fetched data', async () => {
    render(<DataFetcher />);

    // findBy automatically waits for element to appear
    const heading = await screen.findByRole('heading', { name: /data/i });
    expect(heading).toBeInTheDocument();
  });

  it('handles timeout for missing elements', async () => {
    render(<DataFetcher url="/api/missing" />);

    await expect(
      screen.findByText(/success/i, {}, { timeout: 500 })
    ).rejects.toThrow();
  });
});
プロミスのテスト
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AsyncForm } from './AsyncForm';

describe('AsyncForm Component', () => {
  it('submits form and shows success message', async () => {
    const user = userEvent.setup();
    render(<AsyncForm />);

    const input = screen.getByLabelText(/name/i);
    const submitBtn = screen.getByRole('button', { name: /submit/i });

    await user.type(input, 'John Doe');
    await user.click(submitBtn);

    const successMsg = await screen.findByText(/submitted successfully/i);
    expect(successMsg).toBeInTheDocument();
  });

  it('shows error message on failure', async () => {
    const user = userEvent.setup();
    render(<AsyncForm shouldFail={true} />);

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

    const errorMsg = await screen.findByRole('alert');
    expect(errorMsg).toHaveTextContent(/submission failed/i);
  });
});
フェイクタイマーを使ったテスト
import { render, screen, act } from '@testing-library/react';
import { Timer } from './Timer';

describe('Timer Component', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('updates timer every second', () => {
    render(<Timer />);

    expect(screen.getByText(/0 seconds/i)).toBeInTheDocument();

    act(() => {
      jest.advanceTimersByTime(1000);
    });

    expect(screen.getByText(/1 second/i)).toBeInTheDocument();

    act(() => {
      jest.advanceTimersByTime(3000);
    });

    expect(screen.getByText(/4 seconds/i)).toBeInTheDocument();
  });

  it('cleans up timer on unmount', () => {
    const { unmount } = render(<Timer />);

    const clearIntervalSpy = jest.spyOn(global, 'clearInterval');

    unmount();

    expect(clearIntervalSpy).toHaveBeenCalled();
  });
});

カスタムフックのテスト

基本的なフックのテスト
import { renderHook } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter Hook', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());

    expect(result.current.count).toBe(0);
  });

  it('initializes with provided value', () => {
    const { result } = renderHook(() => useCounter(10));

    expect(result.current.count).toBe(10);
  });

  it('increments count', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('decrements count', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
  });
});
プロパティを使うフックのテスト
import { renderHook } from '@testing-library/react';
import { useFetch } from './useFetch';

describe('useFetch Hook', () => {
  it('fetches data for given URL', async () => {
    const { result } = renderHook(() => useFetch('/api/users'));

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toBeDefined();
    expect(result.current.error).toBeNull();
  });

  it('refetches when URL changes', async () => {
    const { result, rerender } = renderHook(
      ({ url }) => useFetch(url),
      { initialProps: { url: '/api/users' } }
    );

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    const firstData = result.current.data;

    rerender({ url: '/api/posts' });

    await waitFor(() => {
      expect(result.current.data).not.toEqual(firstData);
    });
  });
});
コンテキストを使うフックのテスト
import { renderHook } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { useTheme } from './useTheme';

describe('useTheme Hook', () => {
  const wrapper = ({ children }) => (
    <ThemeProvider initialTheme="light">
      {children}
    </ThemeProvider>
  );

  it('returns current theme', () => {
    const { result } = renderHook(() => useTheme(), { wrapper });

    expect(result.current.theme).toBe('light');
  });

  it('toggles theme', () => {
    const { result } = renderHook(() => useTheme(), { wrapper });

    act(() => {
      result.current.toggleTheme();
    });

    expect(result.current.theme).toBe('dark');
  });
});
非同期フックのテスト
import { renderHook, waitFor } from '@testing-library/react';
import { useAsyncData } from './useAsyncData';

describe('useAsyncData Hook', () => {
  it('loads data asynchronously', async () => {
    const { result } = renderHook(() => useAsyncData('/api/data'));

    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBeNull();

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toBeDefined();
  });

  it('handles errors', async () => {
    const { result } = renderHook(() => useAsyncData('/api/error'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.error).toBeDefined();
    expect(result.current.data).toBeNull();
  });
});

統合テストのパターン

コンポーネント統合のテスト
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from './App';

describe('App Integration', () => {
  it('navigates between pages', async () => {
    const user = userEvent.setup();
    render(<App />);

    expect(screen.getByText(/home page/i)).toBeInTheDocument();

    const aboutLink = screen.getByRole('link', { name: /about/i });
    await user.click(aboutLink);

    expect(screen.getByText(/about page/i)).toBeInTheDocument();
  });

  it('completes full user flow', async () => {
    const user = userEvent.setup();
    render(<App />);

    // Navigate to signup
    await user.click(screen.getByRole('link', { name: /sign up/i }));

    // Fill out form
    await user.type(screen.getByLabelText(/email/i), '[email protected]');
    await user.type(screen.getByLabelText(/password/i), 'password123');

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

    // Verify success
    expect(await screen.findByText(/welcome/i)).toBeInTheDocument();
  });
});
Router を使ったテスト
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { AppRoutes } from './AppRoutes';

const renderWithRouter = (ui, { initialEntries = ['/'] } = {}) => {
  return render(
    <MemoryRouter initialEntries={initialEntries}>
      {ui}
    </MemoryRouter>
  );
};

describe('AppRoutes Integration', () => {
  it('renders home page by default', () => {
    renderWithRouter(<AppRoutes />);

    expect(screen.getByText(/home/i)).toBeInTheDocument();
  });

  it('renders user page at /users/:id', () => {
    renderWithRouter(<AppRoutes />, { initialEntries: ['/users/123'] });

    expect(screen.getByText(/user profile/i)).toBeInTheDocument();
  });

  it('navigates programmatically', async () => {
    const user = userEvent.setup();
    renderWithRouter(<AppRoutes />);

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

    expect(screen.getByText(/profile page/i)).toBeInTheDocument();
  });
});
Redux を使ったテスト
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import userEvent from '@testing-library/user-event';
import { TodoList } from './TodoList';
import todosReducer from './todosSlice';

const createMockStore = (initialState = {}) => {
  return configureStore({
    reducer: {
      todos: todosReducer,
    },
    preloadedState: initialState,
  });
};

const renderWithStore = (ui, { store = createMockStore() } = {}) => {
  return render(<Provider store={store}>{ui}</Provider>);
};

describe('TodoList Integration', () => {
  it('adds new todo', async () => {
    const user = userEvent.setup();
    renderWithStore(<TodoList />);

    const input = screen.getByPlaceholderText(/new todo/i);
    const addButton = screen.getByRole('button', { name: /add/i });

    await user.type(input, 'Buy groceries');
    await user.click(addButton);

    expect(screen.getByText(/buy groceries/i)).toBeInTheDocument();
  });

  it('renders initial todos from store', () => {
    const initialState = {
      todos: {
        items: [
          { id: 1, text: 'Existing todo', completed: false },
        ],
      },
    };

    renderWithStore(<TodoList />, { store: createMockStore(initialState) });

    expect(screen.getByText(/existing todo/i)).toBeInTheDocument();
  });
});

Jest DOM マッチャー

よく使うマッチャー
// Element presence
expect(element).toBeInTheDocument();
expect(element).not.toBeInTheDocument();

// Visibility
expect(element).toBeVisible();
expect(element).not.toBeVisible();

// Text content
expect(element).toHaveTextContent('Hello World');
expect(element).toHaveTextContent(/hello/i);

// Attributes
expect(element).toHaveAttribute('type', 'submit');
expect(element).toHaveAttribute('disabled');

// Classes
expect(element).toHaveClass('active');
expect(element).toHaveClass('btn', 'btn-primary');

// Styles
expect(element).toHaveStyle({ color: 'red' });
expect(element).toHaveStyle('display: none');

// Forms
expect(input).toHaveValue('test');
expect(input).toHaveDisplayValue('Test');
expect(checkbox).toBeChecked();
expect(checkbox).not.toBeChecked();
expect(input).toBeDisabled();
expect(input).toBeEnabled();
expect(input).toBeRequired();
expect(input).toBeInvalid();
expect(input).toBeValid();

// Focus
expect(element).toHaveFocus();

// Accessibility
expect(element).toHaveAccessibleName('Submit button');
expect(element).toHaveAccessibleDescription('Click to submit form');

// Contains
expect(container).toContainElement(child);
expect(container).toContainHTML('<span>Text</span>');

ベストプラクティス

テストの整理
  1. 関連するテストをグループ化する:describe ブロックを使ってテストを整理します

    describe('UserProfile', () => {
      describe('when loading', () => {
        it('shows loading spinner', () => {});
      });
    
      describe('when loaded', () => {
        it('displays user information', () => {});
        it('shows profile picture', () => {});
      });
    });
    
  2. わかりやすいテスト名を付ける:テスト名には動作を記述します

    // Good
    it('displays error message when login fails', () => {});
    
    // Bad
    it('test login', () => {});
    
  3. AAA パターンに従う:準備、実行、検証

    it('increments counter', async () => {
      // Arrange
      const user = userEvent.setup();
      render(<Counter />);
    
      // Act
      await user.click(screen.getByRole('button', { name: /increment/i }));
    
      // Assert
      expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
    });
    
クエリのベストプラクティス
  1. アクセシビリティに配慮したクエリを優先する:getByRole、getByLabelText を使います
  2. screen のクエリを使う:render の戻り値から分割代入するのではなく、screen からインポートします
  3. getByTestId を避ける:最後の手段としてのみ使います
  4. 正規表現を使う:完全一致の文字列より柔軟に指定できます
非同期テストのベストプラクティス
  1. 非同期処理には findBy を使う:getBy と waitFor の組み合わせより findBy を優先します
  2. 適切なタイムアウトを設定する:時間のかかる処理には waitFor のタイムアウトを設定します
  3. act() の警告を避ける:userEvent、waitFor、findBy を適切に使います
  4. タイマーをクリーンアップする:jest.useFakeTimers() を使い、適切にクリーンアップします
モックのベストプラクティス
  1. 適切なレベルでモックする:内部ロジックではなく、外部の依存関係をモックします
  2. モックをリセットする:テスト間でモックをクリアします
  3. API には MSW を使う:axios/fetch を直接モックするより MSW を優先します
  4. 過剰なモックを避ける:テスト対象をモックしないでください
カバレッジのベストプラクティス
  1. 動作に焦点を当てる:実装ではなく、ユーザー向けの動作をテストします
  2. 100% を追い求めない:重要なパスに焦点を当てます
  3. エラー状態をテストする:エラー処理のテストを含めます
  4. 境界ケースをテストする:境界条件を含めます

よく使うテストパターン

リストと反復処理のテスト
it('renders list of items', () => {
  const items = ['Apple', 'Banana', 'Cherry'];
  render(<ItemList items={items} />);

  items.forEach(item => {
    expect(screen.getByText(item)).toBeInTheDocument();
  });
});
アクセシビリティのテスト
it('has accessible form', () => {
  render(<ContactForm />);

  const nameInput = screen.getByLabelText(/name/i);
  expect(nameInput).toHaveAccessibleName('Name');
  expect(nameInput).toBeRequired();

  const submitButton = screen.getByRole('button', { name: /submit/i });
  expect(submitButton).toHaveAttribute('type', 'submit');
});
エラーバウンダリのテスト
it('catches errors and displays fallback', () => {
  const ThrowError = () => {
    throw new Error('Test error');
  };

  // Suppress console.error for this test
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});

  render(
    <ErrorBoundary fallback={<div>Something went wrong</div>}>
      <ThrowError />
    </ErrorBoundary>
  );

  expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();

  spy.mockRestore();
});
ポータルのテスト
it('renders modal in portal', () => {
  render(<Modal isOpen={true}>Modal Content</Modal>);

  const modal = screen.getByText(/modal content/i);
  expect(modal).toBeInTheDocument();

  // Modal should be in document.body, not in the component tree
  expect(modal.parentElement).toBe(document.body);
});

トラブルシューティング

よくある問題

問題:「Unable to find element」

  • 解決策:screen.debug() を使って DOM を確認し、クエリの種類を確認して、非同期更新を待ちます

問題:「Act warnings」

  • 解決策:fireEvent の代わりに userEvent を使い、状態更新を act() で囲み、waitFor/findBy を使います

問題:「Jest timeout」

  • 解決策:タイムアウトを延長し、無限ループがないか確認して、非同期処理が完了することを確認します

問題:「Cannot read property of undefined」

  • 解決策:モックが正しく設定されているか確認し、コンポーネントが必要な props を受け取っていることを確認します

問題:「Multiple elements found」

  • 解決策:クエリをより具体的にし、複数の要素には getAllBy を使います

リソース


スキルのバージョン:1.0.0 最終更新日:2025年10月 スキルのカテゴリ:テスト、React、品質保証 互換性:Jest 29+、React Testing Library 13+、React 16.8+

コメント