(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 |
ロール(例:button 、heading など)で要素を取得 |
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次元配列に変換する関数です。
内部で timeUtils
や groupBy
といった依存関数を使っています。
テスト戦略
- 依存関数(
timeUtils
、groupBy
)をモック化 - 空配列や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
のモック -
input
のchange
イベント呼び出し動作確認 -
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
をモック関数に置き換え -
mockImplementation
でPapa.parse
がコールバック関数complete
を直接呼び出すよう操作 -
handleFileChange
をモックファイルとともに呼び出す -
onParsed
が呼ばれたか、期待するデータが渡されたかを検証
例:コンポーネント
状況 : ReportTableコンポーネントはCSVファイルのアップロードや表示、コピー機能を持つ。
テスト戦略
- カスタムフック
useCsvParser
のモック化 - ユーティリティ関数(
convertTo2DArray
、copyTableToClipboard
など)のモック化 - 基本的なレンダリング確認(ボタンやテーブルヘッダー)
- 「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イベントによる関数呼び出し確認
-
fireEvent.click(getByText('Copy'))
→ 「Copy」ボタンをクリックする動作をテストでシミュレートします。 -
const { copyTableToClipboard } = require('../../utils/copyTableToClipboard');
→ モックした関数をテスト内で取得します。 -
expect(copyTableToClipboard).toHaveBeenCalled();
→ その関数が呼ばれたかどうかを確認します。
Discussion