Introduction to Jest

JavaScript Testing Made Easy

The Safety Net

Imagine you're a trapeze artist performing high above the ground. Would you feel comfortable doing your tricks without a safety net? Of course not! In software development, tests are our safety net. They catch us when we fall (make mistakes) and give us confidence to perform more daring feats (write complex code).

graph TD A[Testing] --> B[Unit Tests] A --> C[Integration Tests] A --> D[End-to-End Tests] B --> B1[Test individual functions] B --> B2[Test components] B --> B3[Mock dependencies] C --> C1[Test module interactions] C --> C2[Test API calls] C --> C3[Test database operations] D --> D1[Test full user flows] D --> D2[Test browser interactions] D --> D3[Test across devices] style A fill:#f9f,stroke:#333,stroke-width:4px style B fill:#bbf,stroke:#333 style C fill:#bfb,stroke:#333 style D fill:#ffb,stroke:#333

Why Jest?

Jest is a delightful JavaScript testing framework created by Facebook. It's like having a Swiss Army knife for testing - it comes with everything you need right out of the box!

Key Features of Jest

Setting Up Jest

Let's get Jest up and running in your project:

# Install Jest as a dev dependency
npm install --save-dev jest

# For TypeScript support
npm install --save-dev @types/jest

# For React testing
npm install --save-dev @testing-library/react @testing-library/jest-dom

Package.json Configuration

// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "jsdom",
    "setupFilesAfterEnv": ["<rootDir>/src/setupTests.js"],
    "moduleNameMapper": {
      "\\.(css|less|scss|sass)$": "identity-obj-proxy"
    }
  }
}

Jest Configuration File

// jest.config.js
module.exports = {
  // Test environment
  testEnvironment: 'jsdom', // or 'node' for Node.js projects
  
  // Setup files
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  
  // Module paths
  moduleDirectories: ['node_modules', 'src'],
  
  // File extensions for modules
  moduleFileExtensions: ['js', 'jsx', 'json'],
  
  // Transform files with babel-jest
  transform: {
    '^.+\\.(js|jsx)$': 'babel-jest',
  },
  
  // Module name mapping for imports
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
  
  // Coverage configuration
  collectCoverageFrom: [
    'src/**/*.{js,jsx}',
    '!src/index.js',
    '!src/serviceWorker.js',
  ],
  
  // Test match patterns
  testMatch: [
    '<rootDir>/src/**/__tests__/**/*.{js,jsx}',
    '<rootDir>/src/**/*.{spec,test}.{js,jsx}',
  ],
  
  // Watch plugins
  watchPlugins: [
    'jest-watch-typeahead/filename',
    'jest-watch-typeahead/testname',
  ],
};

Your First Jest Test

Let's write our first test. Think of a test as a conversation with your code:

// math.js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

export function multiply(a, b) {
  return a * b;
}

export function divide(a, b) {
  if (b === 0) {
    throw new Error('Cannot divide by zero');
  }
  return a / b;
}
// math.test.js
import { add, subtract, multiply, divide } from './math';

describe('Math functions', () => {
  // Test suite for add function
  describe('add', () => {
    test('adds 1 + 2 to equal 3', () => {
      expect(add(1, 2)).toBe(3);
    });
    
    test('adds negative numbers correctly', () => {
      expect(add(-1, -1)).toBe(-2);
    });
  });
  
  // Test suite for subtract function
  describe('subtract', () => {
    test('subtracts 5 - 3 to equal 2', () => {
      expect(subtract(5, 3)).toBe(2);
    });
  });
  
  // Test suite for multiply function
  describe('multiply', () => {
    test('multiplies 2 * 3 to equal 6', () => {
      expect(multiply(2, 3)).toBe(6);
    });
  });
  
  // Test suite for divide function
  describe('divide', () => {
    test('divides 6 / 2 to equal 3', () => {
      expect(divide(6, 2)).toBe(3);
    });
    
    test('throws error when dividing by zero', () => {
      expect(() => divide(5, 0)).toThrow('Cannot divide by zero');
    });
  });
});

Understanding Test Structure

Jest tests follow a clear structure, like chapters in a book:

