Mock fluent/chainable APIs (Drizzle, Express, D3, Cheerio, ioredis, and more) with full call tracking and cross-framework matcher support.
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 }]),
})),
});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)],
);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-mock1. 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" />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" />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');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> {}
}Creates a chainable mock instance.
const mock = chainMock();
// With type parameter for better inference
const mock = chainMock<typeof db>();All configuration methods are chainable and can be set on any path in the chain.
// 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'));// Return value synchronously (breaks the chain)
mock.digest.mockReturnValue('abc123');
mock.digest.mockReturnValueOnce('abc123');// Full control over behavior
mock.mockImplementation((...args) => computeResult(args));
mock.mockImplementationOnce((...args) => computeResult(args));// Clear call history, keep configured values
mock.mockClear();
// Reset everything (calls + configured values)
mock.mockReset();mock.mockName('dbSelectMock');
mock.getMockName(); // 'dbSelectMock'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]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 }]);Type guard to check if a value is a ChainMock instance.
if (isChainMock(maybeChainMock)) {
maybeChainMock.mockReturnValue('test');
}Clears call history for all chain mocks. Does not reset configured values.
afterEach(() => {
clearAllMocks();
});Resets all chain mocks to their initial state, clearing both call history and configured values.
afterEach(() => {
resetAllMocks();
});After calling expect.extend(matchers):
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();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);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'],
);Verifies that each segment in the chain was called exactly once.
chain.select('id').from('users').where('active');
expect(chain.select.from.where).toHaveBeenChainCalledExactlyOnce();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'],
);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'],
);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'],
);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()
);[ 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)],
);
});[ 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' }],
);
});[ 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],
[],
);
});[ 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)],
);
});[ 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'],
[],
);
});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.
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!
});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.
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.
Apache-2.0
Copyright 2026 Charles Francoise