npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

chain-mock

v0.2.0

Published

Mock fluent/chainable APIs with full call tracking and cross-framework matcher support

Downloads

43

Readme

chain-mock

Mock fluent/chainable APIs (Drizzle, Express, D3, Cheerio, ioredis, and more) with full call tracking and cross-framework matcher support.

The Problem

Testing code that uses chainable/fluent APIs is painful:

// Your code
const users = await db
  .select({ id: users.id })
  .from(users)
  .where(eq(users.id, 42));

// Your test... 😱
vi.mocked(db.select).mockReturnValue({
  from: vi.fn(() => ({
    where: vi.fn().mockResolvedValue([{ id: 42 }]),
  })),
});

The Solution

import { chainMock, matchers } from 'chain-mock';

// Setup (once in your test setup file)
expect.extend(matchers);

// In your test
const dbMock = chainMock();
dbMock.mockResolvedValue([{ id: 42, name: 'Dan' }]);

// Run your code
const result = await getUserById(dbMock, 42);

// Assert with ease
expect(result).toEqual([{ id: 42, name: 'Dan' }]);
expect(dbMock.select.from.where).toHaveBeenChainCalledWith(
  [{ id: users.id }],
  [users],
  [eq(users.id, 42)],
);

Table of Contents

Installation

npm install -D chain-mock

# or yarn
yarn add -D chain-mock

# or pnpm
pnpm add -D chain-mock

# or bun
bun add -D chain-mock

Framework Setup

Vitest

1. Register matchers in your setup file:

import { expect } from 'vitest';
import { matchers } from 'chain-mock';

expect.extend(matchers);

2. Add type augmentation in your tsconfig.json:

{
  "compilerOptions": {
    "types": ["chain-mock/vitest"]
  }
}

Or use a triple-slash reference in a .d.ts file:

/// <reference types="chain-mock/vitest" />

Jest

1. Register matchers in your setup file:

import { matchers } from 'chain-mock';

expect.extend(matchers);

2. Add type augmentation in your tsconfig.json:

{
  "compilerOptions": {
    "types": ["chain-mock/jest"]
  }
}

Or use a triple-slash reference in a .d.ts file:

/// <reference types="chain-mock/jest" />

Bun

1. Register matchers in your setup file:

import { expect } from 'bun:test';
import { matchers } from 'chain-mock';

expect.extend(matchers);

2. Add type augmentation in your tsconfig.json:

{
  "compilerOptions": {
    "types": ["bun", "chain-mock/bun"]
  }
}

Or use a triple-slash reference in a .d.ts file:

/// <reference types="chain-mock/bun" />

[!WARNING]

Bun's toEqual, toBe, and toStrictEqual matchers constrain the expected value to match the received type. When testing mockReturnValue results, use an explicit type parameter:

chain.mockReturnValue('abc123');
const result = chain();

// Use explicit type parameter to avoid type error
expect(result).toEqual<string>('abc123');

Manual Type Augmentation

If the built-in type augmentation doesn't work for your setup, you can manually augment your framework's types:

import type { ChainMatchers } from 'chain-mock';

// For Vitest
declare module 'vitest' {
  interface Assertion<T = any> extends ChainMatchers<T> {}
  interface AsymmetricMatchersContaining extends ChainMatchers {}
}

// For Jest with @jest/globals
declare module 'expect' {
  interface Matchers<R, T> extends ChainMatchers<R> {}
}

// For Jest with global expect
declare global {
  namespace jest {
    interface Matchers<R, T> extends ChainMatchers<R> {}
  }
}

// For Bun
declare module 'bun:test' {
  interface Matchers<T> extends ChainMatchers<T> {}
  interface AsymmetricMatchers extends ChainMatchers {}
}

// For other expect-based framework
declare module 'other-expect' {
  interface Matchers<R> extends ChainMatchers<R> {}
}

API Reference

chainMock<T>()

Creates a chainable mock instance.

const mock = chainMock();

// With type parameter for better inference
const mock = chainMock<typeof db>();

Mock Configuration

All configuration methods are chainable and can be set on any path in the chain.

Async Values

// Resolve with value when awaited
mock.mockResolvedValue([{ id: 1 }]);
mock.mockResolvedValueOnce([{ id: 1 }]);

// Reject with error when awaited
mock.mockRejectedValue(new Error('Connection failed'));
mock.mockRejectedValueOnce(new Error('Temporary failure'));

Sync Values