graph TD A[Test File] --> B[describe: Test Suite] B --> C[test/it: Individual Test] C --> D[Arrange: Setup] C --> E[Act: Execute] C --> F[Assert: Verify] D --> G[Create test data] D --> H[Mock dependencies] E --> I[Call function] E --> J[Trigger event] F --> K[expect statements] F --> L[Matchers] style A fill:#f9f,stroke:#333 style B fill:#bbf,stroke:#333 style C fill:#bfb,stroke:#333 style D fill:#ffb,stroke:#333 style E fill:#ffb,stroke:#333 style F fill:#ffb,stroke:#333

The AAA Pattern

test('user can update their profile', () => {
  // Arrange - Set up test data and conditions
  const user = {
    id: 1,
    name: 'John Doe',
    email: 'john@example.com'
  };
  
  // Act - Execute the code being tested
  const updatedUser = updateProfile(user, { name: 'John Smith' });
  
  // Assert - Check the results
  expect(updatedUser.name).toBe('John Smith');
  expect(updatedUser.email).toBe('john@example.com');
});

Jest Matchers

Matchers are like different lenses through which you examine your code's output. Jest provides a rich set of matchers:

Common Matchers

// Exact equality
expect(2 + 2).toBe(4);

// Object equality
expect({ name: 'John' }).toEqual({ name: 'John' });

// Truthiness
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(1).toBeDefined();

// Numbers
expect(10).toBeGreaterThan(5);
expect(5).toBeLessThan(10);
expect(5.5).toBeCloseTo(5.5, 2);

// Strings
expect('Hello World').toMatch(/World/);
expect('team').not.toMatch(/I/);

// Arrays and iterables
expect(['Apple', 'Banana', 'Orange']).toContain('Banana');
expect(new Set(['Apple', 'Banana'])).toContain('Apple');

// Exceptions
expect(() => {
  throw new Error('Invalid input');
}).toThrow('Invalid input');

// Async code
await expect(fetchData()).resolves.toBe('data');
await expect(fetchError()).rejects.toThrow('error');

Custom Matchers

// Create a custom matcher
expect.extend({
  toBeWithinRange(received, floor, ceiling) {
    const pass = received >= floor && received <= ceiling;
    if (pass) {
      return {
        message: () => `expected ${received} not to be within range ${floor} - ${ceiling}`,
        pass: true,
      };
    } else {
      return {
        message: () => `expected ${received} to be within range ${floor} - ${ceiling}`,
        pass: false,
      };
    }
  },
});

// Use the custom matcher
test('numeric ranges', () => {
  expect(100).toBeWithinRange(90, 110);
  expect(101).not.toBeWithinRange(0, 100);
});

Testing Asynchronous Code

Testing async code is like waiting for a pizza delivery - you need to handle the waiting period properly:

Promises

// Function that returns a promise
function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id === 1) {
        resolve({ id: 1, name: 'John Doe' });
      } else {
        reject(new Error('User not found'));
      }
    }, 100);
  });
}

// Testing with promises
test('fetches user successfully', () => {
  return fetchUser(1).then(user => {
    expect(user.name).toBe('John Doe');
  });
});

// Using .resolves/.rejects
test('fetches user successfully with resolves', () => {
  return expect(fetchUser(1)).resolves.toEqual({
    id: 1,
    name: 'John Doe'
  });
});

test('fails to fetch non-existent user', () => {
  return expect(fetchUser(999)).rejects.toThrow('User not found');
});

Async/Await

// Testing with async/await
test('fetches user with async/await', async () => {
  const user = await fetchUser(1);
  expect(user.name).toBe('John Doe');
});

test('handles errors with async/await', async () => {
  expect.assertions(1);
  try {
    await fetchUser(999);
  } catch (error) {
    expect(error.message).toBe('User not found');
  }
});

// Cleaner error handling
test('handles errors with async/await and expect', async () => {
  await expect(fetchUser(999)).rejects.toThrow('User not found');
});

Callbacks

// Function with callback
function fetchUserCallback(id, callback) {
  setTimeout(() => {
    if (id === 1) {
      callback(null, { id: 1, name: 'John Doe' });
    } else {
      callback(new Error('User not found'));
    }
  }, 100);
}

// Testing callbacks with done
test('fetches user with callback', done => {
  function callback(error, user) {
    if (error) {
      done(error);
      return;
    }
    try {
      expect(user.name).toBe('John Doe');
      done();
    } catch (error) {
      done(error);
    }
  }
  
  fetchUserCallback(1, callback);
});

Setup and Teardown

