How a 34‑Line QueryDeps Injection Makes Core Query Loops Fully Testable

The article shows how replacing module‑level spyOn with a tiny QueryDeps type and a productionDeps factory eliminates implicit coupling, reduces boilerplate, and enables isolated, type‑safe testing of the core query loop in a large Agent project.

James' Growth Diary
James' Growth Diary
James' Growth Diary
How a 34‑Line QueryDeps Injection Makes Core Query Loops Fully Testable

Testing the query.ts module in a large Agent project originally relied on module‑level jest.spyOn, which caused implicit coupling to internal imports, duplicated mock code across many test files, and broke under ESM when exports were read‑only.

Problem: spyOn Hell

Without a dedicated dependency abstraction, each test had to manually import the target module and apply jest.spyOn to functions such as callModel, microcompact, and autocompact. This created three pain points:

Implicit coupling – test files must know the exact import paths and function names inside query.ts.

Horizontal spread – the same mock setup appears in many test files, forcing copy‑and‑paste for every new scenario.

ESM limitations – spyOn cannot mock read‑only exports in some build configurations.

Solution: a type and a factory

A QueryDeps type declares the four external I/O functions used by the query loop, and a productionDeps() factory returns the real implementations. The file is only 40 lines (15 lines of actual code):

import { randomUUID } from 'crypto'
import { queryModelWithStreaming } from '../services/api/claude.js'
import { autoCompactIfNeeded } from '../services/compact/autoCompact.js'
import { microcompactMessages } from '../services/compact/microCompact.js'

export type QueryDeps = {
  // -- model
  callModel: typeof queryModelWithStreaming
  // -- compaction
  microcompact: typeof microcompactMessages
  autocompact: typeof autoCompactIfNeeded
  // -- platform
  uuid: () => string
}

export function productionDeps(): QueryDeps {
  return {
    callModel: queryModelWithStreaming,
    microcompact: microcompactMessages,
    autocompact: autoCompactIfNeeded,
    uuid: randomUUID,
  }
}

The QueryParams type in query.ts now accepts an optional deps field; if omitted it falls back to productionDeps(). Production code behaves exactly as before, while tests can inject a custom QueryDeps object.

Using typeof fn to keep signatures in sync

Each property of QueryDeps is typed as typeof the real function. Any change to the implementation signature automatically updates the type, and mismatched mock signatures cause compile‑time errors instead of silent runtime failures.

Writing tests with a fake deps object

A helper makeFakeDeps builds a fully controllable object. Example tests demonstrate handling a thrown error from callModel and verifying that autocompact is invoked when a token limit is reached.

function makeFakeDeps(overrides?: Partial<QueryDeps>): QueryDeps {
  return {
    callModel: async function* () { yield { type: 'text', text: 'mock response' } },
    microcompact: async (messages) => ({ messages, compactionInfo: null }),
    autocompact: async (messages) => ({ compactionResult: null, consecutiveFailures: 0 }),
    uuid: () => 'test-uuid-1234',
    ...overrides,
  }
}

it('handles callModel error gracefully', async () => {
  const deps = makeFakeDeps({
    callModel: async function* () { throw new Error('API timeout') },
  })
  const result = await query({ ...baseParams, deps })
  // assertions …
})

Compared with the old pattern, the new approach eliminates the need for import * + spyOn, removes beforeEach/afterEach mock lifecycle management, and lets each test supply an isolated plain object.

Deliberately narrow scope

The QueryDeps type intentionally contains only the four most frequently mocked functions. This minimal scope proves the pattern works while keeping the refactor low‑risk; additional I/O such as runTools or logging can be added later.

Comparison with class‑level DI frameworks

The pattern differs from class‑level DI frameworks (e.g., NestJS, InversifyJS) in several dimensions:

Granularity : class‑level frameworks operate on whole services; QueryDeps works at the single‑function call level.

Declaration : frameworks use decorators or registries; the pattern uses a plain TypeScript type.

Runtime cost : frameworks introduce an IoC container; the pattern has zero runtime overhead (plain object).

Testing : frameworks mock an entire service class; the pattern replaces individual functions.

Use case : frameworks suit long‑lived services; the pattern suits one‑off function calls like query().

Applying the pattern in other Agent projects

The same idea can be used for any core function that performs I/O. Define an AgentDeps type (e.g., callLLM, readFile, writeFile, timestamp) and a matching factory. Observability hooks and time‑dependent functions can also be injected, allowing tests to replace them with jest.fn() or fixed values.

type AgentDeps = {
  callLLM: typeof yourLLMClient.chat
  readFile: typeof fs.readFile
  writeFile: typeof fs.writeFile
  timestamp: () => number
}

function productionDeps(): AgentDeps {
  return {
    callLLM: yourLLMClient.chat.bind(yourLLMClient),
    readFile: fs.readFile,
    writeFile: fs.writeFile,
    timestamp: () => Date.now(),
  }
}

Placing logging, metric recording, or UUID generation into the deps object enables deterministic testing by supplying fixed implementations.

Summary

Function‑level DI via a simple type and factory makes the core query loop fully testable without any framework.

Using typeof fn keeps mock signatures synchronized with real implementations, catching mismatches at compile time.

The ?? productionDeps() fallback adds zero‑cost testability to production code.

A deliberately narrow dependency set reduces refactor risk and demonstrates the pattern before expanding.

Tests now construct plain objects, eliminating repetitive import *, spyOn, and restoreAllMocks boilerplate.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

TypeScripttestingAgentMockingdependency injectionFactory Pattern
James' Growth Diary
Written by

James' Growth Diary

I am James, focusing on AI Agent learning and growth. I continuously update two series: “AI Agent Mastery Path,” which systematically outlines core theories and practices of agents, and “Claude Code Design Philosophy,” which deeply analyzes the design thinking behind top AI tools. Helping you build a solid foundation in the AI era.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.