// Return value synchronously (breaks the chain)
mock.digest.mockReturnValue('abc123');
mock.digest.mockReturnValueOnce('abc123');

Custom Implementation

// Full control over behavior
mock.mockImplementation((...args) => computeResult(args));
mock.mockImplementationOnce((...args) => computeResult(args));

Reset and Clear

// Clear call history, keep configured values
mock.mockClear();

// Reset everything (calls + configured values)
mock.mockReset();

Mock Naming

mock.mockName('dbSelectMock');
mock.getMockName(); // 'dbSelectMock'

Direct Call Access

Access call information directly via the .mock property:

mock.select.mock.calls; // [['id'], ['name']]
mock.select.mock.lastCall; // ['name']
mock.select.mock.results; // [{ type: 'return', value: ... }]
mock.select.mock.contexts; // [thisArg1, thisArg2]
mock.select.mock.invocationCallOrder; // [1, 3]

Utility Functions

chainMocked<T>(value)

Casts a value to its ChainMock type. Useful for typing mocked imports.

import { db } from './db';

vi.mock('./db', () => ({
  db: chainMock(),
}));

const mockDb = chainMocked(db);
mockDb.select.mockResolvedValue([{ id: 42 }]);

isChainMock(value)

Type guard to check if a value is a ChainMock instance.

if (isChainMock(maybeChainMock)) {
  maybeChainMock.mockReturnValue('test');
}

clearAllMocks()

Clears call history for all chain mocks. Does not reset configured values.

afterEach(() => {
  clearAllMocks();
});

resetAllMocks()

Resets all chain mocks to their initial state, clearing both call history and configured values.

afterEach(() => {
  resetAllMocks();
});

Custom Matchers

After calling expect.extend(matchers):

toHaveBeenChainCalled()

Verifies that each segment in the chain was called at least once.

chain.select('id').from('users').where('active');
expect(chain.select.from.where).toHaveBeenChainCalled();

toHaveBeenChainCalledTimes(n)

Verifies that each segment in the chain was called exactly n times.

chain.select('id').from('users').where('active');
chain.select('name').from('posts').where('published');
expect(chain.select.from.where).toHaveBeenChainCalledTimes(2);

toHaveBeenChainCalledWith(...argsPerSegment)

Verifies that any call to the chain had the corresponding arguments at each segment. Pass one array of arguments per segment.

chain.select('id').from('users').where('active');
expect(chain.select.from.where).toHaveBeenChainCalledWith(
  ['id'],
  ['users'],
  ['active'],
);

toHaveBeenChainCalledExactlyOnce()

Verifies that each segment in the chain was called exactly once.

chain.select('id').from('users').where('active');
expect(chain.select.from.where).toHaveBeenChainCalledExactlyOnce();

toHaveBeenChainCalledExactlyOnceWith(...argsPerSegment)

Verifies that each segment was called exactly once with the specified arguments.

chain.select('id').from('users').where('active');
expect(chain.select.from.where).toHaveBeenChainCalledExactlyOnceWith(
  ['id'],
  ['users'],
  ['active'],
);

toHaveBeenNthChainCalledWith(n, ...argsPerSegment)

Verifies that the Nth call to each segment had the corresponding arguments.

chain.select('id').from('users').where('active');
chain.select('name').from('posts').where('published');

expect(chain.select.from.where).toHaveBeenNthChainCalledWith(
  2,
  ['name'],
  ['posts'],
  ['published'],
);

toHaveBeenLastChainCalledWith(...argsPerSegment)

Verifies that the last call to each segment had the corresponding arguments.

chain.select('id').from('users').where('active');
chain.select('name').from('posts').where('published');

expect(chain.select.from.where).toHaveBeenLastChainCalledWith(
  ['name'],
  ['posts'],
  ['published'],
);

Callable Root vs. Property Access

The matchers automatically detect whether the root mock was called as a function. This is useful for APIs like Cheerio where the root is callable ($('.selector')).

When root is called as a function: The root call is included as the first segment in the assertion. Provide an argument array for it:

// Cheerio-style: root is called as a function
chain('.product').find('.price').text();

// Root call included - 3 argument arrays for 3 segments
expect(chain.find.text).toHaveBeenChainCalledWith(
  ['.product'], // root call
  ['.price'], // .find()
  [], // .text()
);

When root is accessed as a property: The root is not included in the assertion. Provide argument arrays only for the accessed segments:

// Drizzle-style: root accessed as property
chain.select('id').from('users').where('active');