Like preparing a kitchen before cooking and cleaning up afterward, Jest provides hooks for setup and teardown:

describe('Database operations', () => {
  let db;
  
  // Run once before all tests in this describe block
  beforeAll(async () => {
    db = await createDatabaseConnection();
  });
  
  // Run once after all tests in this describe block
  afterAll(async () => {
    await db.close();
  });
  
  // Run before each test
  beforeEach(async () => {
    await db.clear();
    await db.seed();
  });
  
  // Run after each test
  afterEach(async () => {
    await db.cleanup();
  });
  
  test('creates a new user', async () => {
    const user = await db.createUser({ name: 'John' });
    expect(user.id).toBeDefined();
  });
  
  test('finds existing user', async () => {
    const user = await db.findUser(1);
    expect(user).toBeTruthy();
  });
});

// Scoped setup
describe('User service', () => {
  describe('when user is logged in', () => {
    beforeEach(() => {
      // Setup for logged in state
      mockAuth.login({ id: 1, role: 'user' });
    });
    
    test('can access profile', () => {
      // Test with logged in user
    });
  });
  
  describe('when user is admin', () => {
    beforeEach(() => {
      // Setup for admin state
      mockAuth.login({ id: 1, role: 'admin' });
    });
    
    test('can access admin panel', () => {
      // Test with admin user
    });
  });
});

Mocking in Jest

Mocking is like using stunt doubles in movies - we replace real dependencies with controlled substitutes:

Function Mocks

// Creating mock functions
const mockCallback = jest.fn();

// Using the mock
mockCallback('first call');
mockCallback('second call');

// Assertions on mock calls
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith('first call');

// Mock implementation
const mockAdd = jest.fn((a, b) => a + b);
expect(mockAdd(1, 2)).toBe(3);

// Mock return values
const mockRandom = jest.fn();
mockRandom
  .mockReturnValueOnce(0.1)
  .mockReturnValueOnce(0.5)
  .mockReturnValue(0.9);

console.log(mockRandom()); // 0.1
console.log(mockRandom()); // 0.5
console.log(mockRandom()); // 0.9
console.log(mockRandom()); // 0.9

// Mock resolved/rejected values
const mockFetch = jest.fn();
mockFetch.mockResolvedValue({ data: 'test' });
mockFetch.mockRejectedValue(new Error('Network error'));

Module Mocks

// api.js
export const fetchData = () => {
  return fetch('/api/data').then(res => res.json());
};

// userService.js
import { fetchData } from './api';

export const getUserData = async (userId) => {
  const data = await fetchData();
  return data.users.find(user => user.id === userId);
};

// userService.test.js
import { getUserData } from './userService';
import { fetchData } from './api';

// Mock the entire module
jest.mock('./api');

test('gets user data', async () => {
  // Mock the implementation
  fetchData.mockResolvedValue({
    users: [
      { id: 1, name: 'John' },
      { id: 2, name: 'Jane' }
    ]
  });
  
  const user = await getUserData(1);
  expect(user.name).toBe('John');
  expect(fetchData).toHaveBeenCalled();
});

Partial Mocks

// Mock only specific exports
jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'), // Keep all original exports
  calculateTotal: jest.fn(), // Mock only this function
}));

// Spy on existing methods
const math = require('./math');
const addSpy = jest.spyOn(math, 'add');

// Now math.add is being tracked
math.add(1, 2);
expect(addSpy).toHaveBeenCalledWith(1, 2);

// Restore original implementation
addSpy.mockRestore();

Testing Timers

When testing code with timers, Jest lets you control time like a time machine:

// Function with timer
function delayedGreeting(callback) {
  setTimeout(() => {
    callback('Hello after 1 second');
  }, 1000);
}

// Traditional approach (slow)
test('calls callback after 1 second', (done) => {
  delayedGreeting((greeting) => {
    expect(greeting).toBe('Hello after 1 second');
    done();
  });
});

// Using fake timers (fast)
test('calls callback after 1 second with fake timers', () => {
  jest.useFakeTimers();
  const callback = jest.fn();
  
  delayedGreeting(callback);
  
  // Fast-forward time
  jest.advanceTimersByTime(1000);
  
  expect(callback).toHaveBeenCalledWith('Hello after 1 second');
  
  jest.useRealTimers(); // Restore real timers
});

