
Writing Code That Is Testable Without Making It Unreadable
There's a version of dependency injection that requires @Injectable() decorators, container registrations, and a 400-line config file. I don't use that version.
Then there's the version where you just pass things in instead of importing them inside the function. This version is available in every language, requires no framework, and makes code dramatically easier to test.
The difference between code I can test confidently and code I can't usually comes down to three things: direct database calls, direct calls to external services, and Date.now() / Math.random(). All three are hidden dependencies. You can't control them in tests.
What Makes Code Untestable
Here's a function I see constantly:
typescript// Hard to test import { db } from '../db' import { sendEmail } from '../email' export async function createUser(input: CreateUserInput) { const existing = await db.user.findFirst({ where: { email: input.email } }) if (existing) { throw new Error('Email already in use') } const user = await db.user.create({ data: input }) await sendEmail({ to: input.email, subject: 'Welcome!', template: 'welcome', }) return user }
To test this, you have to either connect to a real database (slow, stateful, requires setup) or mock the db module import (fragile, tied to internal implementation details). And the email side effect is completely invisible in tests.
The problem isn't what the function does. It's that it reaches out and grabs its own dependencies instead of having them provided.
Simple Dependency Injection
The minimal fix:
typescript// Testable interface UserRepository { findByEmail(email: string): Promise<User | null> create(data: CreateUserInput): Promise<User> } interface EmailService { sendWelcome(email: string): Promise<void> } export async function createUser( input: CreateUserInput, deps: { users: UserRepository email: EmailService } ) { const existing = await deps.users.findByEmail(input.email) if (existing) { throw new Error('Email already in use') } const user = await deps.users.create(input) await deps.email.sendWelcome(input.email) return user }
In production:
typescript// Real implementations const realDeps = { users: { findByEmail: (email) => db.user.findFirst({ where: { email } }), create: (data) => db.user.create({ data }), }, email: { sendWelcome: (email) => sendEmail({ to: email, template: 'welcome', subject: 'Welcome!' }), }, } await createUser(input, realDeps)
In tests:
typescripttest('throws if email is already in use', async () => { const deps = { users: { findByEmail: vi.fn().mockResolvedValue({ id: '1', email: 'test@example.com' }), create: vi.fn(), }, email: { sendWelcome: vi.fn(), }, } await expect( createUser({ name: 'Alice', email: 'test@example.com', password: 'secure' }, deps) ).rejects.toThrow('Email already in use') expect(deps.users.create).not.toHaveBeenCalled() expect(deps.email.sendWelcome).not.toHaveBeenCalled() }) test('sends welcome email on successful creation', async () => { const newUser = { id: '2', email: 'new@example.com', name: 'Alice' } const deps = { users: { findByEmail: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue(newUser), }, email: { sendWelcome: vi.fn().mockResolvedValue(undefined), }, } const result = await createUser( { name: 'Alice', email: 'new@example.com', password: 'secure' }, deps ) expect(result).toEqual(newUser) expect(deps.email.sendWelcome).toHaveBeenCalledWith('new@example.com') })
The tests are fast (no database), deterministic (no external services), and readable (the dependencies are explicit in the test).
The Time Problem
Code that uses new Date() or Date.now() directly is nondeterministic. Your tests pass on Tuesday and fail on Wednesday because the behavior depends on the real clock.
The fix is a clock abstraction:
typescriptexport interface Clock { now(): Date timestamp(): number } export const realClock: Clock = { now: () => new Date(), timestamp: () => Date.now(), } // Testable code receives the clock export function isSubscriptionActive( subscription: Subscription, clock: Clock = realClock // Default to real clock in production ): boolean { return subscription.expiresAt > clock.now() }
In tests:
typescriptconst fixedClock: Clock = { now: () => new Date('2024-06-01T12:00:00Z'), timestamp: () => new Date('2024-06-01T12:00:00Z').getTime(), } test('returns false for expired subscription', () => { const subscription = { expiresAt: new Date('2024-05-31T12:00:00Z'), // expired yesterday } expect(isSubscriptionActive(subscription, fixedClock)).toBe(false) }) test('returns true for active subscription', () => { const subscription = { expiresAt: new Date('2024-06-02T12:00:00Z'), // expires tomorrow } expect(isSubscriptionActive(subscription, fixedClock)).toBe(true) })
The tests will pass on any date because we're not using the real clock.
Class-Based Services (When You Have Many Dependencies)
For services with several dependencies, a class with constructor injection is cleaner than passing a deps object:
typescript// services/subscription.ts interface SubscriptionDeps { db: Database email: EmailService stripe: StripeClient clock: Clock } export class SubscriptionService { constructor(private deps: SubscriptionDeps) {} async cancelSubscription(userId: string): Promise<void> { const sub = await this.deps.db.subscription.findFirst({ where: { userId, status: 'active' }, }) if (!sub) throw new Error('No active subscription found') await this.deps.stripe.cancelSubscription(sub.stripeSubscriptionId) await this.deps.db.subscription.update({ where: { id: sub.id }, data: { status: 'canceled', canceledAt: this.deps.clock.now(), }, }) await this.deps.email.sendCancellationConfirmation(userId) } }
typescript// Test const mockDeps: SubscriptionDeps = { db: { subscription: { findFirst: vi.fn().mockResolvedValue({ id: 'sub_1', stripeSubscriptionId: 'stripe_sub_1', status: 'active', userId: 'user_1', }), update: vi.fn().mockResolvedValue({}), }, } as any, email: { sendCancellationConfirmation: vi.fn() }, stripe: { cancelSubscription: vi.fn() }, clock: { now: () => new Date('2024-06-01'), timestamp: () => 0 }, } const service = new SubscriptionService(mockDeps) test('cancels subscription and sends confirmation', async () => { await service.cancelSubscription('user_1') expect(mockDeps.stripe.cancelSubscription).toHaveBeenCalledWith('stripe_sub_1') expect(mockDeps.db.subscription.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'canceled' }), }) ) expect(mockDeps.email.sendCancellationConfirmation).toHaveBeenCalledWith('user_1') })
Wiring It Together in Production
At the application boundary, you create the real dependencies once and pass them down:
typescript// app.ts (entry point) import { realClock } from './lib/clock' import { db } from './lib/db' import { emailService } from './lib/email' import { stripeClient } from './lib/stripe' import { SubscriptionService } from './services/subscription' import { UserService } from './services/user' // Wire up real dependencies once at startup const services = { subscriptions: new SubscriptionService({ db, email: emailService, stripe: stripeClient, clock: realClock, }), users: new UserService({ db, email: emailService }), } // Pass services to route handlers app.use('/api', createApiRouter(services))
Route handlers receive the services they need, test with mock services:
typescript// routes/subscriptions.ts export function createSubscriptionRoutes( subscriptionService: SubscriptionService ) { const router = Router() router.delete('/subscriptions/:id', async (req, res) => { await subscriptionService.cancelSubscription(req.user.id) res.json({ ok: true }) }) return router }
The service doesn't know about HTTP. The route handler doesn't know about databases or Stripe. Both are easier to test independently.
The Line I Draw
I don't inject things that are genuinely universal and don't need to vary in tests: console.log, process.env values (I read these at startup and pass the values), and structural things like the router setup.
I do inject: anything that makes network calls, anything that reads or writes to storage, and anything that uses the current time or random values.
That's the practical version of the rule. It covers 90% of what makes code hard to test without requiring a framework or a 200-line composition root.