// No root call - 3 argument arrays for 3 segments
expect(chain.select.from.where).toHaveBeenChainCalledWith(
  ['id'], // .select()
  ['users'], // .from()
  ['active'], // .where()
);

Examples

Drizzle ORM

[ Full example ] | [ Drizzle ORM ]

// Without chain-mock 😱
vi.mock('./db', () => ({
  db: {
    select: vi.fn(() => ({
      from: vi.fn(() => ({
        where: vi.fn().mockResolvedValue([{ id: 42, name: 'Dan' }]),
      })),
    })),
  },
}));

it('finds user by id', async () => {
  const result = await findUserById(42);
  expect(result).toEqual({ id: 42, name: 'Dan' });
  // No way to easily assert on the chain calls
});
// With chain-mock ✨
vi.mock('./db', () => ({ db: chainMock() }));

const mockDb = chainMocked(db);

it('finds user by id', async () => {
  mockDb.select.from.where.mockResolvedValue([{ id: 42, name: 'Dan' }]);

  const result = await findUserById(42);

  expect(result).toEqual({ id: 42, name: 'Dan' });
  expect(mockDb.select.from.where).toHaveBeenChainCalledWith(
    [],
    [users],
    [eq(users.id, 42)],
  );
});

Express Response

[ Full example ] | [ Express ]

// Without chain-mock 😱
it('returns 404 when user not found', async () => {
  const res = { status: vi.fn(() => res), json: vi.fn(() => res) };

  await handleGetUser(
    { params: { id: '999' } } as unknown as Request,
    res as unknown as Response,
  );

  expect(res.status).toHaveBeenCalledWith(404);
  expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
  // Assertions are separate - can't verify the chain order
});
// With chain-mock ✨
it('returns 404 when user not found', async () => {
  const mockRes = chainMock<Response>();

  await handleGetUser(
    { params: { id: '999' } } as unknown as Request,
    mockRes as unknown as Response,
  );

  expect(mockRes.status.json).toHaveBeenChainCalledWith(
    [404],
    [{ error: 'User not found' }],
  );
});

ioredis Pipeline

[ Full example ] | [ ioredis ]

// Without chain-mock 😱
const mockExpire = vi.fn(() => ({ exec: mockExec }));
const mockHset = vi.fn(() => ({ expire: mockExpire }));

vi.mock('./redis', () => ({ redis: { pipeline: () => ({ hset: mockHset }) } }));

it('caches session', async () => {
  await cacheSession({ userId: '42', token: 'abc' });
  expect(mockHset).toHaveBeenCalledWith('session:42', 'token', 'abc');
  expect(mockExpire).toHaveBeenCalledWith('session:42', 3600);
});
// With chain-mock ✨
vi.mock('./redis', () => ({ redis: chainMock() }));

const mockRedis = chainMocked(redis);

it('caches session', async () => {
  await cacheSession({ id: '123', data: { name: 'Dan' } });
  expect(mockRedis.pipeline.set.expire.exec).toHaveBeenChainCalledWith(
    [],
    ['123', JSON.stringify({ name: 'Dan' })],
    ['123', 3600],
    [],
  );
});

D3.js

[ Full example ] | [ D3.js ]

// Without chain-mock 😱
const mockAttr2 = vi.fn(() => mockSelection);
const mockAttr = vi.fn(() => ({ attr: mockAttr2 }));
const mockAppend = vi.fn(() => ({ attr: mockAttr }));
const mockEnter = vi.fn(() => ({ append: mockAppend }));
const mockData = vi.fn(() => ({ enter: mockEnter }));
const mockSelectAll = vi.fn(() => ({ data: mockData }));
const mockSelection = { selectAll: mockSelectAll };
vi.mock('d3', () => ({ select: () => mockSelection }));
// ...and we haven't even written the assertions yet
// With chain-mock ✨
vi.mock('d3', () => ({ select: chainMock() }));

const mockSelect = chainMocked(d3.select);

it('renders bars with correct dimensions', () => {
  renderBarChart('#chart', [10, 20, 30]);

  expect(
    mockSelect.selectAll.data.enter.append.attr.attr,
  ).toHaveBeenChainCalledWith(
    ['#chart'],
    ['.bar'],
    [[10, 20, 30]],
    [],
    ['rect'],
    ['class', 'bar'],
    ['height', expect.any(Function)],
  );
});

Cheerio

[ Full example ] | [ Cheerio ]

