# fn(args, deps) Is Not Anti-Mock

By Jag Reehal, 2026-03-23. Canonical: https://arrangeactassert.com/posts/fn-args-deps-is-not-anti-mock/

<!-- Excerpt Start -->

People hear "don't use `vi.mock`" and assume the alternative is "don't mock anything." That is not the argument.

![fn(args, deps) is not anti-mock](/_astro/fn-args-deps-is-not-anti-mock.BS_lkN15_ZsWlYJ.webp)

The real distinction is simpler: mock explicit collaborators, not implicit imports.

<!-- Excerpt End -->

The [earlier post](/posts/to-mock-or-not/) 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 for `deps`?

The answer is simple:

- for unit tests, pass a fake `deps` object
- if you want the nicest ergonomics, generate that object with `vitest-mock-extended`
- reserve `vi.mock` for 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:

```ts
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:

```ts
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:

```ts
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:

```ts
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.

```ts
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](/posts/fn-args-deps-and-hexagonal-architecture/)
- makes [composition roots](/posts/composition-roots-and-fn-args-deps/) straightforward
- makes abstractions [cheap to extract and cheap to undo](/posts/fn-args-deps-and-composition/)

That is why the pattern shows up repeatedly across the other posts in this series. In the [hexagonal architecture piece](/posts/fn-args-deps-and-hexagonal-architecture/), the function signature itself becomes the boundary between the application core and external capabilities. In the [composition piece](/posts/fn-args-deps-and-composition/), growing deps signal when a responsibility has stabilized into its own function. In the [legacy code piece](/posts/fn-args-deps-bringing-order-to-chaos-without-breaking-anything/), `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`, mock `deps`
- 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.mock` pragmatically
- 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

```ts
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

```ts
vi.mock('./db', () => ({
  db: {
    users: {
      insert: vi.fn(),
    },
  },
}));

vi.mock('./mailer', () => ({
  mailer: {
    sendInvite: vi.fn(),
  },
}));
```

### Explicit deps

```ts
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

```ts
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:

```ts
fn(args, deps)
```

But at the boundary you can bind deps once and expose a clean function to the rest of the app:

```ts
export const bindDeps =
  <Args, Deps, Out>(fn: (args: Args, deps: Deps) => Out) =>
  (deps: Deps) =>
  (args: Args) =>
    fn(args, deps);
```

So application code can use:

```ts
const inviteUserWithDeps = bindDeps(inviteUser)(realDeps);
await inviteUserWithDeps({ email: 'alice@test.com' });
```

While tests still hit the core function directly:

```ts
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 `defaultDeps` or wrappers are still being introduced

Defaults are acceptable as a [strangler-fig migration seam](/posts/fn-args-deps-bringing-order-to-chaos-without-breaking-anything/), 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.