Skip to content
intermediate Phase 15 · Testing & Quality

Unit Testing with Jest/Vitest

Write unit tests for React components and Node.js functions. Mock dependencies and test utilities.

1h 15m
0 problems
Topic Progress 0%

Jest and Vitest Fundamentals

Jest and Vitest Fundamentals

Jest and Vitest are the two dominant JavaScript test runners. Jest is the established choice with a rich ecosystem, while Vitest offers native ES module support and faster execution through Vite. Both share nearly identical APIs, so the concepts transfer directly.

Installing and Configuring Jest

npm install -D jest @jest/globals
npm install -D @babel/preset-env @babel/preset-react
// jest.config.js
export default {
  testEnvironment: 'jsdom',
  transform: {
    '^.+\\.jsx?$': 'babel-jest',
  },
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  setupFilesAfterSetup: ['<rootDir>/jest.setup.js'],
  collectCoverage: true,
  coverageThreshold: {
    global: { branches: 80, functions: 80, lines: 80, statements: 80 },
  },
};

Installing Vitest (Alternative)

npm install -D vitest @testing-library/jest-dom
// vitest.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.js'],
  },
});

Test Structure and Matchers

import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';

// Pure function to test
function formatCurrency(amount, currency = 'USD') {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(amount);
}

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

describe('formatCurrency', () => {
  it('should format USD values with two decimals', () => {
    expect(formatCurrency(1234.5)).toBe('$1,234.50');
  });

  it('should format zero as $0.00', () => {
    expect(formatCurrency(0)).toBe('$0.00');
  });

  it('should format negative values', () => {
    expect(formatCurrency(-42.1)).toBe('-$42.10');
  });

  it('should support different currencies', () => {
    expect(formatCurrency(10, 'EUR')).toMatch(/€/);
  });
});

describe('clamp', () => {
  it('should return the value when within range', () => {
    expect(clamp(5, 0, 10)).toBe(5);
  });

  it('should clamp to min when value is below range', () => {
    expect(clamp(-3, 0, 10)).toBe(0);
  });

  it('should clamp to max when value is above range', () => {
    expect(clamp(15, 0, 10)).toBe(10);
  });
});

Common Jest Matchers

// Equality
expect(value).toBe(42);              // strict equality (===)
expect(obj).toEqual({ a: 1 });       // deep equality
expect(obj).toStrictEqual({ a: 1 }); // type and shape equality

// Truthiness
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
expect(value).toBeTruthy();
expect(value).toBeFalsy();

// Numbers
expect(value).toBeGreaterThan(5);
expect(value).toBeGreaterThanOrEqual(5);
expect(value).toBeCloseTo(3.14, 2);   // floating-point safe

// Strings
expect(str).toMatch(/regex/);
expect(str).toContain('substr');

// Arrays
expect(arr).toContain(3);
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining([1, 2]));

// Objects
expect(obj).toHaveProperty('name', 'Alice');
expect(obj).toMatchObject({ name: 'Alice' });
expect(obj).toBeInstanceOf(Error);

// Async
await expect(promise).resolves.toBe(42);
await expect(failedPromise).rejects.toThrow('error');

// Snapshot (use sparingly)
expect(component).toMatchSnapshot();

Setup and Teardown Hooks

describe('database-backed tests', () => {
  beforeAll(() => {
    // Runs once before all tests in this describe block
    // e.g., start a test database connection
  });

  beforeEach(() => {
    // Runs before each individual test
    // e.g., reset database state, clear mocks
  });

  afterEach(() => {
    // Runs after each individual test
    // e.g., clean up side effects, restore stubs
  });

  afterAll(() => {
    // Runs once after all tests in this describe block
    // e.g., close database connection, stop server
  });
});

What to Test vs. What NOT to Test

Test:

  • Pure functions with clear input/output
  • Edge cases: null, undefined, empty strings, boundaries
  • Error handling and thrown exceptions
  • Component rendering and user interactions

Do NOT test:

  • Implementation details (internal state, private methods)
  • Third-party library internals (trust that React, Lodash work)
  • Trivial getters/setters
  • CSS styling (use visual regression tools instead)

Mocking and Test Isolation

Mocking and Test Isolation

Mocking is the practice of replacing real dependencies with controlled fakes so you can test a unit in isolation. Good mocks let you verify behavior without side effects and simulate error conditions that are hard to reproduce in production.

Mock Functions with jest.fn