// Without chain-mock 😱
const mockText = vi.fn(() => '$29.99');
const mockFirst = vi.fn(() => ({ text: mockText }));
const mockFind = vi.fn(() => ({ first: mockFirst }));
const mock$ = vi.fn(() => ({ find: mockFind }));

vi.mock('cheerio', () => ({ load: () => mock$ }));

it('extracts price', async () => {
  const price = await scrapePrice('<html>...</html>');
  expect(mock$).toHaveBeenCalledWith('.product');
  expect(mockFind).toHaveBeenCalledWith('.price');
});
// With chain-mock ✨
const [mock$] = await vi.hoisted(async () => {
  const { chainMock } = await import('chain-mock');
  return [chainMock<cheerio.CheerioAPI>()];
});
vi.mock('cheerio', () => ({ load: () => mock$ }));

it('extracts price', async () => {
  mock$.find.text.mockReturnValue('$29.99' as any);

  const price = await scrapePrice(`<html>...</html>`);

  expect(price).toBe('$29.99');
  expect(mock$.find.text).toHaveBeenChainCalledWith(
    ['.product'],
    ['.price'],
    [],
  );
});

Troubleshooting

"Argument of type 'X' is not assignable to parameter of type 'Y'" (Bun)

Bun's toEqual, toBe, and toStrictEqual matchers use TypeScript's NoInfer utility to constrain the expected value to match the received type. When a ChainMock is called, the return type is ChainMock<T>, not the underlying value type.

Solution: Add an explicit type parameter to the matcher:

const mock = chainMock();
mock.mockReturnValue('hello');
const result = mock();

// ❌ Error: Argument of type 'string' is not assignable...
expect(result).toEqual('hello');

// ✅ Fix: add explicit type parameter
expect(result).toEqual<string>('hello');

See Bun Matchers.toEqual for more details.

Async function returns undefined instead of ChainMock

ChainMock implements PromiseLike, so when returned from an async function, JavaScript automatically awaits it and resolves to its mocked value (or undefined if no value was configured).

Solution: Wrap the ChainMock in a tuple or object to prevent automatic resolution.

// Test helper that loads fixtures and creates a configured mock
async function setupDbMock() {
  const fixtures = await loadFixtures('./users.json');

  const mock = chainMock();
  mock.select.from.where.mockResolvedValue(fixtures);

  return mock; // ❌ Awaited and resolved to undefined!
}

it('queries users', async () => {
  const db = await setupDbMock();
  db.select('*').from('users'); // ❌ Error: db is undefined
});

// Fix: wrap in tuple
async function setupDbMock() {
  const fixtures = await loadFixtures('./users.json');

  const mock = chainMock();
  mock.select.from.where.mockResolvedValue(fixtures);

  return [mock] as const; // ✅ Tuple prevents resolution
}

it('queries users', async () => {
  const [db] = await setupDbMock();
  db.select('*').from('users'); // ✅ Works!
});

"Cannot access '__vi_import_0__' before initialization" (Vitest)

vi.mock() is hoisted to the top of the file, before any imports are evaluated. If you try to use chainMock from a static import inside vi.mock() or vi.hoisted(), the import hasn't been initialized yet.

Solution: Use vi.hoisted() with a dynamic import() and wrap the mock in a tuple:

// ❌ Wrong: static import is not available in hoisted code
import { chainMock } from 'chain-mock';
const mock = vi.hoisted(() => chainMock()); // Error!

// ✅ Correct: use dynamic import inside vi.hoisted
const [mock] = await vi.hoisted(async () => {
  const { chainMock } = await import('chain-mock');
  return [chainMock()];
});

vi.mock('./module', () => ({ fn: mock }));

See Vitest vi.hoisted for more details.

"mockClear() on a nested chain path" error

Calling mockClear() on a nested path (e.g., chain.select.from.where.mockClear()) throws an error. This is by design: nested mockClear() would only clear the specified path and its children, not ancestor paths like select or select.from. This leads to unexpected behavior when using chain matchers, which check all segments in the path.

Solution: Always call mockClear() on the root mock:

const chain = chainMock();
chain.select('id').from('users').where('active');

// ❌ Error: clears only "where", not "select" or "from"
chain.select.from.where.mockClear();

// ✅ Correct: clears all paths in the chain
chain.mockClear();

// Now assertions work as expected
chain.select('name').from('posts').where('published');
expect(chain.select.from.where).toHaveBeenChainCalledExactlyOnce();

The same applies to mockReset() - always call it on the root mock.

License

Apache-2.0

Copyright 2026 Charles Francoise