DevToolBoxGRATIS
Blog

Jest Testing Guide: Mocking, React Testing Library, Snapshots, and Code Coverage

14 min readoleh DevToolBox

TL;DR

Jest is a zero-configuration JavaScript testing framework with built-in mocking, snapshot testing, code coverage via Istanbul, and jsdom for DOM simulation. Pair it with React Testing Library for component tests, use jest.fn() and jest.mock() for mocking, and run --coverage to enforce thresholds in CI.

Key Takeaways

  • Jest works out of the box with zero configuration for most JavaScript and TypeScript projects
  • Use describe / it blocks and expect matchers for expressive, readable tests
  • jest.fn(), jest.mock(), and jest.spyOn() cover virtually all mocking scenarios
  • React Testing Library encourages testing behavior over implementation details
  • Enforce coverage thresholds in CI with --coverage and coverageThreshold
  • Fake timers let you test debounce, throttle, and polling without real delays
  • Vitest is a fast drop-in alternative for Vite-based projects, sharing most of Jest's API

What Is Jest? Zero-Config Testing for JavaScript

Jest is a JavaScript testing framework maintained by Meta (formerly Facebook). It was designed with developer experience as the top priority: install it, run npx jest, and it finds and runs every *.test.js, *.spec.js, or file inside a __tests__ folder automatically โ€” no config file required for most projects.

Under the hood, Jest ships with several integrated capabilities that other frameworks require you to assemble yourself:

  • Test runner โ€” finds, executes, and reports results for all test files in parallel using worker processes
  • Assertion library โ€” the expect API with 30+ built-in matchers plus third-party extensions via expect.extend()
  • Mocking system โ€” jest.fn(), jest.mock(), auto-mocking, and manual mocks via __mocks__ directories
  • Snapshot testing โ€” serialize any JavaScript value to a text file and detect unintended changes across commits
  • Code coverage โ€” powered by Istanbul/NYC, reports lines, statements, branches, and functions with threshold enforcement
  • jsdom environment โ€” simulates a browser DOM in Node.js so you can test React, Vue, and Angular components without a real browser
  • Fake timers โ€” replace setTimeout, setInterval, and Date with controllable test versions

Installation

# npm
npm install --save-dev jest

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

# For React + TypeScript projects
npm install --save-dev jest @types/jest ts-jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

jest.config.ts (TypeScript)

import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',           // use 'node' for server-side tests
  setupFilesAfterFramework: ['<rootDir>/jest.setup.ts'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',  // path alias support
    '\.css$': '<rootDir>/__mocks__/styleMock.js',
    '\.svg$': '<rootDir>/__mocks__/svgMock.js',
  },
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/index.ts',
  ],
};

export default config;
// jest.setup.ts
import '@testing-library/jest-dom';

Writing Tests โ€” describe, it, expect, and Matchers

Every Jest test file is structured using three primary globals: describe for grouping related tests, it (or test) for individual test cases, and expect for assertions.

Basic Test Structure

// src/utils/math.ts
export function add(a: number, b: number): number {
  return a + b;
}

export function divide(a: number, b: number): number {
  if (b === 0) throw new Error('Division by zero');
  return a / b;
}

// src/utils/math.test.ts
import { add, divide } from './math';

describe('add()', () => {
  it('returns the sum of two positive numbers', () => {
    expect(add(2, 3)).toBe(5);
  });

  it('handles negative numbers', () => {
    expect(add(-1, -2)).toBe(-3);
  });

  it('returns the first argument when adding zero', () => {
    expect(add(7, 0)).toBe(7);
  });
});

describe('divide()', () => {
  it('divides two numbers correctly', () => {
    expect(divide(10, 2)).toBe(5);
  });

  it('throws on division by zero', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero');
  });
});

Essential Matchers Reference