// Testing with intervals
function repeatGreeting(callback) {
  setInterval(() => {
    callback('Hello');
  }, 1000);
}

test('calls callback repeatedly', () => {
  jest.useFakeTimers();
  const callback = jest.fn();
  
  repeatGreeting(callback);
  
  // Fast-forward by 3 seconds
  jest.advanceTimersByTime(3000);
  
  expect(callback).toHaveBeenCalledTimes(3);
  
  jest.useRealTimers();
});

// Run all timers
test('runs all timers', () => {
  jest.useFakeTimers();
  const callback = jest.fn();
  
  setTimeout(callback, 1000);
  setTimeout(callback, 2000);
  
  jest.runAllTimers();
  
  expect(callback).toHaveBeenCalledTimes(2);
});

Snapshot Testing

Snapshot testing is like taking a photo of your UI and comparing it with previous photos to detect changes:

// Button.js
import React from 'react';

export const Button = ({ label, onClick, type = 'button' }) => (
  <button 
    className={`btn btn-${type}`}
    onClick={onClick}
  >
    {label}
  </button>
);

// Button.test.js
import React from 'react';
import renderer from 'react-test-renderer';
import { Button } from './Button';

test('Button renders correctly', () => {
  const tree = renderer
    .create(<Button label="Click me" type="primary" />)
    .toJSON();
  
  expect(tree).toMatchSnapshot();
});

// Generated snapshot file (Button.test.js.snap)
// Jest creates this automatically
exports[`Button renders correctly 1`] = `
<button
  className="btn btn-primary"
  onClick={undefined}
>
  Click me
</button>
`;

// Inline snapshots
test('Button renders with inline snapshot', () => {
  const tree = renderer
    .create(<Button label="Submit" type="submit" />)
    .toJSON();
  
  expect(tree).toMatchInlineSnapshot(`
    <button
      className="btn btn-submit"
      onClick={undefined}
    >
      Submit
    </button>
  `);
});

// Property matchers for dynamic content
test('Error message with timestamp', () => {
  const error = {
    message: 'Network error',
    timestamp: Date.now(),
    id: Math.random()
  };
  
  expect(error).toMatchSnapshot({
    timestamp: expect.any(Number),
    id: expect.any(Number)
  });
});

Code Coverage

Code coverage is like a map showing which parts of your code have been tested:

# Run tests with coverage
npm test -- --coverage

# Coverage report output
--------------------|---------|----------|---------|---------|-------------------
File                | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
--------------------|---------|----------|---------|---------|-------------------
All files           |   85.71 |      100 |   83.33 |   85.71 |                   
 math.js            |     100 |      100 |     100 |     100 |                   
 userService.js     |   71.43 |      100 |      50 |   71.43 | 7-9               
--------------------|---------|----------|---------|---------|-------------------

# Configure coverage thresholds
// jest.config.js
module.exports = {
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    },
    './src/utils/': {
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90
    }
  }
};

Coverage Report Types

Watch Mode

Jest's watch mode is like having a personal assistant that runs tests automatically when files change:

# Start Jest in watch mode
npm test -- --watch

# Watch mode options
Watch Usage
 › Press f to run only failed tests.
 › Press o to only run tests related to changed files.
 › Press p to filter by a filename regex pattern.
 › Press t to filter by a test name regex pattern.
 › Press q to quit watch mode.
 › Press Enter to trigger a test run.

# Configure watch plugins
// jest.config.js
module.exports = {
  watchPlugins: [
    'jest-watch-typeahead/filename',
    'jest-watch-typeahead/testname',
    'jest-watch-select-projects'
  ]
};

Best Practices

Follow these best practices to write effective tests:

1. Write Descriptive Test Names

// Bad
test('test 1', () => { /* ... */ });

// Good
test('should return null when user is not found', () => { /* ... */ });
test('throws error when dividing by zero', () => { /* ... */ });
test('formats currency with two decimal places', () => { /* ... */ });

2. Keep Tests Independent

// Bad - Tests depend on shared state
let counter = 0;

test('increments counter', () => {
  counter++;
  expect(counter).toBe(1);
});

test('increments counter again', () => {
  counter++;
  expect(counter).toBe(2); // Fails if tests run in different order
});

// Good - Each test is independent
test('increments counter', () => {
  let counter = 0;
  counter++;
  expect(counter).toBe(1);
});