import { describe, it, expect, jest } from '@jest/globals';

describe('jest.fn mocks', () => {
  it('should track calls and arguments', () => {
    const log = jest.fn();

    log('hello');
    log('world');

    expect(log).toHaveBeenCalledTimes(2);
    expect(log).toHaveBeenCalledWith('hello');
    expect(log).toHaveBeenLastCalledWith('world');
  });

  it('should return configured values', () => {
    const fetchData = jest.fn()
      .mockResolvedValueOnce({ id: 1, name: 'Alice' })
      .mockResolvedValueOnce({ id: 2, name: 'Bob' })
      .mockRejectedValueOnce(new Error('Network error'));

    const result1 = await fetchData();
    const result2 = await fetchData();
    await expect(fetchData()).rejects.toThrow('Network error');

    expect(result1).toEqual({ id: 1, name: 'Alice' });
    expect(result2).toEqual({ id: 2, name: 'Bob' });
  });

  it('should support custom implementation', () => {
    const add = jest.fn((a, b) => a + b);
    expect(add(2, 3)).toBe(5);
    expect(add).toHaveBeenCalledTimes(1);
  });
});

Mocking Modules

// Mock an entire module
jest.mock('./database', () => ({
  db: {
    query: jest.fn(),
    close: jest.fn(),
  },
}));

// Or use auto-mock (replaces all exports with jest.fn)
jest.mock('./emailService');

import { db } from './database';
import { sendEmail } from './emailService';

describe('with mocked modules', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should use mocked database', async () => {
    db.query.mockResolvedValueOnce([{ id: 1, name: 'Test' }]);
    const results = await db.query('SELECT * FROM users');
    expect(results).toHaveLength(1);
    expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
  });

  it('should use mocked email service', async () => {
    sendEmail.mockResolvedValue(true);
    const sent = await sendEmail('user@test.com', 'Hello');
    expect(sent).toBe(true);
    expect(sendEmail).toHaveBeenCalledWith('user@test.com', 'Hello');
  });
});

Spying on Existing Methods

import { describe, it, expect, jest, beforeEach } from '@jest/globals';

const logger = {
  info: (msg) => console.log(msg),
  error: (msg) => console.error(msg),
};

describe('spyOn', () => {
  let spy;

  beforeEach(() => {
    spy = jest.spyOn(logger, 'info').mockImplementation(() => {});
  });

  afterEach(() => {
    spy.mockRestore();
  });

  it('should spy on logger.info calls', () => {
    logger.info('test message');
    expect(spy).toHaveBeenCalledWith('test message');
  });
});

Testing Async Code with Mocks

// src/services/userService.js
export async function fetchAndFormatUser(id) {
  const user = await fetch(`/api/users/${id}`);
  if (!user.ok) throw new Error('User not found');
  const data = await user.json();
  return { ...data, displayName: `${data.firstName} ${data.lastName}` };
}

// userService.test.js
describe('fetchAndFormatUser', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('should format user data correctly', async () => {
    fetch.mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ firstName: 'Jane', lastName: 'Doe', id: 42 }),
    });

    const result = await fetchAndFormatUser(42);

    expect(result).toEqual({
      firstName: 'Jane',
      lastName: 'Doe',
      id: 42,
      displayName: 'Jane Doe',
    });
    expect(fetch).toHaveBeenCalledWith('/api/users/42');
  });

  it('should throw on failed request', async () => {
    fetch.mockResolvedValue({ ok: false, status: 404 });
    await expect(fetchAndFormatUser(999)).rejects.toThrow('User not found');
  });

  it('should handle network errors', async () => {
    fetch.mockRejectedValue(new Error('Network timeout'));
    await expect(fetchAndFormatUser(1)).rejects.toThrow('Network timeout');
  });
});

Mocking Express Middleware and Request/Response

function createMockReq(overrides = {}) {
  return {
    headers: {},
    body: {},
    params: {},
    query: {},
    ...overrides,
  };
}

function createMockRes() {
  const res = {
    status: jest.fn(() => res),
    json: jest.fn(() => res),
    send: jest.fn(() => res),
    redirect: jest.fn(() => res),
  };
  return res;
}