MatcherUse CaseExample
toBe(val)Strict equality (===)expect(1 + 1).toBe(2)
toEqual(val)Deep equality for objects/arraysexpect({a:1}).toEqual({a:1})
toStrictEqual(val)Deep equality + checks undefined propertiesexpect(obj).toStrictEqual(expected)
toBeTruthy() / toBeFalsy()Truthy or falsy checkexpect("hello").toBeTruthy()
toBeNull() / toBeUndefined()Checks for null or undefinedexpect(null).toBeNull()
toContain(item)Array or string contains valueexpect([1,2,3]).toContain(2)
toHaveLength(n)Array or string lengthexpect("abc").toHaveLength(3)
toThrow(err?)Function throws an errorexpect(() => fn()).toThrow()
toBeGreaterThan(n)Number comparisonexpect(5).toBeGreaterThan(3)
toMatch(regex)String matches regex or substringexpect("hello").toMatch(/ell/)
toHaveProperty(path)Object has a propertyexpect(obj).toHaveProperty('a.b')
toBeCloseTo(n, digits?)Floating point comparisonexpect(0.1 + 0.2).toBeCloseTo(0.3)
toHaveBeenCalled()Mock function was calledexpect(mockFn).toHaveBeenCalled()
toHaveBeenCalledWith(...)Mock called with specific argsexpect(fn).toHaveBeenCalledWith(42, 'x')
toHaveBeenCalledTimes(n)Mock call countexpect(fn).toHaveBeenCalledTimes(3)

Lifecycle Hooks

describe('Database service', () => {
  let db: DatabaseService;

  beforeAll(async () => {
    // Runs once before all tests in this describe block
    db = await DatabaseService.connect('sqlite::memory:');
  });

  afterAll(async () => {
    // Runs once after all tests in this describe block
    await db.disconnect();
  });

  beforeEach(async () => {
    // Runs before EACH test โ€” seed fresh data
    await db.seed(testFixtures);
  });

  afterEach(async () => {
    // Runs after EACH test โ€” clean up
    await db.truncate();
  });

  it('finds a user by id', async () => {
    const user = await db.findUser(1);
    expect(user).toHaveProperty('email');
  });
});

Mocking โ€” jest.fn(), jest.mock(), and jest.spyOn()

Mocking replaces real dependencies with controlled substitutes, enabling tests to run in isolation without hitting databases, external APIs, or the filesystem. Jest provides three complementary tools for this.

jest.fn() โ€” Standalone Mock Functions

// Create a mock function
const mockCallback = jest.fn();

// Call it like a real function
mockCallback('hello', 42);

// Inspect how it was called
expect(mockCallback).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenCalledWith('hello', 42);
console.log(mockCallback.mock.calls);   // [['hello', 42]]
console.log(mockCallback.mock.results); // [{ type: 'return', value: undefined }]

// Control return values
const mockFetch = jest.fn()
  .mockReturnValueOnce({ data: 'first call' })
  .mockReturnValueOnce({ data: 'second call' })
  .mockReturnValue({ data: 'subsequent calls' });

// Async return values
const mockApi = jest.fn()
  .mockResolvedValue({ status: 200, body: { id: 1 } });

const result = await mockApi();
expect(result.status).toBe(200);

// Reject with an error
const mockFailing = jest.fn()
  .mockRejectedValue(new Error('Network error'));

jest.mock() โ€” Module-Level Mocking

// Auto-mock: all exports become jest.fn()
jest.mock('./emailService');

// Manual mock with factory function
jest.mock('./userRepository', () => ({
  findById: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
  save: jest.fn().mockResolvedValue(undefined),
}));

// Mock a third-party module
jest.mock('axios', () => ({
  default: {
    get: jest.fn().mockResolvedValue({ data: { users: [] } }),
    post: jest.fn().mockResolvedValue({ data: { id: 42 } }),
  },
}));

// Mock module with default export (ES module)
jest.mock('./config', () => ({
  __esModule: true,
  default: { apiUrl: 'http://test.local', timeout: 1000 },
}));

// Partial mock โ€” keep some real implementations
jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'),  // keep real helpers
  generateId: jest.fn().mockReturnValue('test-id-123'),
}));

