https://github.com/loderunner/chain-mock
Mock fluent/chainable APIs with full call tracking and cross-framework matcher support
https://github.com/loderunner/chain-mock
bun chainable fluent jest matchers mock proxy testing vitest
Last synced: about 2 months ago
JSON representation
Mock fluent/chainable APIs with full call tracking and cross-framework matcher support
- Host: GitHub
- URL: https://github.com/loderunner/chain-mock
- Owner: loderunner
- License: apache-2.0
- Created: 2026-01-27T16:20:22.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-03-31T13:54:53.000Z (about 2 months ago)
- Last Synced: 2026-03-31T15:33:30.638Z (about 2 months ago)
- Topics: bun, chainable, fluent, jest, matchers, mock, proxy, testing, vitest
- Language: TypeScript
- Homepage:
- Size: 533 KB
- Stars: 3
- Watchers: 0
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Funding: .github/FUNDING.yml
- License: LICENSE
Awesome Lists containing this project
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:
```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 }]),
})),
});
```
### The Solution
```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)],
);
```
### Table of Contents
- [Installation](#installation)
- [Framework Setup](#framework-setup)
- [API Reference](#api-reference)
- [Custom Matchers](#custom-matchers)
- [Examples](#examples)
- [Troubleshooting](#troubleshooting)
- [License](#license)
## Installation
```bash
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:
```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
///
```
### Jest
**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
///
```
### Bun
**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');
> ```
### Manual Type Augmentation
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
### `chainMock()`
Creates a chainable mock instance.
```typescript
const mock = chainMock();
// With type parameter for better inference
const mock = chainMock();
```
### Mock Configuration
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'
```
### Direct Call Access
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]
```
### Utility Functions
#### `chainMocked(value)`
Casts 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)`:
### `toHaveBeenChainCalled()`
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();
```
### `toHaveBeenChainCalledTimes(n)`
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);
```
### `toHaveBeenChainCalledWith(...argsPerSegment)`
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'],
);
```
### `toHaveBeenChainCalledExactlyOnce()`
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();
```
### `toHaveBeenChainCalledExactlyOnceWith(...argsPerSegment)`
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'],
);
```
### `toHaveBeenNthChainCalledWith(n, ...argsPerSegment)`
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'],
);
```
### `toHaveBeenLastChainCalledWith(...argsPerSegment)`
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'],
);
```
### 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:
```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
### Drizzle ORM
\[ [Full example](examples/drizzle) \] | \[
[Drizzle ORM](https://orm.drizzle.team/) \]
```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)],
);
});
```
### Express Response
\[ [Full example](examples/express) \] | \[ [Express](https://expressjs.com/) \]
```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' }],
);
});
```
### ioredis Pipeline
\[ [Full example](examples/ioredis) \] | \[
[ioredis](https://github.com/redis/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],
[],
);
});
```
### D3.js
\[ [Full example](examples/d3) \] | \[ [D3.js](https://d3js.org/) \]
```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)],
);
});
```
### Cheerio
\[ [Full example](examples/cheerio) \] | \[ [Cheerio](https://cheerio.js.org/)
\]
```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
### "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`, 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](https://bun.sh/docs/test/writing#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.
```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!
});
```
### "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:
```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](https://vitest.dev/api/vi.html#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:
```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.
## License
Apache-2.0
Copyright 2026 Charles Francoise