😊

(jest)Jestを使ったテストコード作成入門 — Reactコンポーネント&ユーティリティ関数の例付き

に公開

実は、今までTestを作成したことはありません。
いつも直接試してみるだけでしたが、やっぱりこれはやばい。実際のデーターベースを触ったりするし、アップロードもそうだ。

今回のテストは実際やってもあまり関係ないプロジェクトはありますが、練習がてら作ってみました。

TestFolder作成

テスト対象(コンポーネント、ユーティリティ関数など)に応じて、それぞれ適切な場所にテストファイルを作成します。

テスト対象 テストファイルの場所・名前例 理由
コンポーネント components/MyButton/MyButton.test.tsx コンポーネントごとにテストを分けて管理するため
カスタムフック hooks/useCounter.test.ts フック単位で独立したテストを書くため
ユーティリティ関数 utils/formatDate.test.ts 関数単位でテストを整理して保守しやすくするため

例:フォルダー

src/
  components/
    MyButton/
      MyButton.tsx
      MyButton.test.tsx    ← テストファイル
  utils/
      utils.ts
      utils.test.ts         ← ユーティリティ関数のテストファイル
 hooks/
      useCounter.ts
      useCounter.test.ts    ← フックのテストファイル

ライブラリー設置

npm install --save-dev jest @types/jest ts-jest @testing-library/react @testing-library/jest-dom @testing-library/user-event. @testing-library/react-hooks
  • jest: テストフレームワーク
  • @types/jest: Jestの型サポート
  • ts-jest: TypeScriptとJestの連携
  • @testing-library/react: Reactコンポーネントのテストツール
  • @testing-library/jest-dom: DOMに関する追加のmatcher提供
  • @testing-library/user-event: ユーザーイベントのシミュレーション
  • @testing-library/react-hooks: フック関連のテスト

Jest設定

//package.json
"jest": {
  "preset": "ts-jest",
  "testEnvironment": "jsdom",
  "setupFilesAfterEnv": ["@testing-library/jest-dom/extend-expect"]
}

Testコード作成方法

モックの書き方

//文法
jest.mock('モジュール名(例: papaparse)', () => ({
  エクスポートされる関数名: jest.fn(),
}));

jest.mock('papaparse', () => ({
  parse: jest.fn(), 
}));
使用例 説明
jest.fn() 呼び出しの有無や回数を検証 expect(mockFn).toHaveBeenCalled()
jest.fn(() => 値) 呼び出されたときに特定の値を返す関数を定義 jest.fn(() => 42)(APIのモックなど)
jest.fn().mockImplementation(fn) 引数に応じた処理や、条件分岐などを含む複雑なロジックを定義 jest.fn().mockImplementation((x) => x * 2)

Jest + Testing Library 書き方

expect

expect(実際_値).toBe(期待される_値);

screen.getBy

screen.getByText('実際に画面に表示されるテキスト')

expect(screen.getByText('CSVアップロード')).toBeInTheDocument(); // ボタン
expect(screen.getByText('日付')).toBeInTheDocument(); //ヘッダー

その他getBy

メソッド 説明
getByText テキストの内容で要素を取得
getByRole ロール(例:buttonheadingなど)で要素を取得
getByLabelText <label>に関連付けられた入力要素を取得
getByPlaceholderText placeholder属性の値で要素を取得
getByTestId data-testid属性の値で要素を取得
// ボタンの有無
expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument();

// 特定テキスト確認
expect(screen.getByText('作業内容')).toBeInTheDocument();

// テスト用IDで要素確認
expect(screen.getByTestId('summary-row')).toBeVisible();

Render()

render(<コンポーネント />) は React Testing Library で特定の React コンポーネントや JSX 要素を仮想DOMに「レンダリング」する関数です。

書き方

パターン 使用例 説明
JSXコンポーネント render(<ReportTable />) Reactコンポーネントをテスト対象として描画する基本形です。
JSX要素(HTMLタグ) render(<div>Hello</div>) HTMLのような要素でもJSXとして描画できます。
Props付きコンポーネント render(<Button label="送信" />) Props(引数)を与えて、状態や表示を変えたコンポーネントを描画します。
複数の要素をまとめた構造 render(<><Header /><Main /></>) <Fragment><div>で複数の要素をまとめて描画できます。
ContextやProviderで包んだ形 render(<UserContext.Provider value={{ user: '田中' }}><Dashboard /></UserContext.Provider>) グローバル状態やテーマなどのProviderを含む構成もテストできます。

例:render(),expect()

import React from 'react';
import { render, screen } from '@testing-library/react';

// テスト対象のシンプルなコンポーネント
function Greeting() {
  return <h1>Hello, World!</h1>;
}

test('Greetingコンポーネントが正しくレンダリングされる', () => {
  // コンポーネントを仮想DOMにレンダリング
  render(<Greeting />);

  // テキスト「Hello, World!」を持つ要素が存在するか検証
  expect(screen.getByText('Hello, World!')).toBeInTheDocument();
});

Testコード