jest.spyOn() โ€” Wrapping Existing Methods

import * as fs from 'fs';

describe('FileProcessor', () => {
  afterEach(() => {
    jest.restoreAllMocks(); // restore original implementations
  });

  it('reads a file and processes its content', () => {
    // Spy on fs.readFileSync without replacing the entire module
    const readSpy = jest.spyOn(fs, 'readFileSync')
      .mockReturnValue('mocked file content');

    const processor = new FileProcessor();
    const result = processor.processFile('/path/to/file.txt');

    expect(readSpy).toHaveBeenCalledWith('/path/to/file.txt', 'utf-8');
    expect(result).toBe('MOCKED FILE CONTENT');
  });

  it('logs errors when file is missing', () => {
    const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
    jest.spyOn(fs, 'readFileSync').mockImplementation(() => {
      throw new Error('ENOENT: no such file');
    });

    new FileProcessor().processFile('/missing.txt');

    expect(consoleSpy).toHaveBeenCalledWith(
      expect.stringContaining('ENOENT')
    );
  });
});

Manual Mocks with __mocks__ Directory

// __mocks__/axios.ts  (placed next to node_modules)
const axios = {
  get: jest.fn(),
  post: jest.fn(),
  put: jest.fn(),
  delete: jest.fn(),
  create: jest.fn().mockReturnThis(),
  interceptors: {
    request: { use: jest.fn() },
    response: { use: jest.fn() },
  },
};
export default axios;

// In test files โ€” jest.mock('axios') uses __mocks__/axios.ts automatically
jest.mock('axios');
import axios from 'axios';

test('fetches user data', async () => {
  (axios.get as jest.Mock).mockResolvedValueOnce({
    data: { id: 1, name: 'Alice' },
  });
  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
});

Testing React Components with React Testing Library

React Testing Library (RTL) builds on Jest's jsdom environment and provides utilities that encourage testing components the way users interact with them โ€” by finding elements through accessible roles, labels, and text rather than CSS selectors or component internals.

Queries: Finding Elements the Right Way

QueryWhen to UseThrows if Missing?
getByRolePreferred โ€” matches ARIA roles (button, textbox, headingโ€ฆ)Yes
getByLabelTextForm inputs with associated <label>Yes
getByPlaceholderTextInputs with placeholder attributeYes
getByTextNon-interactive elements: headings, paragraphs, spansYes
getByDisplayValueCurrent value of input/select/textareaYes
getByAltTextImages with alt attributeYes
getByTestIdLast resort โ€” requires data-testid attributeYes
queryBy*Same as getBy* but returns null instead of throwingNo
findBy*Async โ€” waits for element to appear (returns Promise)Yes (async)
getAllBy* / queryAllBy* / findAllBy*Returns array of all matching elementsVaries

Component Test Example

// components/LoginForm.tsx
import { useState } from 'react';