describe('authMiddleware', () => {
  it('should reject requests without Authorization header', () => {
    const req = createMockReq({ headers: {} });
    const res = createMockRes();
    const next = jest.fn();

    authMiddleware(req, res, next);

    expect(res.status).toHaveBeenCalledWith(401);
    expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' });
    expect(next).not.toHaveBeenCalled();
  });

  it('should call next for valid tokens', () => {
    const req = createMockReq({
      headers: { authorization: 'Bearer valid-token-123' },
    });
    const res = createMockRes();
    const next = jest.fn();

    authMiddleware(req, res, next);

    expect(next).toHaveBeenCalled();
    expect(res.status).not.toHaveBeenCalled();
  });
});

Mock Best Practices

  • Always call jest.clearAllMocks() or jest.resetAllMocks() in beforeEach to prevent test pollution.
  • Prefer jest.spyOn over jest.fn when the original method exists and you only need to observe calls.
  • Use mockImplementation or mockReturnValue for return values; use mockRejectedValue for async errors.
  • Avoid over-mocking: if you mock too much, you are not actually testing the unit's real behavior.

React Component Testing with React Testing Library

React Component Testing with React Testing Library

React Testing Library (RTL) encourages testing components the way a user would interact with them: by finding elements, clicking buttons, typing into inputs, and asserting what appears on screen. This produces tests that are resilient to internal refactors and focused on real behavior.

Installation

npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
// jest.setup.js or vitest.setup.js
import '@testing-library/jest-dom/vitest';  // for Vitest
// or import '@testing-library/jest-dom';  // for Jest

Basic Component Test

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter', () => {
  it('should render initial count', () => {
    render(<Counter initialCount={0} />);
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });

  it('should increment on click', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={0} />);

    const button = screen.getByRole('button', { name: /increment/i });
    await user.click(button);

    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });

  it('should decrement on click', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={5} />);

    const decrement = screen.getByRole('button', { name: /decrement/i });
    await user.click(decrement);
    await user.click(decrement);

    expect(screen.getByText('Count: 3')).toBeInTheDocument();
  });

  it('should not go below zero', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={0} />);

    const decrement = screen.getByRole('button', { name: /decrement/i });
    await user.click(decrement);

    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });
});

Query Priority and Best Practices

// Priority 1: Accessible roles (best for user-centric tests)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 2 });

// Priority 2: Labels and text
screen.getByLabelText(/password/i);
screen.getByText(/welcome back/i);
screen.getByDisplayValue('initial value');

// Priority 3: Placeholders and test IDs (last resort)
screen.getByPlaceholderText(/search/i);
screen.getByTestId('custom-element');  // add data-testid only when no accessible role exists

// Avoid these: they are brittle and break on refactoring
// screen.getByClassName('btn-primary');
// screen.getByType('submit');

Testing Async Components

import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import { UserProfile } from './UserProfile';

describe('UserProfile', () => {
  beforeEach(() => {
    global.fetch = jest.fn();
  });

  it('should display loading then user data', async () => {
    fetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve({ name: 'Alice', email: 'alice@test.com' }),
    });

    render(<UserProfile userId="1" />);

    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
    });

    expect(screen.getByText('alice@test.com')).toBeInTheDocument();
    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
  });

  it('should display error message on failure', async () => {
    fetch.mockResolvedValueOnce({ ok: false, status: 500 });

    render(<UserProfile userId="1" />);

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent(/failed/i);
    });
  });

  it('should display empty state when no user found', async () => {
    fetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(null) });

    render(<UserProfile userId="999" />);

    await waitFor(() => {
      expect(screen.getByText(/no user found/i)).toBeInTheDocument();
    });
  });
});

Testing Forms and User Events

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  it('should submit form with valid credentials', async () => {
    const onSubmit = jest.fn();
    const user = userEvent.setup();
    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText(/email/i), 'user@test.com');
    await user.type(screen.getByLabelText(/password/i), 'secret123');
    await user.click(screen.getByRole('button', { name: /log in/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: 'user@test.com',
      password: 'secret123',
    });
  });

  it('should show validation errors for empty fields', async () => {
    const onSubmit = jest.fn();
    const user = userEvent.setup();
    render(<LoginForm onSubmit={onSubmit} />);

    await user.click(screen.getByRole('button', { name: /log in/i }));

    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
    expect(screen.getByText(/password is required/i)).toBeInTheDocument();
    expect(onSubmit).not.toHaveBeenCalled();
  });

  it('should toggle password visibility', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={() => {}} />);

    const passwordInput = screen.getByLabelText(/password/i);
    expect(passwordInput).toHaveAttribute('type', 'password');

    const toggle = screen.getByRole('button', { name: /show password/i });
    await user.click(toggle);

    expect(passwordInput).toHaveAttribute('type', 'text');
  });
});

Testing with Context and Providers

import { render, screen } from '@testing-library/react';
import { AuthProvider } from './AuthContext';
import { Dashboard } from './Dashboard';

function renderWithProviders(ui, { user = null } = {}) {
  return render(
    <AuthProvider initialUser={user}>
      {ui}
    </AuthProvider>
  );
}

describe('Dashboard', () => {
  it('should show user name when logged in', () => {
    renderWithProviders(<Dashboard />, { user: { name: 'Alice' } });
    expect(screen.getByText(/welcome, alice/i)).toBeInTheDocument();
  });

  it('should redirect to login when not authenticated', () => {
    renderWithProviders(<Dashboard />, { user: null });
    expect(screen.getByText(/please log in/i)).toBeInTheDocument();
  });
});

Test-Driven Development and Coverage

Test-Driven Development and Coverage

TDD is a disciplined approach where you write a failing test before writing any production code. This forces you to think about requirements upfront and produces tests that are tightly aligned with the intended behavior.

The TDD Cycle

1. RED:   Write a failing test that describes the next piece of behavior
2. GREEN: Write the minimum production code to make the test pass
3. REFACTOR: Improve the code structure while keeping all tests green

TDD Example: Building a ShoppingCart

// Step 1: RED — write failing tests first
describe('ShoppingCart', () => {
  let cart;

  beforeEach(() => {
    cart = new ShoppingCart();
  });

  it('should start empty', () => {
    expect(cart.items).toHaveLength(0);
    expect(cart.total()).toBe(0);
  });

  it('should add an item', () => {
    cart.add({ name: 'Widget', price: 9.99, quantity: 1 });
    expect(cart.items).toHaveLength(1);
    expect(cart.items[0].name).toBe('Widget');
  });

  it('should calculate total with single item', () => {
    cart.add({ name: 'Widget', price: 10, quantity: 2 });
    expect(cart.total()).toBe(20);
  });

  it('should accumulate quantities for same item', () => {
    cart.add({ name: 'Widget', price: 10, quantity: 1 });
    cart.add({ name: 'Widget', price: 10, quantity: 3 });
    expect(cart.items).toHaveLength(1);
    expect(cart.items[0].quantity).toBe(4);
  });

  it('should remove an item', () => {
    cart.add({ name: 'Widget', price: 10, quantity: 1 });
    cart.remove('Widget');
    expect(cart.items).toHaveLength(0);
  });

  it('should apply discount code', () => {
    cart.add({ name: 'Widget', price: 100, quantity: 1 });
    cart.applyDiscount('SAVE10');
    expect(cart.total()).toBe(90);
  });

  it('should throw on invalid discount code', () => {
    expect(() => cart.applyDiscount('INVALID')).toThrow('Invalid discount code');
  });
});

// Step 2: GREEN — minimum code to pass
class ShoppingCart {
  constructor() {
    this.items = [];
  }

  add(item) {
    const existing = this.items.find((i) => i.name === item.name);
    if (existing) {
      existing.quantity += item.quantity;
    } else {
      this.items.push({ ...item });
    }
  }

  remove(name) {
    this.items = this.items.filter((i) => i.name !== name);
  }

  total() {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  applyDiscount(code) {
    if (code !== 'SAVE10') throw new Error('Invalid discount code');
    // discount logic in next refactor step
  }
}

// Step 3: REFACTOR — clean up and extend
class ShoppingCart {
  static DISCOUNT_CODES = { SAVE10: 0.1, SAVE20: 0.2 };

  constructor() {
    this.items = [];
  }

  add(item) {
    const existing = this.items.find((i) => i.name === item.name);
    if (existing) {
      existing.quantity += item.quantity;
    } else {
      this.items.push({ ...item });
    }
  }

  remove(name) {
    this.items = this.items.filter((i) => i.name !== name);
  }

  total() {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  applyDiscount(code) {
    const rate = ShoppingCart.DISCOUNT_CODES[code];
    if (rate === undefined) throw new Error('Invalid discount code');
    this.items.forEach((item) => {
      item.price = +(item.price * (1 - rate)).toFixed(2);
    });
  }
}

Configuring Code Coverage

// jest.config.js (or package.json jest section)
{
  "collectCoverage": true,
  "coverageDirectory": "coverage",
  "coverageReporters": ["text", "lcov", "json-summary"],
  "collectCoverageFrom": [
    "src/**/*.{js,jsx,ts,tsx}",
    "!src/**/*.d.ts",
    "!src/index.js"
  ],
  "coverageThreshold": {
    "global": {
      "branches": 80,
      "functions": 80,
      "lines": 80,
      "statements": 80
    }
  }
}

Running Tests

# Jest
npx jest                    # run all tests
npx jest --coverage         # with coverage report
npx jest --watch            # watch mode
npx jest --testPathPattern=auth  # filter by file name
npx jest --verbose          # show each test result

# Vitest
npx vitest                  # run in watch mode
npx vitest run              # run once
npx vitest --coverage       # with coverage
npx vitest --reporter=verbose  # detailed output

When to Stop Writing Tests

  • Every branch of the function is covered (if, else, early return, error throw)
  • Edge cases are tested: null, empty string, zero, negative numbers, boundary values
  • All public API methods have at least one test
  • Integration points are covered by integration tests (not unit tests)
  • You have tested error paths, not just happy paths

Quiz

1. What does the AAA (Arrange-Act-Assert) pattern represent in unit testing?

Question 1 options

2. Why should you prefer getByRole over getByTestId when testing React components?

Question 2 options

3. What is the main advantage of using jest.mock() to mock an entire module?

Question 3 options

Flashcards

Question

What is the difference between jest.fn() and jest.mock()?

Answer

jest.fn() creates a single mock function that you can use inline for callbacks, event handlers, or strategy patterns. jest.mock() replaces an entire module at the import level, replacing all its exports with jest.fn() stubs. Use jest.fn() for individual function mocks and jest.mock() when you need to isolate a module from its real dependencies.

Question

What is the recommended query priority in React Testing Library?

Answer

1) getByRole — accessible roles (button, heading, textbox), 2) getByLabelText — form elements with labels, 3) getByText — visible text content, 4) getByDisplayValue — current value of inputs, 5) getByPlaceholderText — placeholder as fallback, 6) getByTestId — last resort when no accessible query works. This priority ensures tests are resilient to UI changes and aligned with user experience.

Question

What are the three phases of Test-Driven Development?

Answer

RED: write a failing test that describes the desired behavior. GREEN: write the minimum production code to make the test pass. REFACTOR: improve code structure, remove duplication, and clean up while keeping all tests green. This cycle repeats for each feature, producing code that is tested by design and refactored incrementally.

Revision Notes

Key Takeaways

  • 1. Jest and Vitest share nearly identical APIs; both use describe/it/expect for test structure
  • 2. Mock with jest.fn() for individual functions, jest.mock() for entire modules, jest.spyOn() for observing existing methods
  • 3. React Testing Library prioritizes accessible queries (getByRole, getByLabelText) over implementation details (getByTestId)
  • 4. TDD follows the RED-GREEN-REFACTOR cycle: write a failing test, write minimal code to pass, then refactor
  • 5. Always test error paths and edge cases, not just the happy path
  • 6. Use userEvent.setup() before interacting with components to ensure realistic event simulation
  • 7. Clear mocks in beforeEach with jest.clearAllMocks() to prevent test pollution

Interview Tips

  • Explain why testing user behavior is preferred over testing implementation details
  • Describe the TDD cycle and how it improves code design through incremental refactoring
  • Discuss the difference between unit, integration, and end-to-end tests and when to use each
  • Walk through how you would mock a fetch call and test both success and error scenarios
  • Explain what code coverage metrics mean and why 100% coverage does not guarantee bug-free code
  • Describe how you would test a React form with validation, submission, and error states
  • Discuss strategies for testing async code, including promises, timers, and API calls

Cheat Sheet

Test Structure: describe > it > expect. AAA Pattern: Arrange setup, Act call function, Assert result. Jest Matchers: toBe (strict), toEqual (deep), toThrow (errors), toHaveBeenCalledWith (mock args). Mocking: jest.fn() for inline stubs, jest.mock() for modules, jest.spyOn() for observation. React Testing Library: render(), screen queries (getByRole > getByLabelText > getByText), userEvent.setup() for interactions. TDD: RED (fail) > GREEN (pass) > REFACTOR (clean). Coverage: branches, functions, lines, statements — aim for 80%+ on business logic. Common mistakes: testing implementation, over-mocking, forgetting to clear mocks, skipping edge cases.