fn(args, deps) Is Not Anti-Mock
23 Mar 2026People hear "don't use vi.mock" and assume the alternative is "don't mock anything." That is not the argument.

The real distinction is simpler: mock explicit collaborators, not implicit imports.
The earlier post argued that vi.mock tends to couple tests to module wiring, while dependency injection tends to couple tests to the contract your code actually depends on.
That remains true.
But it leaves a follow-up question:
If the function already follows
fn(args, deps), what should the test use fordeps?
The answer is simple:
- for unit tests, pass a fake
depsobject - if you want the nicest ergonomics, generate that object with
vitest-mock-extended - reserve
vi.mockfor the cases where you truly cannot inject the dependency cleanly
That is not a different philosophy. It is the same one, applied consistently. The fn(args, deps) pattern makes dependencies explicit, local, and easy to substitute in tests. Tests should pass deps as data, not intercept the module system with global shared state.
The misunderstanding
Here is the mistaken mental model:
vi.mock= mocking- dependency injection = no mocking
That is the wrong split.
The real split is:
- module mocking: replacing what code imports
- dependency mocking: passing in a fake implementation of what the function declares it needs
Those are not the same thing.
The first changes module resolution. The second passes a different argument.
That is why fn(args, deps) fits so naturally with test doubles. The pattern already separates:
- what varies per call:
args - what the function needs from the outside world:
deps
That separation is the boundary. The test just supplies a different deps.
The default many teams reach for
Take a simple use case:
import { saveUser } from './db';
import { sendWelcomeEmail } from './mailer';
export async function createUser(name: string, email: string) {
const user = await saveUser({ name, email });
await sendWelcomeEmail(user);
return user;
}
To unit test this, the usual move is:
vi.mock('./db', () => ({
saveUser: vi.fn(),
}));
vi.mock('./mailer', () => ({
sendWelcomeEmail: vi.fn(),
}));
That works.
It also couples the test to:
- the file paths
- the export names
- Vitest's hoisting rules
- global mock state that has to be managed between tests
This is the architecture critique behind the anti-vi.mock argument: the test is asserting on module wiring, not just behavior. vi.mock relies on global shared state, while fn(args, deps) uses direct injection and each test owns its own dependencies.
The shape we actually want
Now look at the same use case with explicit dependencies:
export type CreateUserArgs = {
name: string;
email: string;
};
export type CreateUserDeps = {
db: {
save: (user: { name: string; email: string }) => Promise<User>;
};
mailer: {
sendWelcome: (user: User) => Promise<void>;
};
};
export async function createUser(args: CreateUserArgs, deps: CreateUserDeps) {
const user = await deps.db.save(args);
await deps.mailer.sendWelcome(user);
return user;
}
Now the unit test has no reason to touch vi.mock at all:
import { describe, it, expect, vi } from 'vitest';
import { createUser } from './createUser';
describe('createUser', () => {
it('creates a user and sends welcome email', async () => {
const mockUser = { id: '1', name: 'Alice', email: 'alice@test.com' };
const deps = {
db: { save: vi.fn().mockResolvedValue(mockUser) },
mailer: { sendWelcome: vi.fn().mockResolvedValue(undefined) },
};
const result = await createUser(
{ name: 'Alice', email: 'alice@test.com' },
deps,
);
expect(result).toEqual(mockUser);
expect(deps.mailer.sendWelcome).toHaveBeenCalledWith(mockUser);
});
});
No hoisting. No path coupling. No module interception. The mock is just an object. The function signature makes the seam explicit, and the test uses it. That is exactly the testing story fn(args, deps) is designed to support.
Where vitest-mock-extended fits
The obvious objection is ergonomic.
Manually writing fake dependency objects is fine at first. But once the dependency surface gets slightly deeper, hand-writing every nested vi.fn() becomes repetitive.
That is where vitest-mock-extended earns its keep.
import { describe, it, expect } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { createUser, type CreateUserDeps } from './createUser';
describe('createUser', () => {
it('creates a user and sends welcome email', async () => {
const mockUser = { id: '1', name: 'Alice', email: 'alice@test.com' };
const deps = mock<CreateUserDeps>();
deps.db.save.mockResolvedValue(mockUser);
const result = await createUser(
{ name: 'Alice', email: 'alice@test.com' },
deps,
);
expect(result).toEqual(mockUser);
expect(deps.mailer.sendWelcome).toHaveBeenCalledWith(mockUser);
});
});
That is not a compromise with the pattern. It is the pattern with better tooling. mock<CreateUserDeps>() produces an object with the right methods, backed by vi.fn(), while TypeScript enforces the shape. If a dep method gets renamed, the test fails to compile instead of drifting into runtime surprises.
Why this is different from vi.mock
Both approaches use the word "mock," but the mechanics are very different.
With vi.mock, the test reaches out into the runtime and says:
when code imports this module, secretly replace it
With mock<CreateUserDeps>(), the test says:
here is the deps object this function asked for
One relies on indirection. The other relies on explicitness.
But fn(args, deps) is not primarily a testing pattern. It is an architectural boundary. The same shape that makes unit tests simple also:
- keeps business logic independent from infrastructure
- maps cleanly onto ports and adapters
- makes composition roots straightforward
- makes abstractions cheap to extract and cheap to undo
That is why the pattern shows up repeatedly across the other posts in this series. In the hexagonal architecture piece, the function signature itself becomes the boundary between the application core and external capabilities. In the composition piece, growing deps signal when a responsibility has stabilized into its own function. In the legacy code piece, fn(args, deps = defaultDeps) becomes the seam that lets you migrate safely without breaking callers.
Testing is just where the benefits become obvious first.
A rule of thumb
Here is the rule I would actually give a team:
Mock explicit collaborators, not implicit imports.
That means:
- if your function takes
deps, mockdeps - if the deps object is annoying to hand-write, use
vitest-mock-extended - if the code under test imports a third-party module directly and you cannot introduce a seam yet, use
vi.mockpragmatically - if you own the business logic, prefer explicit injection over module interception
That rule preserves the core distinction.
It says "yes" to mocks as test doubles.
It says "no by default" to mocking the module system.
A worked example: repository + mailer
Here is the full progression.
Hidden imports
import { db } from './db';
import { mailer } from './mailer';
export async function inviteUser(email: string) {
const user = await db.users.insert({ email });
await mailer.sendInvite(user);
return user;
}
Module-mocked test
vi.mock('./db', () => ({
db: {
users: {
insert: vi.fn(),
},
},
}));
vi.mock('./mailer', () => ({
mailer: {
sendInvite: vi.fn(),
},
}));
Explicit deps
export type InviteUserArgs = {
email: string;
};
export type InviteUserDeps = {
db: {
users: {
insert: (input: { email: string }) => Promise<User>;
};
};
mailer: {
sendInvite: (user: User) => Promise<void>;
};
};
export async function inviteUser(args: InviteUserArgs, deps: InviteUserDeps) {
const user = await deps.db.users.insert({ email: args.email });
await deps.mailer.sendInvite(user);
return user;
}
Typed test with vitest-mock-extended
import { describe, it, expect } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { inviteUser, type InviteUserDeps } from './inviteUser';
describe('inviteUser', () => {
it('creates a user and sends an invite', async () => {
const user = { id: 'u1', email: 'alice@test.com' };
const deps = mock<InviteUserDeps>();
deps.db.users.insert.mockResolvedValue(user);
const result = await inviteUser({ email: 'alice@test.com' }, deps);
expect(result).toEqual(user);
expect(deps.mailer.sendInvite).toHaveBeenCalledWith(user);
});
});
That is the pattern at its cleanest:
- app code wires real deps at the boundary
- test code passes fake deps directly
- no module interception needed
What about partial application?
One concern people have is call-site noise.
Do I really have to pass deps through every call?
No.
The core implementation stays:
fn(args, deps)
But at the boundary you can bind deps once and expose a clean function to the rest of the app:
export const bindDeps =
<Args, Deps, Out>(fn: (args: Args, deps: Deps) => Out) =>
(deps: Deps) =>
(args: Args) =>
fn(args, deps);
So application code can use:
const inviteUserWithDeps = bindDeps(inviteUser)(realDeps);
await inviteUserWithDeps({ email: 'alice@test.com' });
While tests still hit the core function directly:
await inviteUser({ email: 'alice@test.com' }, mock<InviteUserDeps>());
That preserves the explicit core while keeping wiring ergonomic at the composition boundary. Implement in the explicit fn(args, deps) form, then partially apply deps where it improves ergonomics.
When vi.mock is still the right tool
None of this means vi.mock is forbidden.
There are still legitimate uses:
- third-party libraries that call globals internally
- platform APIs in code you do not control
- legacy modules where introducing a seam is not yet practical
- migration steps where
defaultDepsor wrappers are still being introduced
Defaults are acceptable as a strangler-fig migration seam, and vi.mock still has legitimate uses when the dependency is truly external or not yet injectable.
The problem is not mocking as such.
The problem is using module mocking as the default testing architecture for business code you control.
The pattern in one sentence
Here is the simplest way to say it:
fn(args, deps)is not anti-mock. It is anti-hidden dependency.
And that is why vitest-mock-extended fits so well.
It does not undermine the pattern. It reinforces it.
It says:
- keep dependencies explicit
- keep tests local
- keep the type system honest
- mock the collaborator, not the module system
That is the same architectural move from a different angle.
The takeaway
If your function already follows fn(args, deps), then vitest-mock-extended is not a compromise. It is one of the best ways to test that function.
Use vi.mock when you are forced to intercept a module boundary.
Use mock<Deps>() when the function has already declared its boundary explicitly.
That is the whole point:
- explicit deps in production
- typed fake deps in unit tests
- real wiring at the boundary
No magic. No hidden state. No pretending the import graph is the contract.
Just functions, data, and capabilities.