interface LoginFormProps {
  onSubmit: (email: string, password: string) => Promise<void>;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError('');
    try {
      await onSubmit(email, password);
    } catch (err) {
      setError('Invalid credentials');
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" value={email}
        onChange={e => setEmail(e.target.value)} />
      <label htmlFor="password">Password</label>
      <input id="password" type="password" value={password}
        onChange={e => setPassword(e.target.value)} />
      {error && <p role="alert">{error}</p>}
      <button type="submit" disabled={loading}>
        {loading ? 'Signing inโ€ฆ' : 'Sign in'}
      </button>
    </form>
  );
}
// components/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  it('submits email and password when form is filled and submitted', async () => {
    const user = userEvent.setup();
    const mockOnSubmit = jest.fn().mockResolvedValue(undefined);

    render(<LoginForm onSubmit={mockOnSubmit} />);

    await user.type(screen.getByLabelText('Email'), 'alice@example.com');
    await user.type(screen.getByLabelText('Password'), 'secret123');
    await user.click(screen.getByRole('button', { name: 'Sign in' }));

    await waitFor(() => {
      expect(mockOnSubmit).toHaveBeenCalledWith('alice@example.com', 'secret123');
    });
  });

  it('shows an error message when login fails', async () => {
    const user = userEvent.setup();
    const mockOnSubmit = jest.fn().mockRejectedValue(new Error('Unauthorized'));

    render(<LoginForm onSubmit={mockOnSubmit} />);

    await user.type(screen.getByLabelText('Email'), 'bad@example.com');
    await user.type(screen.getByLabelText('Password'), 'wrong');
    await user.click(screen.getByRole('button', { name: 'Sign in' }));

    expect(await screen.findByRole('alert')).toHaveTextContent('Invalid credentials');
  });

  it('disables the submit button while loading', async () => {
    const user = userEvent.setup();
    // Never resolves so we can check the loading state
    const mockOnSubmit = jest.fn().mockReturnValue(new Promise(() => {}));

    render(<LoginForm onSubmit={mockOnSubmit} />);
    await user.type(screen.getByLabelText('Email'), 'test@example.com');
    await user.type(screen.getByLabelText('Password'), 'pass');
    await user.click(screen.getByRole('button', { name: 'Sign in' }));

    expect(screen.getByRole('button', { name: 'Signing inโ€ฆ' })).toBeDisabled();
  });
});

Async Testing โ€” Promises, async/await, and Fake Timers

Modern JavaScript is heavily asynchronous. Jest handles async code through native async/await, promise matchers, and a powerful fake timer system.

async/await and Promise Matchers

// Testing a function that returns a Promise
async function fetchUser(id: number) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

// Option 1: async/await (clearest syntax)
it('fetches a user successfully', async () => {
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
  });

  const user = await fetchUser(1);
  expect(user.name).toBe('Alice');
});

// Option 2: resolves / rejects matchers
it('resolves with the correct user', () => {
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
  });
  return expect(fetchUser(1)).resolves.toMatchObject({ name: 'Alice' });
});

it('rejects when server returns 404', () => {
  global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 404 });
  return expect(fetchUser(99)).rejects.toThrow('HTTP 404');
});

Fake Timers for setTimeout and setInterval

// utils/debounce.ts
export function debounce<T extends (...args: unknown[]) => void>(
  fn: T,
  delay: number
): T {
  let timer: ReturnType<typeof setTimeout>;
  return ((...args: unknown[]) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  }) as T;
}

// utils/debounce.test.ts
import { debounce } from './debounce';

beforeEach(() => {
  jest.useFakeTimers();
});

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

it('calls the function after the delay', () => {
  const fn = jest.fn();
  const debounced = debounce(fn, 300);

  debounced();
  expect(fn).not.toHaveBeenCalled();  // not yet

  jest.advanceTimersByTime(300);
  expect(fn).toHaveBeenCalledTimes(1);
});

it('only calls once if invoked multiple times within delay', () => {
  const fn = jest.fn();
  const debounced = debounce(fn, 300);

  debounced();
  debounced();
  debounced();
  jest.advanceTimersByTime(300);

  expect(fn).toHaveBeenCalledTimes(1);
});

it('runs all pending timers with runAllTimers()', () => {
  const fn = jest.fn();
  const debounced = debounce(fn, 5000);

  debounced();
  jest.runAllTimers();
  expect(fn).toHaveBeenCalledTimes(1);
});

waitFor and findBy for DOM Updates

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

it('shows search results after typing', async () => {
  const user = userEvent.setup();
  render(<SearchBox />);

  await user.type(screen.getByRole('searchbox'), 'jest');

  // waitFor keeps polling until assertion passes or times out (default 1000ms)
  await waitFor(() => {
    expect(screen.getByText('jest-circus')).toBeInTheDocument();
  });

  // findBy* is syntactic sugar for waitFor + getBy*
  const result = await screen.findByText('jest-circus');
  expect(result).toBeInTheDocument();
});

Snapshot Testing โ€” toMatchSnapshot and Inline Snapshots

Snapshot tests capture the rendered output of a component (or any serializable value) to a .snap file. On subsequent test runs, Jest compares the current output to the stored snapshot and fails if they differ, protecting against accidental regressions.

