Dependency injection in AI agents: tests that actually work
There's a bug in production. The billing agent generated 23 invoices with the wrong customer. The CTO calls. You open your local environment, run the same case — and it works perfectly.
You can't reproduce it because the agent's tools are hardcoded: the real database, the real email service, the real external API. Local has different data. CI does too. The bug lives in an environment you don't control.
That's the exact cost of building AI agents without dependency injection.
Why agents without DI are untestable
A typical AI agent has tools: it queries the database, sends emails, calls external APIs. When those tools are instantiated inside the agent, you face three problems that collapse into one big one:
Problem 1: Tests that break real things. Every time you run the test suite, the agent sends real emails, modifies production data, and burns API credits. Either you comment out the tests or you risk real side effects.
Problem 2: You can't reproduce production bugs. In production the agent sees real data in specific states. Locally you see a different database with different data. The bug exists in a context you can't replicate.
Problem 3: You can't test edge cases. What does the agent do when the database returns a timeout? When the email service is down? With hardcoded tools, forcing that in tests without affecting production is nearly impossible.
The solution has existed in classic software engineering for decades: dependency injection. Pass the dependencies from the outside, instead of creating them inside.
The ToolContainer pattern
Define a TypeScript interface that groups all of the agent's tools. The implementation can be real, staging, or a mock:
// tools/types.ts
export interface AgentToolContainer {
db: {
getCustomer: (id: string) => Promise<Customer | null>;
saveInvoice: (invoice: Invoice) => Promise<string>;
};
email: {
send: (to: string, template: string, data: object) => Promise<void>;
};
search: {
query: (q: string) => Promise<SearchResult[]>;
};
}
Then define separate implementations per environment:
// tools/environments.ts
export const productionTools: AgentToolContainer = {
db: new SupabaseDBTool(supabaseClient),
email: new ResendEmailTool(resendApiKey),
search: new TavilySearchTool(tavilyApiKey),
};
export const stagingTools: AgentToolContainer = {
db: new SupabaseDBTool(stagingSupabase),
email: new MockEmailTool({ verbose: true }), // no sends, just logs
search: new CachedSearchTool(tavilyApiKey), // cached responses
};
export const testTools = (overrides: Partial<AgentToolContainer>) =>
createMockToolContainer(overrides);
The agent that doesn't know which environment it's in
The agent receives the container as a parameter. It doesn't instantiate anything internally:
// agents/billing-agent.ts
export async function runBillingAgent(
userMessage: string,
tools: AgentToolContainer,
): Promise<AgentResult> {
const anthropicTools = buildToolDefinitions(tools);
const response = await anthropic.messages.create({
model: 'claude-opus-4-7',
tools: anthropicTools,
max_tokens: 2048,
messages: [{ role: 'user', content: userMessage }],
});
return processResponse(response, tools);
}
In the Next.js endpoint you use productionTools. In tests, mocks. The agent code doesn't change:
// app/api/billing/route.ts
export async function POST(req: Request) {
const { message } = await req.json();
return runBillingAgent(message, productionTools);
}
Deterministic tests in milliseconds
With an injected ToolContainer, tests are clean, fast, and side-effect-free:
// __tests__/billing-agent.test.ts
describe('BillingAgent', () => {
it('generates invoice when customer exists', async () => {
const saveInvoice = jest.fn().mockResolvedValue('INV-001');
const tools = testTools({
db: {
getCustomer: () => Promise.resolve(fixtures.customer123),
saveInvoice,
},
});
const result = await runBillingAgent(
'Generate invoice for customer 123',
tools,
);
expect(saveInvoice).toHaveBeenCalledWith(
expect.objectContaining({ customerId: '123', status: 'draft' }),
);
expect(result.status).toBe('success');
});
it('handles DB error with controlled response', async () => {
const tools = testTools({
db: {
getCustomer: () => Promise.reject(new Error('timeout')),
saveInvoice: jest.fn(),
},
});
const result = await runBillingAgent(
'Generate invoice for customer 123',
tools,
);
expect(result.status).toBe('error');
expect(result.message).toContain('unavailable');
});
});
The second test covers the agent's behavior when the database fails. With hardcoded tools, this requires a much more complex mocking infrastructure. Here it's 5 lines.
Fixture recording: production cases as test fixtures
The next level is recording real tool responses from production and turning them into fixtures:
// tools/recording-tool.ts
export class RecordingDBTool implements AgentToolContainer['db'] {
constructor(
private real: AgentToolContainer['db'],
private recorder: FixtureRecorder,
) {}
async getCustomer(id: string) {
const result = await this.real.getCustomer(id);
await this.recorder.save(`db.getCustomer.${id}`, result);
return result;
}
async saveInvoice(invoice: Invoice) {
const result = await this.real.saveInvoice(invoice);
await this.recorder.save('db.saveInvoice', { input: invoice, output: result });
return result;
}
}
Run RecordingDBTool in production for 30 minutes, save the fixtures as JSON, and you have real production cases in your test suite. It's the equivalent of VCR cassettes for HTTP, applied to AI agent tools. When the production bug arrives that you can't reproduce, the fixture is already there.
What changes when you apply this pattern
A team that implements this goes from:
- CI that takes 8 minutes and fails 3 out of 10 times because of external service timeouts
- Tests nobody runs because "they break staging data"
- Production bugs that take hours to reproduce
To:
- Test suite that runs in under 30 seconds without touching any external service
- 15 edge cases covered with fully controlled data
- Any production bug reproducible locally in under 5 minutes
This pattern is the first structural change I recommend when a team has technical debt in their AI integration layer: before adding more tools or more agents, first make the existing ones testable.
If your team is building agents and tests are the main blocker to iterating with confidence, let's talk directly: