Jest の mockImplementation 活用事例集

実際の開発現場では、外部 API やデータベース、ファイルシステムなど、様々な外部依存を持つコードをテストする必要があります。こうした場面で威力を発揮するのが Jest の mockImplementation
です。
単純なモック関数とは異なり、mockImplementation
は動的な戻り値制御や複雑な条件分岐、エラーシナリオの再現など、実際のビジネスロジックに近い形でテストを実行できます。本記事では、開発者の皆様が日常的に遭遇する具体的なシナリオを通じて、mockImplementation
の実践的な活用方法をご紹介いたします。
各事例では実際のコード例とともに、なぜその手法が効果的なのか、どのような課題を解決できるのかも詳しく解説してまいります。テストの品質向上と開発効率の改善に、ぜひお役立てください。
外部 API 通信のモック事例
REST API の成功・失敗パターン
外部 API との通信をテストする際、ネットワークの状態やサーバーの応答を制御する必要があります。以下は典型的な REST API クライアントのテスト例です。
typescript// api/userService.ts
interface User {
id: number;
name: string;
email: string;
}
export class UserService {
async fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(
`Failed to fetch user: ${response.status}`
);
}
return response.json();
}
async createUser(
userData: Omit<User, 'id'>
): Promise<User> {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
if (!response.ok) {
throw new Error(
`Failed to create user: ${response.status}`
);
}
return response.json();
}
}
上記のサービスクラスをテストする際、実際の API サーバーにアクセスするのは現実的ではありません。mockImplementation
を使用して、様々なレスポンスパターンをシミュレートしましょう。
typescript// __tests__/userService.test.ts
import { UserService } from '../api/userService';
// fetch関数をモック化
global.fetch = jest.fn();
const mockFetch = fetch as jest.MockedFunction<
typeof fetch
>;
describe('UserService', () => {
let userService: UserService;
beforeEach(() => {
userService = new UserService();
mockFetch.mockClear();
});
describe('fetchUser', () => {
it('正常なレスポンスの場合、ユーザー情報を返す', async () => {
const mockUser = {
id: 1,
name: '田中太郎',
email: 'tanaka@example.com',
};
// 成功レスポンスをモック
mockFetch.mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockUser),
} as Response)
);
const result = await userService.fetchUser(1);
expect(result).toEqual(mockUser);
expect(mockFetch).toHaveBeenCalledWith(
'/api/users/1'
);
});
it('404エラーの場合、適切なエラーをスローする', async () => {
// エラーレスポンスをモック
mockFetch.mockImplementation(() =>
Promise.resolve({
ok: false,
status: 404,
} as Response)
);
await expect(
userService.fetchUser(999)
).rejects.toThrow('Failed to fetch user: 404');
});
it('ネットワークエラーの場合、エラーをスローする', async () => {
// ネットワークエラーをモック
mockFetch.mockImplementation(() =>
Promise.reject(new Error('Network error'))
);
await expect(
userService.fetchUser(1)
).rejects.toThrow('Network error');
});
});
});
GraphQL クエリのモック
GraphQL を使用している場合も、同様の手法でクエリの結果をモックできます。
typescript// api/graphqlClient.ts
import {
ApolloClient,
gql,
InMemoryCache,
} from '@apollo/client';
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts {
id
title
}
}
}
`;
export class GraphQLUserService {
constructor(private client: ApolloClient<any>) {}
async getUser(id: string) {
const result = await this.client.query({
query: GET_USER,
variables: { id },
});
return result.data.user;
}
}
GraphQL クライアントのテストでは、クエリの実行結果をモックします。
typescript// __tests__/graphqlUserService.test.ts
import { ApolloClient } from '@apollo/client';
import { GraphQLUserService } from '../api/graphqlClient';
describe('GraphQLUserService', () => {
let mockClient: jest.Mocked<ApolloClient<any>>;
let service: GraphQLUserService;
beforeEach(() => {
// ApolloClientをモック
mockClient = {
query: jest.fn(),
} as any;
service = new GraphQLUserService(mockClient);
});
it('ユーザー情報を正常に取得できる', async () => {
const mockUserData = {
id: '1',
name: '佐藤花子',
email: 'sato@example.com',
posts: [
{ id: '1', title: '初投稿です' },
{ id: '2', title: 'Jest学習記録' },
],
};
// GraphQLクエリの結果をモック
mockClient.query.mockImplementation(() =>
Promise.resolve({
data: { user: mockUserData },
loading: false,
networkStatus: 7,
})
);
const result = await service.getUser('1');
expect(result).toEqual(mockUserData);
expect(mockClient.query).toHaveBeenCalledWith({
query: expect.any(Object),
variables: { id: '1' },
});
});
it('ユーザーが見つからない場合、nullを返す', async () => {
mockClient.query.mockImplementation(() =>
Promise.resolve({
data: { user: null },
loading: false,
networkStatus: 7,
})
);
const result = await service.getUser('999');
expect(result).toBeNull();
});
});
ファイルシステム操作のモック事例
ファイル読み書きのモック
Node.js アプリケーションでファイルシステムを操作するコードをテストする際、実際のファイルを作成・削除するのは望ましくありません。fs
モジュールをモックして、様々なファイル操作をシミュレートしましょう。
typescript// utils/fileManager.ts
import { promises as fs } from 'fs';
import path from 'path';
export class FileManager {
async readConfig(configPath: string): Promise<any> {
try {
const content = await fs.readFile(
configPath,
'utf-8'
);
return JSON.parse(content);
} catch (error) {
if (
(error as NodeJS.ErrnoException).code === 'ENOENT'
) {
throw new Error(
`設定ファイルが見つかりません: ${configPath}`
);
}
throw error;
}
}
async saveLog(
logDir: string,
message: string
): Promise<void> {
const timestamp = new Date()
.toISOString()
.split('T')[0];
const logFile = path.join(
logDir,
`app-${timestamp}.log`
);
await fs.mkdir(logDir, { recursive: true });
await fs.appendFile(
logFile,
`${new Date().toISOString()}: ${message}\n`
);
}
async backupFile(
sourcePath: string,
backupDir: string
): Promise<string> {
const fileName = path.basename(sourcePath);
const timestamp = Date.now();
const backupPath = path.join(
backupDir,
`${timestamp}-${fileName}`
);
await fs.mkdir(backupDir, { recursive: true });
await fs.copyFile(sourcePath, backupPath);
return backupPath;
}
}
ファイル操作のテストでは、fs
モジュール全体をモックし、各メソッドの動作を制御します。
typescript// __tests__/fileManager.test.ts
import { promises as fs } from 'fs';
import { FileManager } from '../utils/fileManager';
// fsモジュールをモック
jest.mock('fs', () => ({
promises: {
readFile: jest.fn(),
writeFile: jest.fn(),
appendFile: jest.fn(),
mkdir: jest.fn(),
copyFile: jest.fn(),
},
}));
const mockFs = fs as jest.Mocked<typeof fs>;
describe('FileManager', () => {
let fileManager: FileManager;
beforeEach(() => {
fileManager = new FileManager();
jest.clearAllMocks();
});
describe('readConfig', () => {
it('設定ファイルを正常に読み込める', async () => {
const mockConfig = {
database: { host: 'localhost', port: 5432 },
};
mockFs.readFile.mockImplementation(() =>
Promise.resolve(JSON.stringify(mockConfig))
);
const result = await fileManager.readConfig(
'/path/to/config.json'
);
expect(result).toEqual(mockConfig);
expect(mockFs.readFile).toHaveBeenCalledWith(
'/path/to/config.json',
'utf-8'
);
});
it('ファイルが存在しない場合、適切なエラーをスローする', async () => {
const error = new Error(
'File not found'
) as NodeJS.ErrnoException;
error.code = 'ENOENT';
mockFs.readFile.mockImplementation(() =>
Promise.reject(error)
);
await expect(
fileManager.readConfig('/nonexistent/config.json')
).rejects.toThrow('設定ファイルが見つかりません');
});
it('JSON解析エラーの場合、エラーをスローする', async () => {
mockFs.readFile.mockImplementation(() =>
Promise.resolve('invalid json content')
);
await expect(
fileManager.readConfig('/path/to/config.json')
).rejects.toThrow();
});
});
describe('saveLog', () => {
it('ログディレクトリを作成してログを保存する', async () => {
mockFs.mkdir.mockImplementation(() =>
Promise.resolve(undefined)
);
mockFs.appendFile.mockImplementation(() =>
Promise.resolve()
);
await fileManager.saveLog(
'/logs',
'テストメッセージ'
);
expect(mockFs.mkdir).toHaveBeenCalledWith('/logs', {
recursive: true,
});
expect(mockFs.appendFile).toHaveBeenCalledWith(
expect.stringMatching(
/\/logs\/app-\d{4}-\d{2}-\d{2}\.log/
),
expect.stringContaining('テストメッセージ')
);
});
});
describe('backupFile', () => {
it('ファイルをバックアップディレクトリにコピーする', async () => {
mockFs.mkdir.mockImplementation(() =>
Promise.resolve(undefined)
);
mockFs.copyFile.mockImplementation(() =>
Promise.resolve()
);
// 固定の時刻をモック
const mockDate = new Date('2024-01-15T10:30:00Z');
jest
.spyOn(Date, 'now')
.mockReturnValue(mockDate.getTime());
const backupPath = await fileManager.backupFile(
'/source/file.txt',
'/backup'
);
expect(backupPath).toBe(
`/backup/${mockDate.getTime()}-file.txt`
);
expect(mockFs.mkdir).toHaveBeenCalledWith('/backup', {
recursive: true,
});
expect(mockFs.copyFile).toHaveBeenCalledWith(
'/source/file.txt',
backupPath
);
});
});
});
時間・日付に依存する処理のモック事例
Date オブジェクトのモック
時間に依存するロジックをテストする際、現在時刻を固定値でモックすることで、再現可能なテストを作成できます。
typescript// utils/timeService.ts
export class TimeService {
getCurrentTimestamp(): number {
return Date.now();
}
formatCurrentDate(): string {
return new Date().toISOString().split('T')[0];
}
isBusinessHour(): boolean {
const now = new Date();
const hour = now.getHours();
const day = now.getDay();
// 平日の9時〜18時をビジネスアワーとする
return day >= 1 && day <= 5 && hour >= 9 && hour < 18;
}
getTimeUntilNextBusinessDay(): number {
const now = new Date();
const nextBusinessDay = new Date(now);
// 現在が金曜日の場合、月曜日まで
// それ以外は翌日まで
if (now.getDay() === 5) {
// 金曜日
nextBusinessDay.setDate(now.getDate() + 3);
} else if (now.getDay() === 6) {
// 土曜日
nextBusinessDay.setDate(now.getDate() + 2);
} else if (now.getDay() === 0) {
// 日曜日
nextBusinessDay.setDate(now.getDate() + 1);
} else {
// 平日
nextBusinessDay.setDate(now.getDate() + 1);
}
nextBusinessDay.setHours(9, 0, 0, 0);
return nextBusinessDay.getTime() - now.getTime();
}
}
時間依存のテストでは、Date
コンストラクタや Date.now()
をモックして、特定の日時でのテストを実行します。
typescript// __tests__/timeService.test.ts
import { TimeService } from '../utils/timeService';
describe('TimeService', () => {
let timeService: TimeService;
let mockDate: jest.SpyInstance;
beforeEach(() => {
timeService = new TimeService();
});
afterEach(() => {
if (mockDate) {
mockDate.mockRestore();
}
});
describe('getCurrentTimestamp', () => {
it('現在のタイムスタンプを返す', () => {
const fixedTime = new Date(
'2024-01-15T10:30:00Z'
).getTime();
// Date.nowをモック
jest.spyOn(Date, 'now').mockReturnValue(fixedTime);
const result = timeService.getCurrentTimestamp();
expect(result).toBe(fixedTime);
});
});
describe('formatCurrentDate', () => {
it('現在日付をYYYY-MM-DD形式で返す', () => {
const fixedDate = new Date('2024-01-15T10:30:00Z');
// Dateコンストラクタをモック
mockDate = jest
.spyOn(global, 'Date')
.mockImplementation(() => fixedDate);
const result = timeService.formatCurrentDate();
expect(result).toBe('2024-01-15');
});
});
describe('isBusinessHour', () => {
it('平日の営業時間内の場合、trueを返す', () => {
// 2024年1月15日(月曜日)の14時をモック
const businessHourDate = new Date(
'2024-01-15T14:00:00Z'
);
mockDate = jest
.spyOn(global, 'Date')
.mockImplementation(() => businessHourDate);
const result = timeService.isBusinessHour();
expect(result).toBe(true);
});
it('平日の営業時間外の場合、falseを返す', () => {
// 2024年1月15日(月曜日)の20時をモック
const afterHourDate = new Date(
'2024-01-15T20:00:00Z'
);
mockDate = jest
.spyOn(global, 'Date')
.mockImplementation(() => afterHourDate);
const result = timeService.isBusinessHour();
expect(result).toBe(false);
});
it('週末の場合、falseを返す', () => {
// 2024年1月13日(土曜日)の14時をモック
const weekendDate = new Date('2024-01-13T14:00:00Z');
mockDate = jest
.spyOn(global, 'Date')
.mockImplementation(() => weekendDate);
const result = timeService.isBusinessHour();
expect(result).toBe(false);
});
});
describe('getTimeUntilNextBusinessDay', () => {
it('金曜日の場合、月曜日の9時までの時間を返す', () => {
// 2024年1月12日(金曜日)の15時をモック
const fridayDate = new Date('2024-01-12T15:00:00Z');
mockDate = jest
.spyOn(global, 'Date')
.mockImplementation(() => fridayDate);
const result =
timeService.getTimeUntilNextBusinessDay();
// 月曜日の9時まで(3日と18時間 = 66時間)
const expectedMs = 66 * 60 * 60 * 1000;
expect(result).toBe(expectedMs);
});
it('平日の場合、翌日の9時までの時間を返す', () => {
// 2024年1月15日(月曜日)の15時をモック
const mondayDate = new Date('2024-01-15T15:00:00Z');
mockDate = jest
.spyOn(global, 'Date')
.mockImplementation(() => mondayDate);
const result =
timeService.getTimeUntilNextBusinessDay();
// 翌日の9時まで(18時間)
const expectedMs = 18 * 60 * 60 * 1000;
expect(result).toBe(expectedMs);
});
});
});
setTimeout・setInterval のモック
タイマー機能を含むコードをテストする際は、Jest のタイマーモック機能と mockImplementation
を組み合わせます。
typescript// utils/scheduler.ts
export class Scheduler {
private timers: Set<NodeJS.Timeout> = new Set();
scheduleTask(
callback: () => void,
delay: number
): NodeJS.Timeout {
const timer = setTimeout(() => {
callback();
this.timers.delete(timer);
}, delay);
this.timers.add(timer);
return timer;
}
scheduleRecurringTask(
callback: () => void,
interval: number
): NodeJS.Timeout {
const timer = setInterval(callback, interval);
this.timers.add(timer);
return timer;
}
cancelAllTasks(): void {
this.timers.forEach((timer) => {
clearTimeout(timer);
clearInterval(timer);
});
this.timers.clear();
}
getActiveTaskCount(): number {
return this.timers.size;
}
}
タイマーを使用するコードのテストでは、Jest のタイマーモック機能を活用します。
typescript// __tests__/scheduler.test.ts
import { Scheduler } from '../utils/scheduler';
describe('Scheduler', () => {
let scheduler: Scheduler;
beforeEach(() => {
scheduler = new Scheduler();
// Jestのタイマーモックを有効化
jest.useFakeTimers();
});
afterEach(() => {
scheduler.cancelAllTasks();
// タイマーモックをクリア
jest.clearAllTimers();
jest.useRealTimers();
});
describe('scheduleTask', () => {
it('指定した遅延後にタスクが実行される', () => {
const mockCallback = jest.fn();
scheduler.scheduleTask(mockCallback, 1000);
// 500ms経過時点ではまだ実行されない
jest.advanceTimersByTime(500);
expect(mockCallback).not.toHaveBeenCalled();
// 1000ms経過時点で実行される
jest.advanceTimersByTime(500);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it('タスク実行後、アクティブなタスク数が減る', () => {
const mockCallback = jest.fn();
scheduler.scheduleTask(mockCallback, 1000);
expect(scheduler.getActiveTaskCount()).toBe(1);
// タスク実行
jest.advanceTimersByTime(1000);
expect(scheduler.getActiveTaskCount()).toBe(0);
});
});
describe('scheduleRecurringTask', () => {
it('指定した間隔で繰り返しタスクが実行される', () => {
const mockCallback = jest.fn();
scheduler.scheduleRecurringTask(mockCallback, 500);
// 3回分の時間を進める
jest.advanceTimersByTime(1500);
expect(mockCallback).toHaveBeenCalledTimes(3);
});
});
describe('cancelAllTasks', () => {
it('全てのタスクがキャンセルされる', () => {
const mockCallback1 = jest.fn();
const mockCallback2 = jest.fn();
scheduler.scheduleTask(mockCallback1, 1000);
scheduler.scheduleRecurringTask(mockCallback2, 500);
expect(scheduler.getActiveTaskCount()).toBe(2);
scheduler.cancelAllTasks();
expect(scheduler.getActiveTaskCount()).toBe(0);
// 時間を進めてもコールバックは実行されない
jest.advanceTimersByTime(2000);
expect(mockCallback1).not.toHaveBeenCalled();
expect(mockCallback2).not.toHaveBeenCalled();
});
});
});
条件分岐とエラーハンドリングのモック事例
複雑な条件分岐のテスト
ビジネスロジックには複数の条件分岐が含まれることが多く、全てのパスをテストするために mockImplementation
で様々な状況をシミュレートする必要があります。
typescript// services/orderService.ts
interface Product {
id: string;
name: string;
price: number;
stock: number;
}
interface User {
id: string;
membershipLevel: 'bronze' | 'silver' | 'gold';
points: number;
}
export class OrderService {
constructor(
private productRepository: any,
private userRepository: any,
private paymentService: any
) {}
async calculateOrderTotal(
userId: string,
productId: string,
quantity: number
): Promise<{
subtotal: number;
discount: number;
total: number;
pointsUsed: number;
}> {
// ユーザー情報を取得
const user = await this.userRepository.findById(userId);
if (!user) {
throw new Error('ユーザーが見つかりません');
}
// 商品情報を取得
const product = await this.productRepository.findById(
productId
);
if (!product) {
throw new Error('商品が見つかりません');
}
// 在庫チェック
if (product.stock < quantity) {
throw new Error('在庫が不足しています');
}
const subtotal = product.price * quantity;
let discount = 0;
let pointsUsed = 0;
// 会員レベル別割引
switch (user.membershipLevel) {
case 'gold':
discount = subtotal * 0.15; // 15%割引
break;
case 'silver':
discount = subtotal * 0.1; // 10%割引
break;
case 'bronze':
discount = subtotal * 0.05; // 5%割引
break;
}
// 高額商品の追加割引(10,000円以上)
if (subtotal >= 10000) {
discount += 500;
}
// ポイント使用(最大で小計の50%まで)
const maxPointsUsable = Math.floor(subtotal * 0.5);
pointsUsed = Math.min(user.points, maxPointsUsable);
const total = subtotal - discount - pointsUsed;
return {
subtotal,
discount,
total: Math.max(total, 0),
pointsUsed,
};
}
async processPayment(
userId: string,
productId: string,
quantity: number
): Promise<{ success: boolean; orderId?: string }> {
try {
const orderTotal = await this.calculateOrderTotal(
userId,
productId,
quantity
);
if (orderTotal.total <= 0) {
throw new Error('注文金額が無効です');
}
// 支払い処理
const paymentResult =
await this.paymentService.charge(orderTotal.total);
if (!paymentResult.success) {
throw new Error(
paymentResult.error || '支払い処理に失敗しました'
);
}
return {
success: true,
orderId: paymentResult.transactionId,
};
} catch (error) {
return {
success: false,
};
}
}
}
このサービスクラスをテストする際、様々な条件をシミュレートして全ての分岐をカバーする必要があります。
typescript// __tests__/orderService.test.ts
import { OrderService } from '../services/orderService';
describe('OrderService', () => {
let orderService: OrderService;
let mockProductRepository: any;
let mockUserRepository: any;
let mockPaymentService: any;
beforeEach(() => {
mockProductRepository = {
findById: jest.fn(),
};
mockUserRepository = {
findById: jest.fn(),
};
mockPaymentService = {
charge: jest.fn(),
};
orderService = new OrderService(
mockProductRepository,
mockUserRepository,
mockPaymentService
);
});
describe('calculateOrderTotal', () => {
it('ゴールド会員で高額商品の場合、最大割引が適用される', async () => {
const mockUser = {
id: 'user1',
membershipLevel: 'gold' as const,
points: 2000,
};
const mockProduct = {
id: 'product1',
name: 'ラップトップ',
price: 50000,
stock: 10,
};
mockUserRepository.findById.mockImplementation(
(userId: string) => {
if (userId === 'user1')
return Promise.resolve(mockUser);
return Promise.resolve(null);
}
);
mockProductRepository.findById.mockImplementation(
(productId: string) => {
if (productId === 'product1')
return Promise.resolve(mockProduct);
return Promise.resolve(null);
}
);
const result = await orderService.calculateOrderTotal(
'user1',
'product1',
1
);
expect(result.subtotal).toBe(50000);
expect(result.discount).toBe(8000); // 15% + 500円
expect(result.pointsUsed).toBe(2000);
expect(result.total).toBe(40000);
});
it('ユーザーが存在しない場合、エラーをスローする', async () => {
mockUserRepository.findById.mockImplementation(() =>
Promise.resolve(null)
);
await expect(
orderService.calculateOrderTotal(
'nonexistent',
'product1',
1
)
).rejects.toThrow('ユーザーが見つかりません');
});
it('商品が存在しない場合、エラーをスローする', async () => {
const mockUser = {
id: 'user1',
membershipLevel: 'bronze' as const,
points: 0,
};
mockUserRepository.findById.mockImplementation(() =>
Promise.resolve(mockUser)
);
mockProductRepository.findById.mockImplementation(
() => Promise.resolve(null)
);
await expect(
orderService.calculateOrderTotal(
'user1',
'nonexistent',
1
)
).rejects.toThrow('商品が見つかりません');
});
it('在庫不足の場合、エラーをスローする', async () => {
const mockUser = {
id: 'user1',
membershipLevel: 'bronze' as const,
points: 0,
};
const mockProduct = {
id: 'product1',
name: 'ラップトップ',
price: 50000,
stock: 1,
};
mockUserRepository.findById.mockImplementation(() =>
Promise.resolve(mockUser)
);
mockProductRepository.findById.mockImplementation(
() => Promise.resolve(mockProduct)
);
await expect(
orderService.calculateOrderTotal(
'user1',
'product1',
5
)
).rejects.toThrow('在庫が不足しています');
});
});
describe('processPayment', () => {
it('正常な支払い処理の場合、成功レスポンスを返す', async () => {
const mockUser = {
id: 'user1',
membershipLevel: 'silver' as const,
points: 1000,
};
const mockProduct = {
id: 'product1',
name: 'ラップトップ',
price: 30000,
stock: 10,
};
mockUserRepository.findById.mockImplementation(() =>
Promise.resolve(mockUser)
);
mockProductRepository.findById.mockImplementation(
() => Promise.resolve(mockProduct)
);
mockPaymentService.charge.mockImplementation(
(amount: number) =>
Promise.resolve({
success: true,
transactionId: 'txn_123456',
})
);
const result = await orderService.processPayment(
'user1',
'product1',
1
);
expect(result.success).toBe(true);
expect(result.orderId).toBe('txn_123456');
});
it('支払い処理が失敗した場合、エラーレスポンスを返す', async () => {
const mockUser = {
id: 'user1',
membershipLevel: 'bronze' as const,
points: 0,
};
const mockProduct = {
id: 'product1',
name: 'ラップトップ',
price: 30000,
stock: 10,
};
mockUserRepository.findById.mockImplementation(() =>
Promise.resolve(mockUser)
);
mockProductRepository.findById.mockImplementation(
() => Promise.resolve(mockProduct)
);
mockPaymentService.charge.mockImplementation(() =>
Promise.resolve({
success: false,
error: 'カードが拒否されました',
})
);
const result = await orderService.processPayment(
'user1',
'product1',
1
);
expect(result.success).toBe(false);
expect(result.orderId).toBeUndefined();
});
});
});
React コンポーネントの props・hooks モック事例
カスタムフックのモック
React アプリケーションでカスタムフックを使用している場合、そのフックの戻り値をモックして様々な状態をテストできます。
typescript// hooks/useUserData.ts
import { useState, useEffect } from 'react';
interface User {
id: string;
name: string;
email: string;
}
export const useUserData = (userId: string) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
const response = await fetch(
`/api/users/${userId}`
);
if (!response.ok) {
throw new Error('ユーザーの取得に失敗しました');
}
const userData = await response.json();
setUser(userData);
} catch (err) {
setError(
err instanceof Error
? err.message
: '不明なエラー'
);
} finally {
setLoading(false);
}
};
if (userId) {
fetchUser();
}
}, [userId]);
return { user, loading, error };
};
typescript// components/UserProfile.tsx
import React from 'react';
import { useUserData } from '../hooks/useUserData';
interface UserProfileProps {
userId: string;
}
export const UserProfile: React.FC<UserProfileProps> = ({
userId,
}) => {
const { user, loading, error } = useUserData(userId);
if (loading) {
return <div data-testid='loading'>読み込み中...</div>;
}
if (error) {
return (
<div data-testid='error' role='alert'>
エラー: {error}
</div>
);
}
if (!user) {
return (
<div data-testid='no-user'>
ユーザーが見つかりません
</div>
);
}
return (
<div data-testid='user-profile'>
<h2>{user.name}</h2>
<p>メール: {user.email}</p>
</div>
);
};
カスタムフックをモックして、様々な状態をテストします。
typescript// __tests__/UserProfile.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { UserProfile } from '../components/UserProfile';
import { useUserData } from '../hooks/useUserData';
// カスタムフックをモック
jest.mock('../hooks/useUserData');
const mockUseUserData = useUserData as jest.MockedFunction<
typeof useUserData
>;
describe('UserProfile', () => {
it('ローディング状態を表示する', () => {
mockUseUserData.mockImplementation(() => ({
user: null,
loading: true,
error: null,
}));
render(<UserProfile userId='user123' />);
expect(
screen.getByTestId('loading')
).toBeInTheDocument();
expect(
screen.getByText('読み込み中...')
).toBeInTheDocument();
});
it('ユーザー情報を表示する', () => {
const mockUser = {
id: 'user123',
name: '田中太郎',
email: 'tanaka@example.com',
};
mockUseUserData.mockImplementation(() => ({
user: mockUser,
loading: false,
error: null,
}));
render(<UserProfile userId='user123' />);
expect(
screen.getByTestId('user-profile')
).toBeInTheDocument();
expect(
screen.getByText('田中太郎')
).toBeInTheDocument();
expect(
screen.getByText('メール: tanaka@example.com')
).toBeInTheDocument();
});
it('エラー状態を表示する', () => {
mockUseUserData.mockImplementation(() => ({
user: null,
loading: false,
error: 'ネットワークエラーです',
}));
render(<UserProfile userId='user123' />);
expect(screen.getByTestId('error')).toBeInTheDocument();
expect(
screen.getByText('エラー: ネットワークエラーです')
).toBeInTheDocument();
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('ユーザーが見つからない場合の表示', () => {
mockUseUserData.mockImplementation(() => ({
user: null,
loading: false,
error: null,
}));
render(<UserProfile userId='nonexistent' />);
expect(
screen.getByTestId('no-user')
).toBeInTheDocument();
expect(
screen.getByText('ユーザーが見つかりません')
).toBeInTheDocument();
});
});
Node.js バックエンド処理のモック事例
データベース操作のモック
Node.js アプリケーションでデータベースアクセスをテストする際、実際のデータベースではなくモックを使用します。
typescript// repositories/userRepository.ts
import { Pool } from 'pg';
interface User {
id: number;
name: string;
email: string;
created_at: Date;
}
export class UserRepository {
constructor(private db: Pool) {}
async findById(id: number): Promise<User | null> {
const query = 'SELECT * FROM users WHERE id = $1';
const result = await this.db.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
}
async create(name: string, email: string): Promise<User> {
const query = `
INSERT INTO users (name, email, created_at)
VALUES ($1, $2, NOW())
RETURNING *
`;
const result = await this.db.query(query, [
name,
email,
]);
return result.rows[0];
}
async updateEmail(
id: number,
newEmail: string
): Promise<User | null> {
const query = `
UPDATE users
SET email = $1
WHERE id = $2
RETURNING *
`;
const result = await this.db.query(query, [
newEmail,
id,
]);
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
}
async delete(id: number): Promise<boolean> {
const query = 'DELETE FROM users WHERE id = $1';
const result = await this.db.query(query, [id]);
return result.rowCount > 0;
}
}
データベース操作のテストでは、データベースクライアントをモックします。
typescript// __tests__/userRepository.test.ts
import { Pool } from 'pg';
import { UserRepository } from '../repositories/userRepository';
// pgモジュールをモック
jest.mock('pg', () => ({
Pool: jest.fn().mockImplementation(() => ({
query: jest.fn(),
})),
}));
describe('UserRepository', () => {
let userRepository: UserRepository;
let mockDb: jest.Mocked<Pool>;
beforeEach(() => {
mockDb = new Pool() as jest.Mocked<Pool>;
userRepository = new UserRepository(mockDb);
});
describe('findById', () => {
it('ユーザーが存在する場合、ユーザー情報を返す', async () => {
const mockUser = {
id: 1,
name: '田中太郎',
email: 'tanaka@example.com',
created_at: new Date('2024-01-15'),
};
mockDb.query.mockImplementation((query, params) => {
if (
query === 'SELECT * FROM users WHERE id = $1' &&
params?.[0] === 1
) {
return Promise.resolve({
rows: [mockUser],
rowCount: 1,
} as any);
}
return Promise.resolve({
rows: [],
rowCount: 0,
} as any);
});
const result = await userRepository.findById(1);
expect(result).toEqual(mockUser);
expect(mockDb.query).toHaveBeenCalledWith(
'SELECT * FROM users WHERE id = $1',
[1]
);
});
it('ユーザーが存在しない場合、nullを返す', async () => {
mockDb.query.mockImplementation(() =>
Promise.resolve({ rows: [], rowCount: 0 } as any)
);
const result = await userRepository.findById(999);
expect(result).toBeNull();
});
});
describe('create', () => {
it('新しいユーザーを作成する', async () => {
const mockCreatedUser = {
id: 1,
name: '鈴木花子',
email: 'suzuki@example.com',
created_at: new Date('2024-01-15'),
};
mockDb.query.mockImplementation((query, params) => {
if (
query.includes('INSERT INTO users') &&
params?.[0] === '鈴木花子' &&
params?.[1] === 'suzuki@example.com'
) {
return Promise.resolve({
rows: [mockCreatedUser],
rowCount: 1,
} as any);
}
return Promise.resolve({
rows: [],
rowCount: 0,
} as any);
});
const result = await userRepository.create(
'鈴木花子',
'suzuki@example.com'
);
expect(result).toEqual(mockCreatedUser);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO users'),
['鈴木花子', 'suzuki@example.com']
);
});
});
describe('updateEmail', () => {
it('メールアドレスを更新する', async () => {
const mockUpdatedUser = {
id: 1,
name: '田中太郎',
email: 'new-tanaka@example.com',
created_at: new Date('2024-01-15'),
};
mockDb.query.mockImplementation(() =>
Promise.resolve({
rows: [mockUpdatedUser],
rowCount: 1,
} as any)
);
const result = await userRepository.updateEmail(
1,
'new-tanaka@example.com'
);
expect(result).toEqual(mockUpdatedUser);
});
it('存在しないユーザーの場合、nullを返す', async () => {
mockDb.query.mockImplementation(() =>
Promise.resolve({ rows: [], rowCount: 0 } as any)
);
const result = await userRepository.updateEmail(
999,
'new@example.com'
);
expect(result).toBeNull();
});
});
describe('delete', () => {
it('ユーザーを削除する', async () => {
mockDb.query.mockImplementation(() =>
Promise.resolve({ rowCount: 1 } as any)
);
const result = await userRepository.delete(1);
expect(result).toBe(true);
expect(mockDb.query).toHaveBeenCalledWith(
'DELETE FROM users WHERE id = $1',
[1]
);
});
it('存在しないユーザーの場合、falseを返す', async () => {
mockDb.query.mockImplementation(() =>
Promise.resolve({ rowCount: 0 } as any)
);
const result = await userRepository.delete(999);
expect(result).toBe(false);
});
});
});
Redis キャッシュのモック
キャッシュサーバーとの連携をテストする際も、同様にモックを活用します。
typescript// services/cacheService.ts
import Redis from 'ioredis';
export class CacheService {
constructor(private redis: Redis) {}
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key);
if (!value) return null;
try {
return JSON.parse(value);
} catch {
return value as unknown as T;
}
}
async set(
key: string,
value: any,
expireInSeconds?: number
): Promise<void> {
const serializedValue = JSON.stringify(value);
if (expireInSeconds) {
await this.redis.setex(
key,
expireInSeconds,
serializedValue
);
} else {
await this.redis.set(key, serializedValue);
}
}
async delete(key: string): Promise<boolean> {
const result = await this.redis.del(key);
return result > 0;
}
async exists(key: string): Promise<boolean> {
const result = await this.redis.exists(key);
return result === 1;
}
}
typescript// __tests__/cacheService.test.ts
import Redis from 'ioredis';
import { CacheService } from '../services/cacheService';
// ioredisモジュールをモック
jest.mock('ioredis');
describe('CacheService', () => {
let cacheService: CacheService;
let mockRedis: jest.Mocked<Redis>;
beforeEach(() => {
mockRedis = new Redis() as jest.Mocked<Redis>;
cacheService = new CacheService(mockRedis);
});
describe('get', () => {
it('キャッシュされた値を取得する', async () => {
const mockData = { id: 1, name: '田中太郎' };
mockRedis.get.mockImplementation((key) => {
if (key === 'user:1') {
return Promise.resolve(JSON.stringify(mockData));
}
return Promise.resolve(null);
});
const result = await cacheService.get('user:1');
expect(result).toEqual(mockData);
expect(mockRedis.get).toHaveBeenCalledWith('user:1');
});
it('キャッシュが存在しない場合、nullを返す', async () => {
mockRedis.get.mockImplementation(() =>
Promise.resolve(null)
);
const result = await cacheService.get('nonexistent');
expect(result).toBeNull();
});
});
describe('set', () => {
it('有効期限付きでキャッシュを設定する', async () => {
const testData = { id: 1, name: '田中太郎' };
mockRedis.setex.mockImplementation(() =>
Promise.resolve('OK')
);
await cacheService.set('user:1', testData, 3600);
expect(mockRedis.setex).toHaveBeenCalledWith(
'user:1',
3600,
JSON.stringify(testData)
);
});
it('有効期限なしでキャッシュを設定する', async () => {
const testData = { id: 1, name: '田中太郎' };
mockRedis.set.mockImplementation(() =>
Promise.resolve('OK')
);
await cacheService.set('user:1', testData);
expect(mockRedis.set).toHaveBeenCalledWith(
'user:1',
JSON.stringify(testData)
);
});
});
describe('delete', () => {
it('キャッシュを削除する', async () => {
mockRedis.del.mockImplementation(() =>
Promise.resolve(1)
);
const result = await cacheService.delete('user:1');
expect(result).toBe(true);
expect(mockRedis.del).toHaveBeenCalledWith('user:1');
});
it('存在しないキーの場合、falseを返す', async () => {
mockRedis.del.mockImplementation(() =>
Promise.resolve(0)
);
const result = await cacheService.delete(
'nonexistent'
);
expect(result).toBe(false);
});
});
describe('exists', () => {
it('キーが存在する場合、trueを返す', async () => {
mockRedis.exists.mockImplementation(() =>
Promise.resolve(1)
);
const result = await cacheService.exists('user:1');
expect(result).toBe(true);
});
it('キーが存在しない場合、falseを返す', async () => {
mockRedis.exists.mockImplementation(() =>
Promise.resolve(0)
);
const result = await cacheService.exists(
'nonexistent'
);
expect(result).toBe(false);
});
});
});
非同期処理とプロミスのモック事例
Promise.all と Promise.race のモック
複数の非同期処理を並行実行するコードをテストする際、個々のプロミスの結果をコントロールしてテストします。
typescript// services/batchProcessingService.ts
export class BatchProcessingService {
constructor(
private apiClient: any,
private logger: any
) {}
async processUsersInParallel(userIds: string[]): Promise<{
results: Array<{
userId: string;
success: boolean;
data?: any;
error?: string;
}>;
summary: {
total: number;
success: number;
failed: number;
};
}> {
const promises = userIds.map(async (userId) => {
try {
const userData = await this.apiClient.fetchUser(
userId
);
const processedData =
await this.apiClient.processUser(userData);
this.logger.info(
`ユーザー ${userId} の処理が完了しました`
);
return {
userId,
success: true,
data: processedData,
};
} catch (error) {
this.logger.error(
`ユーザー ${userId} の処理でエラー: ${error}`
);
return {
userId,
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error',
};
}
});
const results = await Promise.all(promises);
const summary = {
total: results.length,
success: results.filter((r) => r.success).length,
failed: results.filter((r) => !r.success).length,
};
return { results, summary };
}
async processWithTimeout(
userIds: string[],
timeoutMs: number
): Promise<{
completed: boolean;
results?: any;
error?: string;
}> {
const processingPromise =
this.processUsersInParallel(userIds);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(
() =>
reject(new Error('処理がタイムアウトしました')),
timeoutMs
)
);
try {
const results = await Promise.race([
processingPromise,
timeoutPromise,
]);
return { completed: true, results };
} catch (error) {
return {
completed: false,
error:
error instanceof Error
? error.message
: 'Unknown error',
};
}
}
async processWithRetry(
userId: string,
maxRetries: number = 3
): Promise<{
success: boolean;
data?: any;
retriesUsed: number;
}> {
let retriesUsed = 0;
for (
let attempt = 0;
attempt <= maxRetries;
attempt++
) {
try {
const userData = await this.apiClient.fetchUser(
userId
);
const processedData =
await this.apiClient.processUser(userData);
return {
success: true,
data: processedData,
retriesUsed,
};
} catch (error) {
retriesUsed++;
if (attempt === maxRetries) {
return {
success: false,
retriesUsed,
};
}
// 指数バックオフで待機
const waitTime = Math.pow(2, attempt) * 1000;
await new Promise((resolve) =>
setTimeout(resolve, waitTime)
);
}
}
return { success: false, retriesUsed };
}
}
typescript// __tests__/batchProcessingService.test.ts
import { BatchProcessingService } from '../services/batchProcessingService';
describe('BatchProcessingService', () => {
let batchProcessingService: BatchProcessingService;
let mockApiClient: any;
let mockLogger: any;
beforeEach(() => {
mockApiClient = {
fetchUser: jest.fn(),
processUser: jest.fn(),
};
mockLogger = {
info: jest.fn(),
error: jest.fn(),
};
batchProcessingService = new BatchProcessingService(
mockApiClient,
mockLogger
);
// タイマーモックを有効化
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('processUsersInParallel', () => {
it('全てのユーザーが正常に処理される場合', async () => {
const userIds = ['user1', 'user2', 'user3'];
mockApiClient.fetchUser.mockImplementation(
(userId: string) => {
return Promise.resolve({
id: userId,
name: `User ${userId}`,
});
}
);
mockApiClient.processUser.mockImplementation(
(userData: any) => {
return Promise.resolve({
processed: true,
userId: userData.id,
});
}
);
const result =
await batchProcessingService.processUsersInParallel(
userIds
);
expect(result.summary.total).toBe(3);
expect(result.summary.success).toBe(3);
expect(result.summary.failed).toBe(0);
expect(result.results).toHaveLength(3);
expect(result.results.every((r) => r.success)).toBe(
true
);
});
it('一部のユーザー処理が失敗する場合', async () => {
const userIds = ['user1', 'user2', 'user3'];
mockApiClient.fetchUser.mockImplementation(
(userId: string) => {
if (userId === 'user2') {
return Promise.reject(
new Error('ユーザーが見つかりません')
);
}
return Promise.resolve({
id: userId,
name: `User ${userId}`,
});
}
);
mockApiClient.processUser.mockImplementation(
(userData: any) => {
return Promise.resolve({
processed: true,
userId: userData.id,
});
}
);
const result =
await batchProcessingService.processUsersInParallel(
userIds
);
expect(result.summary.total).toBe(3);
expect(result.summary.success).toBe(2);
expect(result.summary.failed).toBe(1);
const failedResult = result.results.find(
(r) => r.userId === 'user2'
);
expect(failedResult?.success).toBe(false);
expect(failedResult?.error).toBe(
'ユーザーが見つかりません'
);
});
});
describe('processWithTimeout', () => {
it('タイムアウト時間内に処理が完了する場合', async () => {
const userIds = ['user1'];
mockApiClient.fetchUser.mockImplementation(() =>
Promise.resolve({ id: 'user1', name: 'User 1' })
);
mockApiClient.processUser.mockImplementation(() =>
Promise.resolve({ processed: true })
);
const resultPromise =
batchProcessingService.processWithTimeout(
userIds,
5000
);
// 少し時間を進める(タイムアウトより短い)
jest.advanceTimersByTime(1000);
const result = await resultPromise;
expect(result.completed).toBe(true);
expect(result.results).toBeDefined();
expect(result.error).toBeUndefined();
});
it('タイムアウトが発生する場合', async () => {
const userIds = ['user1'];
// 処理が非常に遅いAPIをモック
mockApiClient.fetchUser.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() => resolve({ id: 'user1' }),
10000
)
)
);
const resultPromise =
batchProcessingService.processWithTimeout(
userIds,
2000
);
// タイムアウト時間を経過させる
jest.advanceTimersByTime(2000);
const result = await resultPromise;
expect(result.completed).toBe(false);
expect(result.error).toBe(
'処理がタイムアウトしました'
);
expect(result.results).toBeUndefined();
});
});
describe('processWithRetry', () => {
it('最初の試行で成功する場合', async () => {
mockApiClient.fetchUser.mockImplementation(() =>
Promise.resolve({ id: 'user1', name: 'User 1' })
);
mockApiClient.processUser.mockImplementation(() =>
Promise.resolve({ processed: true })
);
const result =
await batchProcessingService.processWithRetry(
'user1',
3
);
expect(result.success).toBe(true);
expect(result.retriesUsed).toBe(0);
expect(result.data).toEqual({ processed: true });
});
it('リトライ後に成功する場合', async () => {
let attemptCount = 0;
mockApiClient.fetchUser.mockImplementation(() => {
attemptCount++;
if (attemptCount <= 2) {
return Promise.reject(
new Error('一時的なエラー')
);
}
return Promise.resolve({
id: 'user1',
name: 'User 1',
});
});
mockApiClient.processUser.mockImplementation(() =>
Promise.resolve({ processed: true })
);
const resultPromise =
batchProcessingService.processWithRetry('user1', 3);
// 指数バックオフの待機時間をスキップ
jest.runAllTimers();
const result = await resultPromise;
expect(result.success).toBe(true);
expect(result.retriesUsed).toBe(2);
expect(attemptCount).toBe(3);
});
it('最大リトライ回数を超えて失敗する場合', async () => {
mockApiClient.fetchUser.mockImplementation(() =>
Promise.reject(new Error('恒久的なエラー'))
);
const resultPromise =
batchProcessingService.processWithRetry('user1', 2);
// 全ての待機時間をスキップ
jest.runAllTimers();
const result = await resultPromise;
expect(result.success).toBe(false);
expect(result.retriesUsed).toBe(2);
expect(result.data).toBeUndefined();
});
});
});
まとめ
この記事では、Jest の mockImplementation
を活用した実践的な事例を 7 つの領域に分けて解説しました。
外部 API の通信からファイルシステム操作、時間依存の処理、複雑な条件分岐、React コンポーネント、Node.js バックエンド、非同期処理まで、様々なシナリオでのモック活用法をご紹介しました。
mockImplementation
の真価は、実際のコードでは制御が困難な外部依存関係を自在に操ることで、確実で高速なテストを実現できる点にあります。特に以下のようなケースで威力を発揮しますね。
- エラーケースの網羅的なテスト: ネットワークエラーやファイルアクセスエラーなど、本来再現が困難なエラー状況を簡単にシミュレートできます
- 時間に依存する処理の安定化: 現在時刻やタイマーを固定値でモックすることで、テスト実行時間に関係なく一貫した結果を得られます
- 外部サービスからの独立: データベースや外部 API に依存せず、高速で信頼性の高いテストを作成できます
実際のプロジェクトでは、これらの事例を参考にしながら、皆さんのアプリケーションの特性に合わせてモック戦略を調整していってください。適切なモックの活用により、開発効率の向上とコードの品質確保を両立できるはずです。
関連リンク
- blog
TDDって結局何がいいの?コードに自信が持てる、テスト駆動開発のはじめの一歩
- blog
「昨日やったこと、今日やること」の報告会じゃない!デイリースクラムをチームのエンジンにするための3つの問いかけ
- blog
燃え尽きるのは誰だ?バーンダウンチャートでプロジェクトの「ヤバさ」をチームで共有する方法
- blog
「誰が、何を、なぜ」が伝わらないユーザーストーリーは無意味。開発者が本当に欲しいストーリーの書き方
- blog
「誰が何するんだっけ?」をなくす。スクラムの役割とイベント、最初にこれだけは押さえておきたいこと
- blog
スクラムチーム、本当に機能してる?PO・SM・開発チームの「あるべき姿」と現実のギャップ