File Snapshots

// components/Badge.tsx
export function Badge({ label, variant }: { label: string; variant: 'success' | 'error' }) {
  const bg = variant === 'success' ? '#dcfce7' : '#fee2e2';
  const color = variant === 'success' ? '#166534' : '#991b1b';
  return <span style={{ background: bg, color, padding: '2px 8px', borderRadius: '9999px' }}>{label}</span>;
}

// components/Badge.test.tsx
import { render } from '@testing-library/react';
import { Badge } from './Badge';

it('renders a success badge', () => {
  const { asFragment } = render(<Badge label="Passing" variant="success" />);
  expect(asFragment()).toMatchSnapshot();
  // Creates __snapshots__/Badge.test.tsx.snap on first run
});

// To update after intentional UI changes:
// npx jest --updateSnapshot  or  npx jest -u

Inline Snapshots (Preferred for Small Values)

it('serializes a config object', () => {
  const config = buildConfig({ env: 'test', debug: false });
  expect(config).toMatchInlineSnapshot(`
    {
      "debug": false,
      "env": "test",
      "logLevel": "warn",
      "retries": 3,
    }
  `);
  // Jest writes the snapshot directly into the test file on first run
});

Snapshot Best Practices

  • Review every snapshot diff in code review โ€” blindly running -u defeats the purpose of snapshot testing
  • Prefer inline snapshots for small, focused values; they are easier to review in pull requests
  • Commit snapshot files to version control alongside your test code
  • Avoid snapshotting large component trees โ€” they become brittle and obscure real regressions in noise. Target specific sub-trees or data structures instead
  • Use expect.any(String) or property matchers in snapshots to avoid failing on dynamic values like timestamps or UUIDs
// Property matchers โ€” ignore dynamic values
it('creates a user with a generated id', () => {
  const user = createUser({ name: 'Alice' });
  expect(user).toMatchSnapshot({
    id: expect.any(String),      // ignore random UUID
    createdAt: expect.any(Date), // ignore timestamp
  });
  // Only non-matched properties are snapshotted
});

Code Coverage โ€” Istanbul, Thresholds, and CI Integration

Run jest --coverage to generate a coverage report powered by Istanbul (NYC). Jest instruments your source code on the fly and tracks which lines, statements, branches, and functions are exercised during the test run.

Coverage Commands

# Generate coverage report (HTML, lcov, text)
npx jest --coverage

# Specify reporters
npx jest --coverage --coverageReporters=text --coverageReporters=html --coverageReporters=lcov

# Open HTML report
open coverage/index.html

# Fail if coverage drops below thresholds
npx jest --coverage --coverageThreshold='{"global":{"lines":80}}'

Coverage Configuration in jest.config.ts

const config: Config = {
  // ...
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.tsx',  // exclude Storybook files
    '!src/**/index.ts',       // exclude barrel files
    '!src/generated/**',      // exclude generated code
  ],
  coveragePathIgnorePatterns: [
    '/node_modules/',
    '/dist/',
    '/__tests__/',
  ],
  coverageReporters: ['text', 'lcov', 'html'],
  coverageThreshold: {
    global: {
      branches: 75,
      functions: 80,
      lines: 80,
      statements: 80,
    },
    // Per-file threshold (stricter for critical modules)
    './src/lib/auth.ts': {
      branches: 90,
      lines: 95,
    },
  },
};

Coverage in GitHub Actions CI

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test -- --coverage --ci --forceExit
        env:
          CI: true
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage/lcov.info

Understanding the Coverage Report

MetricWhat it MeasuresTypical Target
StatementsPercentage of executed statements80%+
BranchesPercentage of if/else/ternary branches taken75%+
FunctionsPercentage of functions called at least once80%+
LinesPercentage of source lines executed80%+

Testing Custom Hooks with renderHook and act

