Learn Jest in 10 DaysDay 9: Hands-on Project
books.chapter 9Learn Jest in 10 Days

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 only fetch and 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

  1. Separating business logic from API communication makes testing easier
  2. In unit tests, use jest.mock() to replace dependencies and test only the target
  3. In integration tests, mock only the external boundary to verify internal interaction
  4. In component tests, simulate user actions to verify behavior
  5. 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 query
  • sortTodos(field, order): Sort by field ('text' | 'createdAt') in order ('asc' | 'desc')

References


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!