test('increments counter from any value', () => {
  let counter = 5;
  counter++;
  expect(counter).toBe(6);
});

3. Test One Thing at a Time

// Bad - Testing multiple things
test('user service works', () => {
  const user = createUser({ name: 'John' });
  expect(user.id).toBeDefined();
  expect(user.name).toBe('John');
  
  const updated = updateUser(user.id, { name: 'Jane' });
  expect(updated.name).toBe('Jane');
  
  deleteUser(user.id);
  expect(getUser(user.id)).toBeNull();
});

// Good - Separate tests for each operation
describe('User Service', () => {
  test('creates user with generated ID', () => {
    const user = createUser({ name: 'John' });
    expect(user.id).toBeDefined();
    expect(user.name).toBe('John');
  });
  
  test('updates user name', () => {
    const user = createUser({ name: 'John' });
    const updated = updateUser(user.id, { name: 'Jane' });
    expect(updated.name).toBe('Jane');
  });
  
  test('deletes user', () => {
    const user = createUser({ name: 'John' });
    deleteUser(user.id);
    expect(getUser(user.id)).toBeNull();
  });
});

4. Use Meaningful Assertions

// Bad - Generic assertions
expect(result).toBeTruthy();
expect(error).toBeFalsy();

// Good - Specific assertions
expect(result).toEqual({ id: 1, name: 'John' });
expect(error).toBeNull();
expect(users).toHaveLength(3);
expect(response.status).toBe(200);

Common Testing Patterns

1. Testing Error Conditions

function validateEmail(email) {
  if (!email) {
    throw new Error('Email is required');
  }
  if (!email.includes('@')) {
    throw new Error('Invalid email format');
  }
  return true;
}

describe('validateEmail', () => {
  test('throws error when email is empty', () => {
    expect(() => validateEmail('')).toThrow('Email is required');
  });
  
  test('throws error for invalid format', () => {
    expect(() => validateEmail('invalid')).toThrow('Invalid email format');
  });
  
  test('returns true for valid email', () => {
    expect(validateEmail('test@example.com')).toBe(true);
  });
});

2. Testing Async Operations

// API service
async function fetchUserProfile(userId) {
  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok) {
    throw new Error('Failed to fetch user');
  }
  return response.json();
}

// Test file
describe('fetchUserProfile', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });
  
  afterEach(() => {
    jest.resetAllMocks();
  });
  
  test('fetches user successfully', async () => {
    const mockUser = { id: 1, name: 'John' };
    
    fetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(mockUser)
    });
    
    const user = await fetchUserProfile(1);
    
    expect(fetch).toHaveBeenCalledWith('/api/users/1');
    expect(user).toEqual(mockUser);
  });
  
  test('handles API errors', async () => {
    fetch.mockResolvedValueOnce({
      ok: false,
      status: 404
    });
    
    await expect(fetchUserProfile(999))
      .rejects
      .toThrow('Failed to fetch user');
  });
});

3. Testing Event Handlers

// Component with event handler
class ClickCounter {
  constructor() {
    this.count = 0;
    this.button = document.createElement('button');
    this.button.textContent = 'Click me';
    this.button.addEventListener('click', () => this.increment());
  }
  
  increment() {
    this.count++;
    this.button.textContent = `Clicked ${this.count} times`;
  }
}

// Test
describe('ClickCounter', () => {
  let counter;
  
  beforeEach(() => {
    counter = new ClickCounter();
  });
  
  test('increments count on click', () => {
    // Simulate click
    counter.button.click();
    
    expect(counter.count).toBe(1);
    expect(counter.button.textContent).toBe('Clicked 1 times');
  });
  
  test('handles multiple clicks', () => {
    counter.button.click();
    counter.button.click();
    counter.button.click();
    
    expect(counter.count).toBe(3);
    expect(counter.button.textContent).toBe('Clicked 3 times');
  });
});

Practical Exercise: Testing a Todo App

Let's practice by testing a simple Todo application:

// todo.js
export class TodoList {
  constructor() {
    this.todos = [];
    this.nextId = 1;
  }
  
  addTodo(text) {
    if (!text || text.trim() === '') {
      throw new Error('Todo text cannot be empty');
    }
    
    const todo = {
      id: this.nextId++,
      text: text.trim(),
      completed: false,
      createdAt: new Date()
    };
    
    this.todos.push(todo);
    return todo;
  }
  