Custom hooks often encapsulate complex stateful logic. React Testing Library's renderHook utility lets you test hooks in isolation without needing a full component wrapper. Wrap state-changing calls in act() to flush React's update queue.

// hooks/useCounter.ts
import { useState, useCallback } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);
  return { count, increment, decrement, reset };
}

// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('initializes with the provided value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('increments the counter', () => {
    const { result } = renderHook(() => useCounter());
    act(() => {
      result.current.increment();
    });
    expect(result.current.count).toBe(1);
  });

  it('decrements the counter', () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => {
      result.current.decrement();
    });
    expect(result.current.count).toBe(4);
  });

  it('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(3));
    act(() => { result.current.increment(); });
    act(() => { result.current.increment(); });
    act(() => { result.current.reset(); });
    expect(result.current.count).toBe(3);
  });
});

Testing Hooks with Context Providers

// hooks/useAuth.ts
import { useContext } from 'react';
import { AuthContext } from '../context/AuthContext';

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within an AuthProvider');
  return ctx;
}

// hooks/useAuth.test.tsx
import { renderHook } from '@testing-library/react';
import { AuthProvider } from '../context/AuthContext';
import { useAuth } from './useAuth';

const wrapper = ({ children }: { children: React.ReactNode }) => (
  <AuthProvider initialUser={{ id: '1', name: 'Alice', role: 'admin' }}>
    {children}
  </AuthProvider>
);

it('returns the current user from AuthContext', () => {
  const { result } = renderHook(() => useAuth(), { wrapper });
  expect(result.current.user).toMatchObject({ name: 'Alice', role: 'admin' });
});

it('throws when used outside AuthProvider', () => {
  // renderHook without wrapper โ€” no provider present
  expect(() => renderHook(() => useAuth())).toThrow(
    'useAuth must be used within an AuthProvider'
  );
});

Jest vs Vitest vs Mocha vs Jasmine โ€” Framework Comparison

Choosing a test framework depends on your project's bundler, runtime, and team preferences. Here is a direct comparison of the four most popular JavaScript testing frameworks in 2025.

FeatureJestVitestMochaJasmine
Maintained byMeta (Facebook)Vitest team / Anthony FuCommunityPivotal / Community
Initial release2014202120112010
Zero configYesYes (Vite projects)No โ€” needs assertion lib + runnerYes
TypeScript supportVia ts-jest or BabelNative (Vite)Via ts-node / BabelVia types + Babel
Built-in assertionsYes (expect)Yes (expect โ€” Jest-compatible)No (use Chai, etc.)Yes (expect)
Built-in mockingYes (jest.fn, jest.mock)Yes (vi.fn, vi.mock)No (use Sinon)Yes (spyOn)
Snapshot testingYesYes (Jest-compatible)Plugin neededPlugin needed
Code coverageYes (Istanbul)Yes (V8 or Istanbul)Plugin (nyc)Plugin needed
Fake timersYesYesVia SinonYes
Watch modeYesYes (HMR-powered)Plugin (nodemon)Via CLI
jsdom supportYes (built-in)Yes (via jsdom pool)Via jsdom packageVia jsdom package
ESM supportExperimentalNativeYes (v10+)Limited
SpeedGood (parallel workers)Fastest (Vite HMR)ModerateGood
Best forReact / CRA / large appsVite / Vue / SvelteCustom setups / Node.jsAngular / legacy
GitHub stars (2025)~44K~13K~23K~16K

Migration from Jest to Vitest

Vitest is API-compatible with Jest for most use cases. The migration steps are minimal:

# 1. Install Vitest
npm install --save-dev vitest @vitest/coverage-v8 jsdom

# 2. Update vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  test: {
    globals: true,             // no need to import describe/it/expect
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      thresholds: { lines: 80, functions: 80, branches: 75 },
    },
  },
});

# 3. Replace jest.fn() with vi.fn(), jest.mock() with vi.mock()
# Most code requires no changes โ€” the API is intentionally compatible

Advanced Patterns โ€” Test Factories, Custom Matchers, and Projects

Test Data Factories

