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.
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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
