Mock fluent/chainable APIs with full call tracking and cross-framework matcher support
npm install chain-mockMock 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:
``typescript
// 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 }]),
})),
});
`
`typescript
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)],
);
`
- Installation
- Framework Setup
- API Reference
- Custom Matchers
- Examples
- Troubleshooting
- License
`bash
npm install -D chain-mock
Framework Setup
$3
1. Register matchers in your setup file:
`typescript
import { expect } from 'vitest';
import { matchers } from 'chain-mock';expect.extend(matchers);
`2. Add type augmentation in your
tsconfig.json:`json
{
"compilerOptions": {
"types": ["chain-mock/vitest"]
}
}
`Or use a triple-slash reference in a
.d.ts file:`typescript
///
`$3
1. Register matchers in your setup file:
`typescript
import { matchers } from 'chain-mock';expect.extend(matchers);
`2. Add type augmentation in your
tsconfig.json:`json
{
"compilerOptions": {
"types": ["chain-mock/jest"]
}
}
`Or use a triple-slash reference in a
.d.ts file:`typescript
///
`$3
1. Register matchers in your setup file:
`typescript
import { expect } from 'bun:test';
import { matchers } from 'chain-mock';expect.extend(matchers);
`2. Add type augmentation in your
tsconfig.json:`json
{
"compilerOptions": {
"types": ["bun", "chain-mock/bun"]
}
}
`Or use a triple-slash reference in a
.d.ts file:`typescript
///
`> [!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:
>
> `typescript
> chain.mockReturnValue('abc123');
> const result = chain();
>
> // Use explicit type parameter to avoid type error
> expect(result).toEqual('abc123');
> `$3
If the built-in type augmentation doesn't work for your setup, you can manually
augment your framework's types:
`typescript
import type { ChainMatchers } from 'chain-mock';// For Vitest
declare module 'vitest' {
interface Assertion extends ChainMatchers {}
interface AsymmetricMatchersContaining extends ChainMatchers {}
}
// For Jest with @jest/globals
declare module 'expect' {
interface Matchers extends ChainMatchers {}
}
// For Jest with global expect
declare global {
namespace jest {
interface Matchers extends ChainMatchers {}
}
}
// For Bun
declare module 'bun:test' {
interface Matchers extends ChainMatchers {}
interface AsymmetricMatchers extends ChainMatchers {}
}
// For other expect-based framework
declare module 'other-expect' {
interface Matchers extends ChainMatchers {}
}
`API Reference
$3
Creates a chainable mock instance.
`typescript
const mock = chainMock();// With type parameter for better inference
const mock = chainMock();
`$3
All configuration methods are chainable and can be set on any path in the chain.
#### Async Values
`typescript
// 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
`typescript
// Return value synchronously (breaks the chain)
mock.digest.mockReturnValue('abc123');
mock.digest.mockReturnValueOnce('abc123');
`#### Custom Implementation
`typescript
// Full control over behavior
mock.mockImplementation((...args) => computeResult(args));
mock.mockImplementationOnce((...args) => computeResult(args));
`#### Reset and Clear
`typescript
// Clear call history, keep configured values
mock.mockClear();// Reset everything (calls + configured values)
mock.mockReset();
`#### Mock Naming
`typescript
mock.mockName('dbSelectMock');
mock.getMockName(); // 'dbSelectMock'
`$3
Access call information directly via the
.mock property:`typescript
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]
`$3
####
chainMockedCasts a value to its
ChainMock type. Useful for typing mocked imports.`typescript
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.`typescript
if (isChainMock(maybeChainMock)) {
maybeChainMock.mockReturnValue('test');
}
`####
clearAllMocks()Clears call history for all chain mocks. Does not reset configured values.
`typescript
afterEach(() => {
clearAllMocks();
});
`####
resetAllMocks()Resets all chain mocks to their initial state, clearing both call history and
configured values.
`typescript
afterEach(() => {
resetAllMocks();
});
`Custom Matchers
After calling
expect.extend(matchers):$3
Verifies that each segment in the chain was called at least once.
`typescript
chain.select('id').from('users').where('active');
expect(chain.select.from.where).toHaveBeenChainCalled();
`$3
Verifies that each segment in the chain was called exactly
n times.`typescript
chain.select('id').from('users').where('active');
chain.select('name').from('posts').where('published');
expect(chain.select.from.where).toHaveBeenChainCalledTimes(2);
`$3
Verifies that any call to the chain had the corresponding arguments at each
segment. Pass one array of arguments per segment.
`typescript
chain.select('id').from('users').where('active');
expect(chain.select.from.where).toHaveBeenChainCalledWith(
['id'],
['users'],
['active'],
);
`$3
Verifies that each segment in the chain was called exactly once.
`typescript
chain.select('id').from('users').where('active');
expect(chain.select.from.where).toHaveBeenChainCalledExactlyOnce();
`$3
Verifies that each segment was called exactly once with the specified arguments.
`typescript
chain.select('id').from('users').where('active');
expect(chain.select.from.where).toHaveBeenChainCalledExactlyOnceWith(
['id'],
['users'],
['active'],
);
`$3
Verifies that the Nth call to each segment had the corresponding arguments.
`typescript
chain.select('id').from('users').where('active');
chain.select('name').from('posts').where('published');expect(chain.select.from.where).toHaveBeenNthChainCalledWith(
2,
['name'],
['posts'],
['published'],
);
`$3
Verifies that the last call to each segment had the corresponding arguments.
`typescript
chain.select('id').from('users').where('active');
chain.select('name').from('posts').where('published');expect(chain.select.from.where).toHaveBeenLastChainCalledWith(
['name'],
['posts'],
['published'],
);
`$3
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:
`typescript
// 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:
`typescript
// 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
$3
\ [Full example \] | \
[Drizzle ORM \]
`typescript
// 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
});
``typescript
// 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)],
);
});
`$3
\ [Full example \] | \ [Express \]
`typescript
// 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
});
``typescript
// With chain-mock ✨
it('returns 404 when user not found', async () => {
const mockRes = chainMock(); await handleGetUser(
{ params: { id: '999' } } as unknown as Request,
mockRes as unknown as Response,
);
expect(mockRes.status.json).toHaveBeenChainCalledWith(
[404],
[{ error: 'User not found' }],
);
});
`$3
\ [Full example \] | \
[ioredis \]
`typescript
// 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);
});
``typescript
// 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],
[],
);
});
`$3
\ [Full example \] | \ [D3.js \]
`typescript
// 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
``typescript
// 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)],
);
});
`$3
\ [Full example \] | \ [Cheerio
\]
`typescript
// 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('...');
expect(mock$).toHaveBeenCalledWith('.product');
expect(mockFind).toHaveBeenCalledWith('.price');
});
``typescript
// With chain-mock ✨
const [mock$] = await vi.hoisted(async () => {
const { chainMock } = await import('chain-mock');
return [chainMock()];
});
vi.mock('cheerio', () => ({ load: () => mock$ }));it('extracts price', async () => {
mock$.find.text.mockReturnValue('$29.99' as any);
const price = await scrapePrice(
...); expect(price).toBe('$29.99');
expect(mock$.find.text).toHaveBeenChainCalledWith(
['.product'],
['.price'],
[],
);
});
`Troubleshooting
$3
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, not the underlying value
type.Solution: Add an explicit type parameter to the matcher:
`typescript
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('hello');
`See Bun Matchers.toEqual for more
details.
$3
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.
`typescript
// 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!
});
`$3
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:`typescript
// ❌ 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.
$3
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:`typescript
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