// test/factories/userFactory.ts
import { v4 as uuid } from 'uuid';

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

export function createUser(overrides: Partial<User> = {}): User {
  return {
    id: uuid(),
    name: 'Test User',
    email: 'test@example.com',
    role: 'user',
    createdAt: new Date('2024-01-01'),
    ...overrides,
  };
}

// Usage in tests
const admin = createUser({ role: 'admin', name: 'Alice Admin' });
const users = Array.from({ length: 10 }, (_, i) => createUser({ name: `User ${i}` }));

Custom Matchers with expect.extend()

// test/matchers/customMatchers.ts
expect.extend({
  toBeValidEmail(received: string) {
    const emailRegex = /^[^s@]+@[^s@]+.[^s@]+$/;
    const pass = emailRegex.test(received);
    return {
      pass,
      message: () =>
        pass
          ? `expected ${received} not to be a valid email`
          : `expected ${received} to be a valid email`,
    };
  },
  toBeWithinRange(received: number, floor: number, ceiling: number) {
    const pass = received >= floor && received <= ceiling;
    return {
      pass,
      message: () =>
        `expected ${received} ${pass ? 'not ' : ''}to be within range ${floor} - ${ceiling}`,
    };
  },
});

// Extend TypeScript types
declare global {
  namespace jest {
    interface Matchers<R> {
      toBeValidEmail(): R;
      toBeWithinRange(floor: number, ceiling: number): R;
    }
  }
}

// Usage
expect('user@example.com').toBeValidEmail();
expect(75).toBeWithinRange(0, 100);

Jest Projects Configuration (Monorepo)

// jest.config.ts (root of monorepo)
const config: Config = {
  projects: [
    {
      displayName: 'frontend',
      testEnvironment: 'jsdom',
      testMatch: ['<rootDir>/packages/frontend/**/*.test.tsx'],
      setupFilesAfterFramework: ['<rootDir>/jest.setup.ts'],
    },
    {
      displayName: 'backend',
      testEnvironment: 'node',
      testMatch: ['<rootDir>/packages/backend/**/*.test.ts'],
    },
    {
      displayName: 'shared',
      testEnvironment: 'node',
      testMatch: ['<rootDir>/packages/shared/**/*.test.ts'],
    },
  ],
  coverageThreshold: {
    global: { lines: 80 },
  },
};

Debugging and Troubleshooting Common Jest Issues

Debugging with VS Code

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Jest: Debug current file",
      "program": "${workspaceFolder}/node_modules/.bin/jest",
      "args": [
        "${relativeFile}",
        "--no-coverage",
        "--runInBand",
        "--watchAll=false"
      ],
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen"
    }
  ]
}

Common Issues and Fixes

IssueCauseFix
SyntaxError: Cannot use import statementJest runs in CommonJS by default; ESM modules not transformedAdd transformIgnorePatterns override or use Babel/ts-jest
Cannot find module "X" from test filemoduleNameMapper not configured for path aliasesAdd alias mapping to moduleNameMapper in jest.config
ReferenceError: document is not definedtestEnvironment defaults to "node" for non-browser testsSet testEnvironment: "jsdom" or add @jest-environment jsdom docblock
act() warning: An update was not wrapped in act()State update triggered outside React's rendering cycleWrap state-triggering calls in act() or use userEvent
Snapshot tests fail every timeDynamic values (Date, UUID) stored in snapshotsUse expect.any(Date) property matchers to ignore dynamic values
Tests hang and do not exitOpen handles: DB connections, HTTP servers not closedUse --forceExit or close connections in afterAll
Mock not resetting between testsclearMocks not configured, shared module-level mock stateSet clearMocks: true in config or call jest.clearAllMocks() in afterEach

Frequently Asked Questions

How do I mock an ES module with Jest?

Use jest.mock() at the top of the test file. For named exports, provide a factory function. For default exports, set __esModule: true in the factory. For projects using native ESM (without Babel), use jest.unstable_mockModule() with --experimental-vm-modules.