例:ユーティリティ関数

状況 : convertTo2DArray は複数日の作業データを2次元配列に変換する関数です。
内部で timeUtilsgroupBy といった依存関数を使っています。

テスト戦略

  • 依存関数(timeUtilsgroupBy)をモック化
  • 空配列や1日分、複数日データなどのパターンを検証
  • timeUtils のモック返却値をケースごとに切り替え

7.9 追記:依存関数のモック化は要らなかった。

コード

export {};
import { ReportRow } from "../types/report";
import { convertTo2DArray } from "./convertTo2DArray";

// timeUtilsやgroupByの依存関数をモック
jest.mock('./timeUtils', () => ({
    addDecimalHoursToTime: jest.fn(() => '18:00'),
    decimalToTime: jest.fn(() => '8:00'),
    formatDateToJapanese: jest.fn((data: string) => data.slice(5).replace('-', '/')),
    getDatesBetween: jest.fn(() => ['2024-06-01']),
    getDayOfWeek: jest.fn(() => '土'),
    getLastDayOfMonth: jest.fn(() => 30),
    parseTimeToDecimal: jest.fn(() => 8),
}));
jest.mock('./groupBy', () => ({
    groupBy: jest.fn((arr: any[], fn: any) => {
        return arr.reduce((acc, cur) => {
            const key = fn(cur);
            acc[key] = acc[key] || [];
            acc[key].push(cur);
            return acc;
        }, {} as Record<string, any[]>);
    }),
}));

describe('convertTo2DArray', () => {
    afterEach(() => {
        jest.clearAllMocks();
    });

    it('空配列を渡すと空配列を返す', () => {
        expect(convertTo2DArray([])).toEqual([]);
    });

    it('1日分のデータを正しく変換する', () => {
       const data: ReportRow[] = [
            {
                date: '2024-06-01',
                dayOfWeek: '土',
                workContent: '作業A',
                workTime: '8:00',
                timeStart: '10:00',
                timeEnd: '18:00',
                breakTime: 0, 
            },
        ];
        const result = convertTo2DArray(data);
        expect(result).toEqual([
            ['06/01', '土', '10:00', '18:00', '0:00', '8:00', '作業A']
        ]);
    });

    it('複数日データを正しく変換する', () => {
        const { getDatesBetween } = require('./timeUtils');
        getDatesBetween.mockReturnValue(['2024-06-01', '2024-06-02']);
        const data: ReportRow[] = [
            {
                date: '2024-06-01',
                dayOfWeek: '土',
                workContent: '作業A',
                workTime: '8:00',
                timeStart: '10:00',
                timeEnd: '18:00',
                breakTime: 0,
            }
        ];
        const result = convertTo2DArray(data);
        expect(result).toEqual([
            ['06/01', '土', '10:00', '18:00', '0:00', '8:00', '作業A'],
            ['06/02', '土', '', '', '', '0:00', ''],
        ]);
    });

    it('日付が異なる複数データを正しく変換する', () => {
        const { getDatesBetween } = require('./timeUtils');
        getDatesBetween.mockReturnValue(['2024-06-01', '2024-06-02', '2024-06-03']);
        const data: ReportRow[] = [
            {
                date: '2024-06-01',
                dayOfWeek: '土',
                workContent: '作業A',
                workTime: '8:00',
                timeStart: '10:00',
                timeEnd: '18:00',
                breakTime: 0,
            },
            {
                date: '2024-06-03',
                dayOfWeek: '月',
                workContent: '作業B',
                workTime: '7:00',
                timeStart: '10:00',
                timeEnd: '17:00',
                breakTime: 0,
            }
        ];
        const { parseTimeToDecimal, decimalToTime, addDecimalHoursToTime } = require('./timeUtils');
        parseTimeToDecimal.mockImplementation((t: string) => t === '8:00' ? 8 : 7);
        decimalToTime.mockImplementation((n: number) => n === 8 ? '8:00' : '7:00');
        addDecimalHoursToTime.mockImplementation((start: string, n: number) => n === 8 ? '18:00' : '17:00');

        const result = convertTo2DArray(data);
        expect(result).toEqual([
            ['06/01', '土', '10:00', '18:00', '0:00', '8:00', '作業A'],
            ['06/02', '土', '', '', '', '0:00', ''],
            ['06/03', '月', '10:00', '17:00', '0:00', '7:00', '作業B'],
        ]);
    });
});
  • 依存関数はjest.mockでモック化し、返り値を自由に制御可能
  • afterEachでテストごとにモックの状態をクリア
  • テストケースごとにモックの返却値を切り替え、さまざまな状況を検証
  • 実際の関数が期待通りに動くかどうかを細かくチェック

例:フック

状況:CSVファイルをPapa.parseのライブラリーを使ってファイルをアップロードするフック。

テスト戦略

  • Papa.parseのモック
  • inputchangeイベント呼び出し動作確認
  • onParsed動作確認

コード

もちろん原本がないと、わかりにくいと思いますが、、参考までに