  toggleTodo(id) {
    const todo = this.todos.find(t => t.id === id);
    if (!todo) {
      throw new Error('Todo not found');
    }
    
    todo.completed = !todo.completed;
    return todo;
  }
  
  deleteTodo(id) {
    const index = this.todos.findIndex(t => t.id === id);
    if (index === -1) {
      throw new Error('Todo not found');
    }
    
    return this.todos.splice(index, 1)[0];
  }
  
  getActiveTodos() {
    return this.todos.filter(todo => !todo.completed);
  }
  
  getCompletedTodos() {
    return this.todos.filter(todo => todo.completed);
  }
}

// todo.test.js
import { TodoList } from './todo';

describe('TodoList', () => {
  let todoList;
  
  beforeEach(() => {
    todoList = new TodoList();
  });
  
  describe('addTodo', () => {
    test('adds a new todo', () => {
      const todo = todoList.addTodo('Buy groceries');
      
      expect(todo).toEqual({
        id: 1,
        text: 'Buy groceries',
        completed: false,
        createdAt: expect.any(Date)
      });
      expect(todoList.todos).toHaveLength(1);
    });
    
    test('trims whitespace from todo text', () => {
      const todo = todoList.addTodo('  Clean house  ');
      expect(todo.text).toBe('Clean house');
    });
    
    test('throws error for empty todo', () => {
      expect(() => todoList.addTodo('')).toThrow('Todo text cannot be empty');
      expect(() => todoList.addTodo('   ')).toThrow('Todo text cannot be empty');
    });
    
    test('increments ID for each todo', () => {
      const todo1 = todoList.addTodo('First todo');
      const todo2 = todoList.addTodo('Second todo');
      
      expect(todo1.id).toBe(1);
      expect(todo2.id).toBe(2);
    });
  });
  
  describe('toggleTodo', () => {
    test('toggles todo completion status', () => {
      const todo = todoList.addTodo('Test todo');
      expect(todo.completed).toBe(false);
      
      const toggledTodo = todoList.toggleTodo(todo.id);
      expect(toggledTodo.completed).toBe(true);
      
      const toggledAgain = todoList.toggleTodo(todo.id);
      expect(toggledAgain.completed).toBe(false);
    });
    
    test('throws error for non-existent todo', () => {
      expect(() => todoList.toggleTodo(999)).toThrow('Todo not found');
    });
  });
  
  describe('deleteTodo', () => {
    test('deletes todo by ID', () => {
      const todo1 = todoList.addTodo('Todo 1');
      const todo2 = todoList.addTodo('Todo 2');
      
      const deleted = todoList.deleteTodo(todo1.id);
      
      expect(deleted).toEqual(todo1);
      expect(todoList.todos).toHaveLength(1);
      expect(todoList.todos[0]).toEqual(todo2);
    });
    
    test('throws error when todo not found', () => {
      expect(() => todoList.deleteTodo(999)).toThrow('Todo not found');
    });
  });
  
  describe('filtering todos', () => {
    beforeEach(() => {
      todoList.addTodo('Active todo 1');
      todoList.addTodo('Active todo 2');
      todoList.addTodo('Completed todo');
      todoList.toggleTodo(3); // Complete the third todo
    });
    
    test('getActiveTodos returns only active todos', () => {
      const activeTodos = todoList.getActiveTodos();
      
      expect(activeTodos).toHaveLength(2);
      expect(activeTodos.every(todo => !todo.completed)).toBe(true);
    });
    
    test('getCompletedTodos returns only completed todos', () => {
      const completedTodos = todoList.getCompletedTodos();
      
      expect(completedTodos).toHaveLength(1);
      expect(completedTodos[0].text).toBe('Completed todo');
      expect(completedTodos[0].completed).toBe(true);
    });
  });
});

Summary and Next Steps

Today we've learned the fundamentals of testing with Jest:

Practice Exercises

  1. Write tests for a shopping cart class that handles adding items, removing items, and calculating totals
  2. Create tests for an API client that handles different HTTP methods
  3. Test a form validation function with various input scenarios
  4. Write snapshot tests for a React component
  5. Practice mocking external dependencies like localStorage or fetch

Remember: Good tests are an investment in your code's future. They give you confidence to refactor, add features, and fix bugs without fear of breaking existing functionality!