How do I set up Jest in a CI/CD pipeline?

Add a test:ci script: jest --ci --coverage --forceExit. The --ci flag disables interactive mode and fails on outdated snapshots. Set coverageThreshold in jest.config.js to enforce minimum coverage. Use the jest-junit reporter to emit JUnit XML for test result parsing in GitHub Actions or Jenkins.

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

jest.fn() creates a standalone mock function for dependency injection. jest.mock() replaces an entire module for all imports within a test file. jest.spyOn() wraps an existing method on an object, allowing tracking while optionally keeping the original implementation. Use mockRestore() to undo spies in afterEach.

How do I achieve test isolation between Jest tests?

Configure clearMocks: true (reset call counts) or resetMocks: true (also reset return values) in jest.config.js. Use beforeEach to set up fresh state. Jest runs each test file in a separate Node.js worker by default, providing automatic file-level isolation. For database tests, wrap each test in a transaction and roll it back.

How do I test async code with fake timers?

Call jest.useFakeTimers() in beforeEach and jest.useRealTimers() in afterEach. Advance time with jest.advanceTimersByTime(ms) or flush all pending timers with jest.runAllTimers(). In React component tests, combine with act() to flush state updates.

When should I update snapshots?

Only update snapshots when you intentionally changed the output. Run jest --updateSnapshot (or -u) and review every diff carefully. Never batch-update all snapshots without reviewing changes โ€” this defeats the regression-detection purpose. Prefer inline snapshots for small values that are easier to review in pull requests.

How do I improve slow Jest test suites?

Key optimizations: (1) enable cache: true in config, (2) stub heavy modules (CSS, images) via moduleNameMapper, (3) use jest.mock() to avoid real I/O, (4) set maxWorkers to 50% of CPU cores in CI, (5) limit collectCoverageFrom to source files only, and (6) consider switching to Vitest for Vite-based projects where native ESM and HMR make reruns significantly faster.

What is the best way to test API calls in React components?

Use Mock Service Worker (MSW) to intercept HTTP requests at the network level using a service worker or Node.js interceptor. MSW works transparently with any HTTP library (fetch, axios, ky) and makes tests realistic without depending on a real server. Set up MSW handlers in jest.setup.ts, define request handlers for test scenarios, and use server.use() for per-test overrides. Alternatively, use jest.mock('axios') for simpler scenarios where full network interception is unnecessary.

Related Developer Tools

When writing tests you often need to generate test data, validate JSON schemas, or format output. DevToolBox provides several free tools to streamline your testing workflow:

  • JSON Formatter โ€” Validate and format JSON responses returned by mocked API calls
  • Regex Tester โ€” Build and test regular expressions used in custom Jest matchers
  • UUID Generator โ€” Generate test UUIDs for use in test fixtures and factories
  • Base64 Encoder/Decoder โ€” Encode and decode test payloads and authorization headers
  • Hash Generator โ€” Generate SHA-256 hashes for testing cryptographic utilities
๐• Twitterin LinkedIn
Apakah ini membantu?

Tetap Update

Dapatkan tips dev mingguan dan tool baru.

Tanpa spam. Berhenti kapan saja.

Coba Alat Terkait

{ }JSON Formatter.*Regex TesterBโ†’Base64 Encoder

Artikel Terkait

Panduan Lengkap TypeScript Generics 2026: Dari Dasar hingga Pola Lanjutan

Kuasai TypeScript generics: parameter tipe, constraints, conditional types, mapped types, utility types, dan pola dunia nyata.

Panduan Lengkap React Hooks: useState, useEffect, dan Custom Hooks

Kuasai React Hooks dengan contoh praktis. Pelajari useState, useEffect, useContext, useReducer, useMemo, useCallback, custom hooks, dan React 18+ concurrent hooks.

Node.js Guide: Complete Tutorial for Backend Development

Master Node.js backend development. Covers event loop, Express.js, REST APIs, authentication with JWT, database integration, testing with Jest, PM2 deployment, and Node.js vs Deno vs Bun comparison.