export {};
import { renderHook, act } from '@testing-library/react';
import { useCsvParser } from './useCsvParser';
import Papa from 'papaparse';

// jest.mockを使ってPapa.parseをモック化
jest.mock('papaparse', () => ({
    parse: jest.fn(), 
  }));

  describe('useCsvParser フックテスト', () => {
    test('CSVファイルを正しくパースしてonParsedを呼び出す', () => {

    // dummyFile
    const dummyFile = new File(['a,b,c\n1,2,3'], 'dummy.csv', { type: 'text/csv' });

    const mockOnParsed = jest.fn();
  
      // renderHookを使ってuseCsvParserフックを呼び出す
      const { result } = renderHook(() => useCsvParser(mockOnParsed));
  
      // Papa.parseのモック実装を設定
      (Papa.parse as jest.Mock).mockImplementation((file, options) => {
        options.complete({ //completeされたときのコールバック
          errors: [], 
          data: [ 
            { '日にち': '2025/06/03', '備考': 'Test', '出勤時間': '09:00', '退勤時間': '18:00', '': '', '勤務時間': '8:00' },
          ],
        });
      });
  
      // handleFileChangeのシミューレーション
      result.current.handleFileChange({
        target: { files: [dummyFile] },
      } as unknown as React.ChangeEvent<HTMLInputElement>);
  
      // onParsedが呼び出されたか確認
      expect(mockOnParsed).toHaveBeenCalled();
  
      // Papa.parseが正しい引数で呼び出されたか確認
      const parsedData = mockOnParsed.mock.calls[0][0];
      //toBe 予想されるデータの検証
      expect(parsedData[0].date).toBe('2025-06-03');
      expect(parsedData[0].workContent).toBe('Test');
    });
  });
  • jest.mock('papaparse')Papa.parse をモック関数に置き換え
  • mockImplementationPapa.parseがコールバック関数 complete を直接呼び出すよう操作
  • handleFileChangeをモックファイルとともに呼び出す
  • onParsedが呼ばれたか、期待するデータが渡されたかを検証

例:コンポーネント

状況 : ReportTableコンポーネントはCSVファイルのアップロードや表示、コピー機能を持つ。

テスト戦略

  • カスタムフックuseCsvParserのモック化
  • ユーティリティ関数(convertTo2DArraycopyTableToClipboardなど)のモック化
  • 基本的なレンダリング確認(ボタンやテーブルヘッダー)
  • 「Copy」ボタンのクリックイベントによる関数呼び出し確認

コード

export {};
import '@testing-library/jest-dom';
import { render, screen, fireEvent } from '@testing-library/react';
import ReportTable from './ReportTable';

//mock作成
jest.mock('../../hooks/useCsvParser', () => ({
  useCsvParser: (onParsed: any) => ({
    fileInputRef: { current: null },
    handleFileChange: jest.fn(),
    triggerFileSelect: jest.fn(),
  }),
}));
jest.mock('../../utils/convertTo2DArray', () => ({
  convertTo2DArray: jest.fn(() => [['a']]),
}));
jest.mock('../../utils/copyTableToClipboard', () => ({
  copyTableToClipboard: jest.fn(),
}));
jest.mock('../../utils/groupBy', () => ({
  groupBy: jest.fn((arr, fn) => {
    return arr.reduce((acc: any, cur: any) => {
      const key = fn(cur);
      acc[key] = acc[key] || [];
      acc[key].push(cur);
      return acc;
    }, {});
  }),
}));
jest.mock('../../utils/timeUtils', () => ({
  addDecimalHoursToTime: jest.fn(() => '18:00'),
  parseTimeToDecimal: jest.fn(() => 8),
}));

//render確認
describe('ReportTable', () => {
  it('テーブルヘッダーがレンダリングされる', () => {
    render(<ReportTable />);
    expect(screen.getByText('CSVアップロード')).toBeInTheDocument();
    expect(screen.getByText('Copy')).toBeInTheDocument();
    expect(screen.getByText('日付')).toBeInTheDocument();
    expect(screen.getByText('曜日')).toBeInTheDocument();
    expect(screen.getByText('作業内容')).toBeInTheDocument();
  });

//copyイベントによる関数呼び出し確認
  it('Copyクリック、 copyTableToClipboardが呼ばれる', () => {
    const { getByText } = render(<ReportTable />);
    fireEvent.click(getByText('Copy'));
    const { copyTableToClipboard } = require('../../utils/copyTableToClipboard');
    expect(copyTableToClipboard).toHaveBeenCalled();
  });

  // 追加のシナリオ(エラー、正常データなど)は必要に応じて追加
});

copyイベントによる関数呼び出し確認

  1. fireEvent.click(getByText('Copy'))
    → 「Copy」ボタンをクリックする動作をテストでシミュレートします。

  2. const { copyTableToClipboard } = require('../../utils/copyTableToClipboard');
    → モックした関数をテスト内で取得します。

  3. expect(copyTableToClipboard).toHaveBeenCalled();
    → その関数が呼ばれたかどうかを確認します。

Discussion