Day 9: 実践プロジェクト
今日学ぶこと
- Todo Appを題材に、Day 1〜8の知識を総合的に活用する
- ビジネスロジック層(TodoService)のユニットテスト
- 外部API層(TodoAPI)のモックとインテグレーションテスト
- Reactコンポーネント(TodoApp)のテスト
- テストの整理・構造化のベストプラクティス
- カバレッジレポートの確認
プロジェクト概要
今日は、シンプルなTodo Appを構築しながら、これまでに学んだテスト技法をすべて実践します。
flowchart TB
subgraph UI["UIレイヤー"]
COMP["TodoApp\nReactコンポーネント"]
end
subgraph BIZ["ビジネスロジック層"]
SVC["TodoService\nCRUD操作・フィルタリング"]
end
subgraph DATA["データ層"]
API["TodoAPI\n外部APIとの通信"]
end
COMP --> SVC
SVC --> API
style UI fill:#3b82f6,color:#fff
style BIZ fill:#8b5cf6,color:#fff
style DATA fill:#22c55e,color:#fff
機能一覧
| 機能 | 説明 |
|---|---|
| 追加 | 新しいTodoを作成する |
| 切り替え | Todoの完了/未完了を切り替える |
| 削除 | Todoを削除する |
| フィルタリング | 全て / アクティブ / 完了済みで絞り込む |
ディレクトリ構成
src/
├── todo/
│ ├── TodoAPI.js # 外部API通信
│ ├── TodoService.js # ビジネスロジック
│ ├── TodoApp.jsx # Reactコンポーネント
│ └── __tests__/
│ ├── TodoAPI.test.js
│ ├── TodoService.test.js
│ ├── TodoApp.test.jsx
│ └── integration.test.js
Step 1: データ型の定義
まず、Todoのデータ構造を定義します。
// todo/types.js
/**
* @typedef {Object} Todo
* @property {string} id
* @property {string} text
* @property {boolean} completed
* @property {string} createdAt
*/
TypeScript版:
// todo/types.ts
export interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: string;
}
export type FilterType = 'all' | 'active' | 'completed';
Step 2: TodoAPI — 外部API層
外部APIとの通信を担当するモジュールです。テストではこのモジュールをモックします。
// todo/TodoAPI.js
const API_BASE = 'https://api.example.com/todos';
const TodoAPI = {
async fetchAll() {
const res = await fetch(API_BASE);
if (!res.ok) throw new Error('Failed to fetch todos');
return res.json();
},
async create(text) {
const res = await fetch(API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, completed: false }),
});
if (!res.ok) throw new Error('Failed to create todo');
return res.json();
},
async update(id, updates) {
const res = await fetch(`${API_BASE}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error('Failed to update todo');
return res.json();
},
async remove(id) {
const res = await fetch(`${API_BASE}/${id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete todo');
},
};
module.exports = TodoAPI;
TodoAPIのユニットテスト
global.fetch をモックして、APIモジュールを単独でテストします。
// todo/__tests__/TodoAPI.test.js
const TodoAPI = require('../TodoAPI');
// global.fetch をモック
global.fetch = jest.fn();
describe('TodoAPI', () => {
afterEach(() => {
jest.resetAllMocks();
});
describe('fetchAll', () => {
test('returns todos on success', async () => {
const mockTodos = [
{ id: '1', text: 'Learn Jest', completed: false },
];
fetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockTodos),
});
const result = await TodoAPI.fetchAll();
expect(result).toEqual(mockTodos);
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/todos'
);
});
test('throws error on failure', async () => {
fetch.mockResolvedValue({ ok: false });
await expect(TodoAPI.fetchAll()).rejects.toThrow(
'Failed to fetch todos'
);
});
});
describe('create', () => {
test('sends POST request and returns new todo', async () => {
const newTodo = {
id: '2',
text: 'Write tests',
completed: false,
};
fetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(newTodo),
});
const result = await TodoAPI.create('Write tests');
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/todos',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
text: 'Write tests',
completed: false,
}),
})
);
expect(result).toEqual(newTodo);
});
});
describe('remove', () => {
test('sends DELETE request', async () => {
fetch.mockResolvedValue({ ok: true });
await TodoAPI.remove('1');
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/todos/1',
expect.objectContaining({ method: 'DELETE' })
);
});
test('throws error on failure', async () => {
fetch.mockResolvedValue({ ok: false });
await expect(TodoAPI.remove('1')).rejects.toThrow(
'Failed to delete todo'
);
});
});
});
flowchart LR
subgraph Test["テスト"]
T["TodoAPI.test.js"]
end
subgraph Target["テスト対象"]
A["TodoAPI.js"]
end
subgraph Mock["モック"]
F["global.fetch\n(jest.fn())"]
end
T --> A --> F
style Test fill:#3b82f6,color:#fff
style Target fill:#8b5cf6,color:#fff
style Mock fill:#f59e0b,color:#fff
Step 3: TodoService — ビジネスロジック層
TodoServiceはTodoAPIを利用してビジネスロジックを実装します。
// todo/TodoService.js
const TodoAPI = require('./TodoAPI');
class TodoService {
constructor() {
this.todos = [];
}
async loadTodos() {
this.todos = await TodoAPI.fetchAll();
return this.todos;
}
async addTodo(text) {
if (!text || text.trim() === '') {
throw new Error('Todo text cannot be empty');
}
const newTodo = await TodoAPI.create(text.trim());
this.todos.push(newTodo);
return newTodo;
}
async toggleTodo(id) {
const todo = this.todos.find(t => t.id === id);
if (!todo) {
throw new Error(`Todo with id ${id} not found`);
}
const updated = await TodoAPI.update(id, {
completed: !todo.completed,
});
todo.completed = updated.completed;
return todo;
}
async deleteTodo(id) {
await TodoAPI.remove(id);
this.todos = this.todos.filter(t => t.id !== id);
}
getFilteredTodos(filter = 'all') {
switch (filter) {
case 'active':
return this.todos.filter(t => !t.completed);
case 'completed':
return this.todos.filter(t => t.completed);
default:
return [...this.todos];
}
}
getStats() {
const total = this.todos.length;
const completed = this.todos.filter(t => t.completed).length;
const active = total - completed;
return { total, completed, active };
}
}
module.exports = TodoService;
TypeScript版:
// todo/TodoService.ts
import * as TodoAPI from './TodoAPI';
import { Todo, FilterType } from './types';
export class TodoService {
private todos: Todo[] = [];
async loadTodos(): Promise<Todo[]> {
this.todos = await TodoAPI.fetchAll();
return this.todos;
}
async addTodo(text: string): Promise<Todo> {
if (!text || text.trim() === '') {
throw new Error('Todo text cannot be empty');
}
const newTodo = await TodoAPI.create(text.trim());
this.todos.push(newTodo);
return newTodo;
}
async toggleTodo(id: string): Promise<Todo> {
const todo = this.todos.find(t => t.id === id);
if (!todo) {
throw new Error(`Todo with id ${id} not found`);
}
const updated = await TodoAPI.update(id, {
completed: !todo.completed,
});
todo.completed = updated.completed;
return todo;
}
async deleteTodo(id: string): Promise<void> {
await TodoAPI.remove(id);
this.todos = this.todos.filter(t => t.id !== id);
}
getFilteredTodos(filter: FilterType = 'all'): Todo[] {
switch (filter) {
case 'active':
return this.todos.filter(t => !t.completed);
case 'completed':
return this.todos.filter(t => t.completed);
default:
return [...this.todos];
}
}
getStats(): { total: number; completed: number; active: number } {
const total = this.todos.length;
const completed = this.todos.filter(t => t.completed).length;
const active = total - completed;
return { total, completed, active };
}
}
TodoServiceのユニットテスト
jest.mock() でTodoAPIモジュール全体をモックし、ビジネスロジックだけをテストします。
// todo/__tests__/TodoService.test.js
const TodoService = require('../TodoService');
const TodoAPI = require('../TodoAPI');
// TodoAPI モジュール全体をモック
jest.mock('../TodoAPI');
describe('TodoService', () => {
let service;
beforeEach(() => {
service = new TodoService();
jest.clearAllMocks();
});
// ----- loadTodos -----
describe('loadTodos', () => {
test('loads todos from API', async () => {
const mockTodos = [
{ id: '1', text: 'Learn Jest', completed: false },
{ id: '2', text: 'Write tests', completed: true },
];
TodoAPI.fetchAll.mockResolvedValue(mockTodos);
const result = await service.loadTodos();
expect(result).toEqual(mockTodos);
expect(TodoAPI.fetchAll).toHaveBeenCalledTimes(1);
});
test('propagates API errors', async () => {
TodoAPI.fetchAll.mockRejectedValue(
new Error('Network error')
);
await expect(service.loadTodos()).rejects.toThrow(
'Network error'
);
});
});
// ----- addTodo -----
describe('addTodo', () => {
test('adds a new todo', async () => {
const newTodo = {
id: '3',
text: 'New task',
completed: false,
createdAt: '2025-01-01',
};
TodoAPI.create.mockResolvedValue(newTodo);
const result = await service.addTodo('New task');
expect(result).toEqual(newTodo);
expect(TodoAPI.create).toHaveBeenCalledWith('New task');
expect(service.getStats().total).toBe(1);
});
test('trims whitespace from text', async () => {
TodoAPI.create.mockResolvedValue({
id: '4',
text: 'Trimmed',
completed: false,
});
await service.addTodo(' Trimmed ');
expect(TodoAPI.create).toHaveBeenCalledWith('Trimmed');
});
test('throws error for empty text', async () => {
await expect(service.addTodo('')).rejects.toThrow(
'Todo text cannot be empty'
);
expect(TodoAPI.create).not.toHaveBeenCalled();
});
test('throws error for whitespace-only text', async () => {
await expect(service.addTodo(' ')).rejects.toThrow(
'Todo text cannot be empty'
);
});
});
// ----- toggleTodo -----
describe('toggleTodo', () => {
beforeEach(async () => {
TodoAPI.fetchAll.mockResolvedValue([
{ id: '1', text: 'Task 1', completed: false },
{ id: '2', text: 'Task 2', completed: true },
]);
await service.loadTodos();
});
test('toggles incomplete to complete', async () => {
TodoAPI.update.mockResolvedValue({
id: '1',
completed: true,
});
const result = await service.toggleTodo('1');
expect(result.completed).toBe(true);
expect(TodoAPI.update).toHaveBeenCalledWith('1', {
completed: true,
});
});
test('toggles complete to incomplete', async () => {
TodoAPI.update.mockResolvedValue({
id: '2',
completed: false,
});
const result = await service.toggleTodo('2');
expect(result.completed).toBe(false);
});
test('throws error for non-existent id', async () => {
await expect(service.toggleTodo('999')).rejects.toThrow(
'Todo with id 999 not found'
);
});
});
// ----- deleteTodo -----
describe('deleteTodo', () => {
beforeEach(async () => {
TodoAPI.fetchAll.mockResolvedValue([
{ id: '1', text: 'Task 1', completed: false },
{ id: '2', text: 'Task 2', completed: true },
]);
await service.loadTodos();
});
test('removes todo from list', async () => {
TodoAPI.remove.mockResolvedValue();
await service.deleteTodo('1');
expect(service.getStats().total).toBe(1);
expect(TodoAPI.remove).toHaveBeenCalledWith('1');
});
});
// ----- getFilteredTodos -----
describe('getFilteredTodos', () => {
beforeEach(async () => {
TodoAPI.fetchAll.mockResolvedValue([
{ id: '1', text: 'Active 1', completed: false },
{ id: '2', text: 'Done 1', completed: true },
{ id: '3', text: 'Active 2', completed: false },
]);
await service.loadTodos();
});
test('returns all todos with "all" filter', () => {
const result = service.getFilteredTodos('all');
expect(result).toHaveLength(3);
});
test('returns only active todos', () => {
const result = service.getFilteredTodos('active');
expect(result).toHaveLength(2);
expect(result.every(t => !t.completed)).toBe(true);
});
test('returns only completed todos', () => {
const result = service.getFilteredTodos('completed');
expect(result).toHaveLength(1);
expect(result[0].text).toBe('Done 1');
});
test('defaults to "all" filter', () => {
const result = service.getFilteredTodos();
expect(result).toHaveLength(3);
});
});
// ----- getStats -----
describe('getStats', () => {
test('returns correct statistics', async () => {
TodoAPI.fetchAll.mockResolvedValue([
{ id: '1', text: 'A', completed: false },
{ id: '2', text: 'B', completed: true },
{ id: '3', text: 'C', completed: false },
]);
await service.loadTodos();
expect(service.getStats()).toEqual({
total: 3,
completed: 1,
active: 2,
});
});
test('returns zeros for empty list', () => {
expect(service.getStats()).toEqual({
total: 0,
completed: 0,
active: 0,
});
});
});
});
flowchart TB
subgraph Tests["テストスイート構成"]
L["loadTodos\n2テスト"]
A["addTodo\n4テスト"]
T["toggleTodo\n3テスト"]
D["deleteTodo\n1テスト"]
F["getFilteredTodos\n4テスト"]
S["getStats\n2テスト"]
end
subgraph Mock["モック化された依存"]
API["TodoAPI\n(jest.mock)"]
end
L --> API
A --> API
T --> API
D --> API
style Tests fill:#3b82f6,color:#fff
style Mock fill:#f59e0b,color:#fff
Step 4: インテグレーションテスト
TodoServiceとTodoAPIを組み合わせたインテグレーションテストでは、global.fetch だけをモックし、モジュール間の連携を検証します。
// todo/__tests__/integration.test.js
const TodoService = require('../TodoService');
// jest.mock('../TodoAPI') は使わない!
// fetch だけをモックして、実際のモジュール連携をテスト
global.fetch = jest.fn();
describe('TodoService Integration', () => {
let service;
beforeEach(() => {
service = new TodoService();
jest.clearAllMocks();
});
test('full workflow: load, add, toggle, delete', async () => {
// 1. Load initial todos
fetch.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue([
{ id: '1', text: 'Existing', completed: false },
]),
});
await service.loadTodos();
expect(service.getStats().total).toBe(1);
// 2. Add a new todo
fetch.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue({
id: '2',
text: 'New task',
completed: false,
}),
});
await service.addTodo('New task');
expect(service.getStats().total).toBe(2);
expect(service.getStats().active).toBe(2);
// 3. Toggle the first todo
fetch.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue({
id: '1',
completed: true,
}),
});
await service.toggleTodo('1');
expect(service.getStats().completed).toBe(1);
expect(service.getStats().active).toBe(1);
// 4. Filter: only active
const active = service.getFilteredTodos('active');
expect(active).toHaveLength(1);
expect(active[0].text).toBe('New task');
// 5. Delete the completed todo
fetch.mockResolvedValueOnce({ ok: true });
await service.deleteTodo('1');
expect(service.getStats().total).toBe(1);
// Verify total fetch calls
expect(fetch).toHaveBeenCalledTimes(4);
});
test('handles API failure during add gracefully', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValue([]),
});
await service.loadTodos();
fetch.mockResolvedValueOnce({ ok: false });
await expect(service.addTodo('Will fail')).rejects.toThrow(
'Failed to create todo'
);
// State should remain unchanged
expect(service.getStats().total).toBe(0);
});
});
flowchart LR
subgraph Integration["インテグレーションテスト"]
T["テスト"]
end
subgraph Real["実際のモジュール"]
SVC["TodoService"]
API["TodoAPI"]
end
subgraph Mock["モックのみ"]
F["global.fetch"]
end
T --> SVC --> API --> F
style Integration fill:#22c55e,color:#fff
style Real fill:#8b5cf6,color:#fff
style Mock fill:#f59e0b,color:#fff
ユニットテスト vs インテグレーションテスト: ユニットテストでは
jest.mock('../TodoAPI')でAPI層を完全にモックし、ビジネスロジックだけを検証します。インテグレーションテストではfetchのみをモックし、TodoService → TodoAPI の連携が正しく動作するかを確認します。
Step 5: Reactコンポーネント — TodoApp
// todo/TodoApp.jsx
import React, { useState, useEffect } from 'react';
export default function TodoApp({ todoService }) {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
const [inputText, setInputText] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
todoService
.loadTodos()
.then(loaded => {
setTodos(loaded);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [todoService]);
const handleAdd = async () => {
try {
setError('');
const newTodo = await todoService.addTodo(inputText);
setTodos(prev => [...prev, newTodo]);
setInputText('');
} catch (err) {
setError(err.message);
}
};
const handleToggle = async (id) => {
const updated = await todoService.toggleTodo(id);
setTodos(prev =>
prev.map(t => (t.id === id ? { ...t, completed: updated.completed } : t))
);
};
const handleDelete = async (id) => {
await todoService.deleteTodo(id);
setTodos(prev => prev.filter(t => t.id !== id));
};
const filtered = todos.filter(t => {
if (filter === 'active') return !t.completed;
if (filter === 'completed') return t.completed;
return true;
});
if (loading) return <div role="status">Loading...</div>;
return (
<div>
<h1>Todo App</h1>
{error && <div role="alert">{error}</div>}
<div>
<input
type="text"
placeholder="What needs to be done?"
value={inputText}
onChange={e => setInputText(e.target.value)}
aria-label="New todo"
/>
<button onClick={handleAdd}>Add</button>
</div>
<nav aria-label="Filter">
{['all', 'active', 'completed'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
aria-pressed={filter === f}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</nav>
<ul>
{filtered.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
aria-label={`Toggle ${todo.text}`}
/>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none',
}}>
{todo.text}
</span>
<button
onClick={() => handleDelete(todo.id)}
aria-label={`Delete ${todo.text}`}
>
Delete
</button>
</li>
))}
</ul>
<p>{todos.filter(t => !t.completed).length} items left</p>
</div>
);
}
Reactコンポーネントのテスト
Testing Libraryを使って、ユーザー操作の観点からテストします。
// todo/__tests__/TodoApp.test.jsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoApp from '../TodoApp';
// TodoService のモック
function createMockService(initialTodos = []) {
return {
loadTodos: jest.fn().mockResolvedValue(initialTodos),
addTodo: jest.fn(),
toggleTodo: jest.fn(),
deleteTodo: jest.fn(),
getFilteredTodos: jest.fn(),
getStats: jest.fn(),
};
}
describe('TodoApp', () => {
// ----- 初期表示 -----
test('shows loading state then renders todos', async () => {
const mockService = createMockService([
{ id: '1', text: 'Learn Jest', completed: false },
{ id: '2', text: 'Write tests', completed: true },
]);
render(<TodoApp todoService={mockService} />);
// Loading state
expect(screen.getByRole('status')).toHaveTextContent(
'Loading...'
);
// After loading
await waitFor(() => {
expect(screen.getByText('Learn Jest')).toBeInTheDocument();
});
expect(screen.getByText('Write tests')).toBeInTheDocument();
expect(screen.getByText('1 items left')).toBeInTheDocument();
});
// ----- Todo追加 -----
test('adds a new todo', async () => {
const user = userEvent.setup();
const mockService = createMockService([]);
mockService.addTodo.mockResolvedValue({
id: '1',
text: 'New task',
completed: false,
});
render(<TodoApp todoService={mockService} />);
await waitFor(() => {
expect(
screen.queryByRole('status')
).not.toBeInTheDocument();
});
const input = screen.getByLabelText('New todo');
const addButton = screen.getByText('Add');
await user.type(input, 'New task');
await user.click(addButton);
expect(mockService.addTodo).toHaveBeenCalledWith('New task');
await waitFor(() => {
expect(screen.getByText('New task')).toBeInTheDocument();
});
expect(input).toHaveValue('');
});
// ----- エラーハンドリング -----
test('displays error when adding empty todo', async () => {
const user = userEvent.setup();
const mockService = createMockService([]);
mockService.addTodo.mockRejectedValue(
new Error('Todo text cannot be empty')
);
render(<TodoApp todoService={mockService} />);
await waitFor(() => {
expect(
screen.queryByRole('status')
).not.toBeInTheDocument();
});
await user.click(screen.getByText('Add'));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
'Todo text cannot be empty'
);
});
});
// ----- Todo切り替え -----
test('toggles a todo', async () => {
const user = userEvent.setup();
const mockService = createMockService([
{ id: '1', text: 'Task 1', completed: false },
]);
mockService.toggleTodo.mockResolvedValue({
id: '1',
completed: true,
});
render(<TodoApp todoService={mockService} />);
await waitFor(() => {
expect(screen.getByText('Task 1')).toBeInTheDocument();
});
const checkbox = screen.getByLabelText('Toggle Task 1');
await user.click(checkbox);
expect(mockService.toggleTodo).toHaveBeenCalledWith('1');
});
// ----- Todo削除 -----
test('deletes a todo', async () => {
const user = userEvent.setup();
const mockService = createMockService([
{ id: '1', text: 'Task 1', completed: false },
]);
mockService.deleteTodo.mockResolvedValue();
render(<TodoApp todoService={mockService} />);
await waitFor(() => {
expect(screen.getByText('Task 1')).toBeInTheDocument();
});
await user.click(screen.getByLabelText('Delete Task 1'));
await waitFor(() => {
expect(
screen.queryByText('Task 1')
).not.toBeInTheDocument();
});
});
// ----- フィルタリング -----
test('filters todos by status', async () => {
const user = userEvent.setup();
const mockService = createMockService([
{ id: '1', text: 'Active task', completed: false },
{ id: '2', text: 'Done task', completed: true },
]);
render(<TodoApp todoService={mockService} />);
await waitFor(() => {
expect(screen.getByText('Active task')).toBeInTheDocument();
});
// Show completed only
await user.click(screen.getByText('Completed'));
expect(
screen.queryByText('Active task')
).not.toBeInTheDocument();
expect(screen.getByText('Done task')).toBeInTheDocument();
// Show active only
await user.click(screen.getByText('Active'));
expect(screen.getByText('Active task')).toBeInTheDocument();
expect(
screen.queryByText('Done task')
).not.toBeInTheDocument();
// Show all
await user.click(screen.getByText('All'));
expect(screen.getByText('Active task')).toBeInTheDocument();
expect(screen.getByText('Done task')).toBeInTheDocument();
});
// ----- スナップショット -----
test('matches snapshot', async () => {
const mockService = createMockService([
{ id: '1', text: 'Snapshot test', completed: false },
]);
const { container } = render(
<TodoApp todoService={mockService} />
);
await waitFor(() => {
expect(
screen.getByText('Snapshot test')
).toBeInTheDocument();
});
expect(container).toMatchSnapshot();
});
// ----- ロードエラー -----
test('shows error when loading fails', async () => {
const mockService = createMockService();
mockService.loadTodos.mockRejectedValue(
new Error('Server unavailable')
);
render(<TodoApp todoService={mockService} />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
'Server unavailable'
);
});
});
});
flowchart TB
subgraph ComponentTests["コンポーネントテスト"]
INIT["初期表示\nローディング → 描画"]
ADD["Todo追加\n入力 → ボタン → 更新"]
TOG["切り替え\nチェックボックス"]
DEL["削除\nDeleteボタン"]
FIL["フィルタリング\nAll/Active/Completed"]
SNAP["スナップショット"]
ERR["エラー表示"]
end
subgraph Approach["テストアプローチ"]
UTL["Testing Library\nユーザー視点"]
UE["userEvent\n実際の操作"]
end
INIT --> UTL
ADD --> UE
TOG --> UE
DEL --> UE
FIL --> UE
style ComponentTests fill:#3b82f6,color:#fff
style Approach fill:#22c55e,color:#fff
Step 6: テスト構造のベストプラクティス
テストファイルの命名規則
| パターン | 例 | 用途 |
|---|---|---|
*.test.js |
TodoService.test.js |
ユニットテスト |
*.test.jsx |
TodoApp.test.jsx |
コンポーネントテスト |
integration.test.js |
integration.test.js |
インテグレーションテスト |
describe/test のネスト構造
describe('TodoService', () => {
// Feature group
describe('addTodo', () => {
// Happy path
test('adds a new todo with valid text', async () => {});
// Edge cases
test('trims whitespace from text', async () => {});
// Error cases
test('throws error for empty text', async () => {});
test('throws error for whitespace-only text', async () => {});
});
});
テストヘルパーの活用
テスト全体で共通するセットアップはヘルパー関数にまとめます。
// todo/__tests__/helpers.js
function createMockTodos(count = 3) {
return Array.from({ length: count }, (_, i) => ({
id: String(i + 1),
text: `Todo ${i + 1}`,
completed: i % 2 === 0,
createdAt: new Date().toISOString(),
}));
}
function createMockService(initialTodos = []) {
return {
loadTodos: jest.fn().mockResolvedValue(initialTodos),
addTodo: jest.fn(),
toggleTodo: jest.fn(),
deleteTodo: jest.fn(),
getFilteredTodos: jest.fn(),
getStats: jest.fn(),
};
}
module.exports = { createMockTodos, createMockService };
Step 7: カバレッジ付きでテスト実行
すべてのテストをカバレッジ付きで実行してみましょう。
npx jest --coverage --verbose todo/
出力例:
PASS todo/__tests__/TodoAPI.test.js
PASS todo/__tests__/TodoService.test.js
PASS todo/__tests__/integration.test.js
PASS todo/__tests__/TodoApp.test.jsx
Test Suites: 4 passed, 4 total
Tests: 22 passed, 22 total
-----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------------------|---------|----------|---------|---------|
All files | 100 | 100 | 100 | 100 |
TodoAPI.js | 100 | 100 | 100 | 100 |
TodoService.js | 100 | 100 | 100 | 100 |
TodoApp.jsx | 100 | 100 | 100 | 100 |
-----------------------|---------|----------|---------|---------|
flowchart TB
subgraph Coverage["カバレッジ100%の達成"]
U["ユニットテスト\nTodoAPI + TodoService"]
I["インテグレーションテスト\nService + API連携"]
C["コンポーネントテスト\nTodoApp"]
end
subgraph Result["結果"]
R["全22テスト合格\nカバレッジ100%"]
end
U --> R
I --> R
C --> R
style Coverage fill:#8b5cf6,color:#fff
style Result fill:#22c55e,color:#fff
テスト戦略まとめ
flowchart TB
subgraph Pyramid["テストピラミッド"]
E2E["E2Eテスト\n少数・高コスト"]
INT["インテグレーションテスト\n中程度"]
UNIT["ユニットテスト\n多数・低コスト"]
end
style E2E fill:#ef4444,color:#fff
style INT fill:#f59e0b,color:#fff
style UNIT fill:#22c55e,color:#fff
| テストレベル | モック対象 | 検証内容 | テスト数 |
|---|---|---|---|
| ユニット | 直接の依存(TodoAPI) | ビジネスロジック単体 | 多い |
| インテグレーション | 外部境界(fetch) | モジュール間連携 | 中程度 |
| コンポーネント | Service層 | UI操作とレンダリング | 中程度 |
まとめ
| 概念 | 説明 |
|---|---|
| レイヤー分離 | API層・ビジネスロジック層・UI層を分離してテスト容易性を確保 |
jest.mock() |
モジュール全体をモックしてユニットテストを独立させる |
jest.fn() |
モック関数でコールバックや依存を制御 |
| インテグレーションテスト | 最小限のモック(fetch)でモジュール連携を検証 |
| Testing Library | ユーザー視点でコンポーネントをテスト |
| カバレッジ | --coverage フラグで未テスト箇所を特定 |
| テスト構造 | describe/testのネスト、ヘルパー関数で整理 |
重要ポイント
- ビジネスロジックとAPI通信を分離すると、テストが書きやすくなる
- ユニットテストでは
jest.mock()で依存を置換し、テスト対象だけを検証する - インテグレーションテストでは外部境界のみモックし、内部連携を確認する
- コンポーネントテストではユーザーの操作をシミュレートして動作を検証する
- カバレッジ100%を目指すが、意味のあるテストを優先する
練習問題
問題1: 基本
TodoServiceに updateText(id, newText) メソッドを追加し、そのユニットテストを書いてください。空文字の場合はエラーをスローするようにしましょう。
問題2: 応用
TodoAppコンポーネントに「完了済みを一括削除」ボタンを追加し、Testing Libraryでテストを書いてください。
チャレンジ問題
TodoServiceに以下の機能を追加し、ユニットテストとインテグレーションテストの両方を書いてください。
searchTodos(query): テキストにqueryを含むTodoを返すsortTodos(field, order): 指定フィールドで並び替え('text' | 'createdAt'、'asc' | 'desc')
参考リンク
- Jest - Mock Functions
- Jest - Testing Asynchronous Code
- Testing Library - Queries
- Testing Library - User Event
- Kent C. Dodds - Write tests
次回予告: Day 10では「CI/CDとベストプラクティス」について学びます。GitHub Actionsでの自動テスト、テスト戦略の設計、パフォーマンス最適化など、実務で役立つ知識を総まとめします!