Day 9: Hands-on Project
What You'll Learn Today
- Apply everything from Days 1-8 by building and testing a Todo App
- Unit testing a business logic layer (TodoService)
- Mocking an external API layer (TodoAPI)
- Integration tests combining TodoService + TodoAPI
- React component tests for TodoApp using Testing Library
- Test organization and structure best practices
- Running all tests with coverage
Project Overview
Today we build a simple Todo App and write comprehensive tests using every technique we have learned so far.
flowchart TB
subgraph UI["UI Layer"]
COMP["TodoApp\nReact Component"]
end
subgraph BIZ["Business Logic Layer"]
SVC["TodoService\nCRUD & Filtering"]
end
subgraph DATA["Data Layer"]
API["TodoAPI\nExternal API Communication"]
end
COMP --> SVC
SVC --> API
style UI fill:#3b82f6,color:#fff
style BIZ fill:#8b5cf6,color:#fff
style DATA fill:#22c55e,color:#fff
Features
| Feature | Description |
|---|---|
| Add | Create a new todo |
| Toggle | Mark a todo as complete/incomplete |
| Delete | Remove a todo |
| Filter | Show all / active / completed todos |
Directory Structure
src/
βββ todo/
β βββ TodoAPI.js # External API communication
β βββ TodoService.js # Business logic
β βββ TodoApp.jsx # React component
β βββ __tests__/
β βββ TodoAPI.test.js
β βββ TodoService.test.js
β βββ TodoApp.test.jsx
β βββ integration.test.js
Step 1: Data Types
First, let's define the Todo data structure.
// todo/types.js
/**
* @typedef {Object} Todo
* @property {string} id
* @property {string} text
* @property {boolean} completed
* @property {string} createdAt
*/
TypeScript version:
// todo/types.ts
export interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: string;
}
export type FilterType = 'all' | 'active' | 'completed';
Step 2: TodoAPI β Data Layer
This module handles communication with the external API. In tests, we will mock this module.
// 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;
Unit Tests for TodoAPI
We mock global.fetch to test the API module in isolation.
// todo/__tests__/TodoAPI.test.js
const TodoAPI = require('../TodoAPI');
// Mock 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["Test"]
T["TodoAPI.test.js"]
end
subgraph Target["Under Test"]
A["TodoAPI.js"]
end
subgraph Mock["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 β Business Logic Layer
TodoService uses TodoAPI to implement business logic.
// 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 version:
// 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 };
}
}
Unit Tests for TodoService
We use jest.mock() to mock the entire TodoAPI module, testing business logic in isolation.
// todo/__tests__/TodoService.test.js
const TodoService = require('../TodoService');
const TodoAPI = require('../TodoAPI');
// Mock the entire TodoAPI module
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["Test Suite Structure"]
L["loadTodos\n2 tests"]
A["addTodo\n4 tests"]
T["toggleTodo\n3 tests"]
D["deleteTodo\n1 test"]
F["getFilteredTodos\n4 tests"]
S["getStats\n2 tests"]
end
subgraph Mock["Mocked Dependency"]
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: Integration Tests
In integration tests, we only mock global.fetch (not TodoAPI), so we can verify how TodoService and TodoAPI work together.
// todo/__tests__/integration.test.js
const TodoService = require('../TodoService');
// Do NOT use jest.mock('../TodoAPI')!
// Only mock fetch to test actual module interaction
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["Integration Test"]
T["Test"]
end
subgraph Real["Real Modules"]
SVC["TodoService"]
API["TodoAPI"]
end
subgraph Mock["Mock Only"]
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
Unit Tests vs Integration Tests: In unit tests, we use
jest.mock('../TodoAPI')to completely replace the API layer and verify only business logic. In integration tests, we mock onlyfetchand verify that TodoService and TodoAPI work together correctly.
Step 5: React Component β 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>
);
}
Component Tests
We use Testing Library to test from the user's perspective.
// 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';
// Mock TodoService factory
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', () => {
// ----- Initial render -----
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();
});
// ----- Adding a 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('');
});
// ----- Error handling -----
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'
);
});
});
// ----- Toggling a 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');
});
// ----- Deleting a 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();
});
});
// ----- Filtering -----
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();
});
// ----- Snapshot -----
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();
});
// ----- Load error -----
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["Component Tests"]
INIT["Initial Render\nLoading β Display"]
ADD["Add Todo\nInput β Button β Update"]
TOG["Toggle\nCheckbox"]
DEL["Delete\nDelete Button"]
FIL["Filter\nAll/Active/Completed"]
SNAP["Snapshot"]
ERR["Error Display"]
end
subgraph Approach["Testing Approach"]
UTL["Testing Library\nUser perspective"]
UE["userEvent\nReal interactions"]
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 Organization Best Practices
File Naming Conventions
| Pattern | Example | Purpose |
|---|---|---|
*.test.js |
TodoService.test.js |
Unit tests |
*.test.jsx |
TodoApp.test.jsx |
Component tests |
integration.test.js |
integration.test.js |
Integration tests |
Nesting describe/test Blocks
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 () => {});
});
});
Test Helpers
Extract common setup logic into helper functions shared across test files.
// 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: Running Tests with Coverage
Run all tests with coverage enabled.
npx jest --coverage --verbose todo/
Example output:
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["Achieving 100% Coverage"]
U["Unit Tests\nTodoAPI + TodoService"]
I["Integration Tests\nService + API interaction"]
C["Component Tests\nTodoApp"]
end
subgraph Result["Result"]
R["All 22 tests pass\n100% coverage"]
end
U --> R
I --> R
C --> R
style Coverage fill:#8b5cf6,color:#fff
style Result fill:#22c55e,color:#fff
Testing Strategy Summary
flowchart TB
subgraph Pyramid["Testing Pyramid"]
E2E["E2E Tests\nFew, expensive"]
INT["Integration Tests\nModerate"]
UNIT["Unit Tests\nMany, cheap"]
end
style E2E fill:#ef4444,color:#fff
style INT fill:#f59e0b,color:#fff
style UNIT fill:#22c55e,color:#fff
| Test Level | What's Mocked | What's Verified | Test Count |
|---|---|---|---|
| Unit | Direct dependency (TodoAPI) | Business logic alone | Many |
| Integration | External boundary (fetch) | Module interaction | Moderate |
| Component | Service layer | UI behavior and rendering | Moderate |
Summary
| Concept | Description |
|---|---|
| Layer separation | Separate API, business logic, and UI layers for testability |
jest.mock() |
Mock entire modules to isolate unit tests |
jest.fn() |
Create mock functions for callbacks and dependencies |
| Integration tests | Mock only fetch to verify module interaction |
| Testing Library | Test components from the user's perspective |
| Coverage | Use --coverage flag to find untested code |
| Test structure | Organize with describe/test nesting and helper functions |
Key Takeaways
- Separating business logic from API communication makes testing easier
- In unit tests, use
jest.mock()to replace dependencies and test only the target - In integration tests, mock only the external boundary to verify internal interaction
- In component tests, simulate user actions to verify behavior
- Aim for 100% coverage, but prioritize meaningful tests
Exercises
Exercise 1: Basic
Add an updateText(id, newText) method to TodoService and write unit tests for it. It should throw an error for empty text.
Exercise 2: Intermediate
Add a "Clear completed" button to the TodoApp component and write tests using Testing Library.
Challenge
Add the following features to TodoService and write both unit tests and integration tests:
searchTodos(query): Return todos whose text contains the querysortTodos(field, order): Sort by field ('text' | 'createdAt') in order ('asc' | 'desc')
References
- Jest - Mock Functions
- Jest - Testing Asynchronous Code
- Testing Library - Queries
- Testing Library - User Event
- Kent C. Dodds - Write tests
Next up: On Day 10, we'll cover "CI/CD and Best Practices" β automating tests with GitHub Actions, designing test strategies, performance optimization, and a final review of everything we've learned!