Key insight

Don't replace mocks with slowdep

Jest mocks and slowdep serve different purposes at different levels of the test pyramid. Jest mocks give you isolation and control: every call returns exactly what you configured, instantly, with no side effects. That's exactly what you want for unit tests where you're testing a function's logic in isolation.

slowdep gives you realism: it wraps the real (or mock) function and adds a latency distribution. Use it when you're testing how your code behaves when a dependency is slow — not when you're testing what the dependency returns. These are separate concerns.

The rule

Unit tests: use Jest mocks for isolation, speed, and control. Integration tests: use slowdep (wrapping a mock or a real function) to add realistic latency.

Feature comparison

Comparison table

featurejest.mock()slowdep
response time synchronous / instant lognormal distribution (realistic)
error simulation explicit — .mockRejectedValue() probabilistic — errorRate option
realistic behavior no — always instant yes — lognormal latency
test isolation complete — no side effects possible partial — wraps real or mock function
setup complexity zero — built into Jest minimal — one import, one wrap
call tracking full — .mock.calls, .mock.results none — compose with jest.fn() for tracking
composable with real implementations jest.requireActual() wraps any function, real or mock
tests retry logic no — errors are deterministic, not probabilistic yes — probabilistic errors exercise retry paths
When to use

The right tool for the right layer

1
Unit tests — use Jest mocks
You're testing a function in isolation. The dependency behavior doesn't matter — you just need it to return a value. Jest mocks are perfect: instant, deterministic, trackable. jest.fn().mockResolvedValue({ id: 1 }) is the right tool.
2
Integration tests — use slowdep
You're testing how code behaves when a dependency is slow or unreliable. Wrap the real function (or a mock that returns realistic data) with slowdep. The latency distribution exercises retry logic, timeout handling, and loading states.
3
Combined — jest.fn() + slowdep
Get call tracking from jest.fn() and realistic latency from slowdep. withLatency(jest.fn().mockResolvedValue(data), 'postgres') gives you the trackability of a Jest mock with realistic latency behavior.
Code examples

Same scenario, different tools

// Unit test: testing UserService.getUser logic in isolation
// Goal: verify the function parses DB response correctly
// Latency doesn't matter here — we're testing logic

jest.mock('../db');
import { db } from '../db';
import { UserService } from './UserService';

describe('UserService.getUser', () => {
  it('returns null when user not found', async () => {
    (db.query as jest.Mock).mockResolvedValue({ rows: [] });
    const result = await new UserService(db).getUser('999');
    expect(result).toBeNull();
  });

  it('maps rows to User objects', async () => {
    (db.query as jest.Mock).mockResolvedValue({
      rows: [{ id: '1', name: 'Alice', email: 'alice@example.com' }]
    });
    const user = await new UserService(db).getUser('1');
    expect(user).toMatchObject({ name: 'Alice', email: 'alice@example.com' });
  });
});
// Integration test: testing UserService under realistic DB latency
// Goal: verify retry logic fires correctly under slow DB calls
import { withLatency } from 'slowdep';

describe('UserService with realistic DB latency', () => {
  const realData = { rows: [{ id: '1', name: 'Alice' }] };

  it('handles occasional DB slowness gracefully', async () => {
    const slowDb = {
      query: withLatency(
        async (sql: string) => realData,
        { p50: 5, p99: 200, errorRate: 0.05 }
      ),
    };

    // run 20 calls — errorRate ensures retries get exercised
    const svc = new UserService(slowDb);
    const results = await Promise.allSettled(
      Array.from({ length: 20 }, () => svc.getUser('1'))
    );
    const succeeded = results.filter(r => r.status === 'fulfilled');
    expect(succeeded.length).toBeGreaterThan(15); // retries salvage most calls
  });
});
// Combined: jest.fn() for tracking + slowdep for latency
import { withLatency } from 'slowdep';

describe('UserService — tracked realistic test', () => {
  it('calls DB exactly once per request under normal conditions', async () => {
    const baseQuery = jest.fn().mockResolvedValue({
      rows: [{ id: '1', name: 'Alice' }]
    });
    const slowDb = {
      // jest.fn() for call tracking, slowdep for latency
      query: withLatency(baseQuery, { p50: 5, p99: 200, errorRate: 0 }),
    };

    const user = await new UserService(slowDb).getUser('1');

    expect(user.name).toBe('Alice');
    expect(baseQuery).toHaveBeenCalledTimes(1);         // tracked by jest.fn()
    expect(baseQuery).toHaveBeenCalledWith(              // verified call args
      expect.stringContaining('SELECT'), ['1']
    );
  });
});
Summary

The test pyramid

Think of the test pyramid: unit tests at the base (fast, isolated, Jest mocks), integration tests in the middle (realistic behavior, slowdep), E2E at the top (real services, Toxiproxy or staging). Each layer catches different bugs. Each needs different tools.

layertoolwhat it catches
unit testsjest.mock()logic bugs, parsing bugs, branching
integration testsslowdep + jest.fn()retry bugs, timeout bugs, latency UX
E2E / stagingreal services or Toxiproxynetwork bugs, infra bugs, system-level behavior