Loading content...
Many developers treat testing as something that happens after code is written—a verification step that follows implementation. This approach inevitably leads to frustration: code that seemed straightforward becomes nightmarish to test, requiring complex setup, fragile mocks, or simply remaining untested because 'it's too hard.'
The solution is a fundamental shift in perspective: testability is not a testing concern—it's a design quality. Just as we design for performance, maintainability, and extensibility, we must design for testability. When testability is considered during design, testing becomes natural and straightforward. When it's ignored, testing becomes an uphill battle against the code's structure.
This page explores how DIP, combined with complementary design practices, creates code that is inherently testable.
By the end of this page, you will understand the architectural patterns that promote testability, how to design APIs that facilitate testing, the relationship between DIP and other SOLID principles in creating testable code, and organizational strategies for maintaining testability as systems evolve.
Designing for testability requires adopting a specific mental model when writing code. Every design decision should be evaluated through the lens of: How would I test this?
The design implication:
If you answer 'no' to any of these questions, you've identified a testability problem. The remarkable insight is that fixing testability problems almost always improves the design overall. Testability and good design are deeply correlated—they reinforce each other.
This is because testability problems are usually symptoms of:
Before committing any new code, imagine writing a unit test for it. If you immediately see how to write a clear, focused test, the design is likely sound. If you feel resistance—'this would be hard to test'—pause and reconsider the design. Your testing intuition is surfacing deeper design issues.
Several architectural patterns naturally promote testability by establishing clear boundaries, separating concerns, and ensuring dependencies flow in a controllable direction. These patterns leverage DIP as their foundation.
Hexagonal Architecture (also called Ports and Adapters) places business logic at the center, surrounded by ports (interfaces) that define how the core interacts with the outside world. Adapters implement these ports for specific technologies.
Why it's testable: The core can be tested completely in isolation by providing mock adapters. The core has no knowledge of databases, APIs, or frameworks—only abstract ports.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// Hexagonal Architecture - testable by design // PORTS (interfaces defined by the core)// Primary port - how the outside world drives the applicationinterface OrderService { placeOrder(order: OrderRequest): Promise<OrderConfirmation>; cancelOrder(orderId: string): Promise<void>; getOrderStatus(orderId: string): Promise<OrderStatus>;} // Secondary ports - how the core reaches the outside worldinterface OrderRepository { save(order: Order): Promise<Order>; findById(id: string): Promise<Order | null>; update(order: Order): Promise<void>;} interface PaymentGateway { processPayment(payment: Payment): Promise<PaymentResult>; refund(transactionId: string): Promise<RefundResult>;} interface NotificationService { sendOrderConfirmation(email: string, order: Order): Promise<void>; sendCancellationNotice(email: string, orderId: string): Promise<void>;} // CORE (pure business logic, depends only on ports)class OrderServiceCore implements OrderService { constructor( private readonly orders: OrderRepository, private readonly payments: PaymentGateway, private readonly notifications: NotificationService ) {} async placeOrder(request: OrderRequest): Promise<OrderConfirmation> { // Pure business logic - no infrastructure knowledge const order = Order.create(request); order.validate(); // Domain validation const paymentResult = await this.payments.processPayment( order.createPayment() ); if (!paymentResult.success) { throw new PaymentFailedException(paymentResult.error); } order.markAsPaid(paymentResult.transactionId); const savedOrder = await this.orders.save(order); await this.notifications.sendOrderConfirmation( request.email, savedOrder ); return OrderConfirmation.from(savedOrder); } // ... other methods} // ADAPTERS (implementations for specific technologies)// These are tested separately with integration testsclass PostgresOrderRepository implements OrderRepository { constructor(private readonly db: Pool) {} async save(order: Order): Promise<Order> { /* SQL implementation */ } async findById(id: string): Promise<Order | null> { /* SQL implementation */ } async update(order: Order): Promise<void> { /* SQL implementation */ }} class StripePaymentGateway implements PaymentGateway { constructor(private readonly stripe: Stripe) {} async processPayment(payment: Payment): Promise<PaymentResult> { /* Stripe API */ } async refund(transactionId: string): Promise<RefundResult> { /* Stripe API */ }} // TESTING - core tests with mock adaptersdescribe('OrderServiceCore', () => { let mockOrders: jest.Mocked<OrderRepository>; let mockPayments: jest.Mocked<PaymentGateway>; let mockNotifications: jest.Mocked<NotificationService>; let service: OrderServiceCore; beforeEach(() => { mockOrders = { save: jest.fn(), findById: jest.fn(), update: jest.fn() }; mockPayments = { processPayment: jest.fn(), refund: jest.fn() }; mockNotifications = { sendOrderConfirmation: jest.fn(), sendCancellationNotice: jest.fn() }; service = new OrderServiceCore(mockOrders, mockPayments, mockNotifications); }); it('orchestrates order placement correctly', async () => { mockPayments.processPayment.mockResolvedValue({ success: true, transactionId: 'txn-123' }); mockOrders.save.mockImplementation(async (order) => order); await service.placeOrder(validOrderRequest); expect(mockPayments.processPayment).toHaveBeenCalled(); expect(mockOrders.save).toHaveBeenCalled(); expect(mockNotifications.sendOrderConfirmation).toHaveBeenCalled(); });});Notice the common thread: all these patterns establish clear boundaries through abstractions (DIP), separate concerns (SRP), and ensure dependencies flow in a predictable, controllable direction. The specific pattern matters less than these underlying principles.
Beyond architecture, the design of individual class and method APIs significantly impacts testability. Small decisions in API design compound to make testing either natural or agonizing.
EmailAddress is clearer than string.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
// ❌ Hard to test API designclass UserManager { private database: PostgresDB; private emailService: SendGridClient; private cache: RedisCache; private logger: ConsoleLogger; constructor() { // Hidden dependencies, hard-coded implementations this.database = PostgresDB.getInstance(); this.emailService = new SendGridClient(process.env.SENDGRID_KEY!); this.cache = RedisCache.getInstance(); this.logger = new ConsoleLogger(); } // Fat interface with many responsibilities async createUser( email: string, // Primitive - what's valid? password: string, // Primitive - what are the rules? firstName: string, lastName: string, phone: string, address?: string ): Promise<string> { // String - what does it represent? // 100 lines of logic mixing validation, persistence, // notification, caching, and logging... return "userId"; // Magic return value } // Returns void - how do we verify this worked? async updateUser(userId: string, data: object): Promise<void> { // Side effects we can't observe without database access }} // ✅ Testable API designinterface UserRepository { save(user: User): Promise<User>; findByEmail(email: EmailAddress): Promise<User | null>;} interface WelcomeEmailSender { send(user: User): Promise<void>;} class CreateUserUseCase { constructor( private readonly users: UserRepository, // Explicit dependency private readonly emailSender: WelcomeEmailSender // Explicit dependency ) {} async execute(request: CreateUserRequest): Promise<CreateUserResult> { // Domain types make contract clear const email = EmailAddress.parse(request.email); const password = Password.create(request.password); // Check for existing user const existing = await this.users.findByEmail(email); if (existing) { return CreateUserResult.emailTaken(); } // Create and save user const user = User.create({ email, passwordHash: password.hash(), name: new Name(request.firstName, request.lastName) }); const savedUser = await this.users.save(user); // Send welcome email await this.emailSender.send(savedUser); return CreateUserResult.success(savedUser.id); }} // Result type makes outcomes explicit and testableclass CreateUserResult { private constructor( public readonly success: boolean, public readonly userId?: UserId, public readonly error?: CreateUserError ) {} static success(userId: UserId): CreateUserResult { return new CreateUserResult(true, userId); } static emailTaken(): CreateUserResult { return new CreateUserResult(false, undefined, CreateUserError.EMAIL_TAKEN); } isSuccess(): boolean { return this.success; }} // Testing is now straightforwarddescribe('CreateUserUseCase', () => { it('returns email taken result for duplicate email', async () => { const existingUser = User.create({ /* ... */ }); const mockUsers: UserRepository = { findByEmail: jest.fn().mockResolvedValue(existingUser), save: jest.fn() }; const mockSender: WelcomeEmailSender = { send: jest.fn() }; const useCase = new CreateUserUseCase(mockUsers, mockSender); const result = await useCase.execute({ email: 'existing@example.com', password: 'ValidPass123!', firstName: 'John', lastName: 'Doe' }); expect(result.isSuccess()).toBe(false); expect(result.error).toBe(CreateUserError.EMAIL_TAKEN); expect(mockUsers.save).not.toHaveBeenCalled(); expect(mockSender.send).not.toHaveBeenCalled(); });});While DIP is the most direct enabler of testability, all SOLID principles contribute to creating testable code. Understanding how each principle impacts testability reveals why principled design and testability go hand in hand.
| Principle | Testability Impact | When Violated |
|---|---|---|
| SRP — Single Responsibility | Units have focused scope; tests verify specific behavior; changes affect fewer tests | Tests become large, verifying many unrelated behaviors; any change breaks many tests |
| OCP — Open/Closed | New behavior added through new implementations without changing existing code; existing tests remain valid | Adding features requires modifying existing code; every change potentially breaks existing tests |
| LSP — Liskov Substitution | Mock implementations can substitute for real ones; behavioral contracts ensure mocks behave correctly | Mocks may not correctly simulate real behavior; substitution fails in subtle ways |
| ISP — Interface Segregation | Small interfaces require fewer mock methods; mocks are focused and simple | Mocking fat interfaces requires implementing many irrelevant methods |
| DIP — Dependency Inversion | Dependencies can be replaced with test doubles; complete isolation is possible | Cannot substitute dependencies; tests require real infrastructure |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// ISP impact on testability // ❌ Fat interface - painful to mockinterface DataService { // 15+ methods that clients may or may not need createUser(data: UserData): Promise<User>; updateUser(id: string, data: UserData): Promise<User>; deleteUser(id: string): Promise<void>; findUserById(id: string): Promise<User>; findUsersByEmail(email: string): Promise<User[]>; createOrder(data: OrderData): Promise<Order>; updateOrder(id: string, data: OrderData): Promise<Order>; cancelOrder(id: string): Promise<void>; findOrderById(id: string): Promise<Order>; findOrdersByUser(userId: string): Promise<Order[]>; createProduct(data: ProductData): Promise<Product>; updateProduct(id: string, data: ProductData): Promise<Product>; deleteProduct(id: string): Promise<void>; findProductById(id: string): Promise<Product>; searchProducts(query: string): Promise<Product[]>;} // Testing a class that needs only user lookup:const mockDataService: jest.Mocked<DataService> = { createUser: jest.fn(), // Not needed but must provide updateUser: jest.fn(), // Not needed but must provide deleteUser: jest.fn(), // Not needed but must provide findUserById: jest.fn(), // The one we actually need findUsersByEmail: jest.fn(), // Not needed... // 10 more mocks for methods we don't care about...}; // ✅ Segregated interfaces - easy to mockinterface UserFinder { findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>;} interface OrderReader { findById(id: string): Promise<Order | null>; findByUser(userId: string): Promise<Order[]>;} // Now testing is focused:class OrderHistoryPresenter { constructor( private readonly users: UserFinder, private readonly orders: OrderReader ) {} async getHistory(userId: string): Promise<OrderHistoryView> { const user = await this.users.findById(userId); if (!user) throw new UserNotFoundException(); const orders = await this.orders.findByUser(userId); return this.formatHistory(user, orders); }} // Mock only what you need:const mockUsers: jest.Mocked<UserFinder> = { findById: jest.fn(), // 2 methods total findByEmail: jest.fn()}; const mockOrders: jest.Mocked<OrderReader> = { findById: jest.fn(), // 2 methods total findByUser: jest.fn()};If your code follows SOLID principles, it will be testable. If your code is hard to test, it's probably violating at least one SOLID principle. Use testing difficulty as a code smell detector—when testing hurts, identify which principle is being violated.
The preference for composition over inheritance isn't just about flexibility—it has profound implications for testability. Inherited behavior is notoriously difficult to test in isolation, while composed behavior through interfaces is trivially mockable.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// ❌ Inheritance makes testing difficultabstract class BaseReportGenerator { protected database: DatabaseConnection; protected cache: CacheService; constructor() { // Dependencies in base class - how to mock in subclass tests? this.database = DatabaseConnection.getInstance(); this.cache = CacheService.getInstance(); } async generateReport(params: ReportParams): Promise<Report> { const cached = await this.cache.get(params.cacheKey); if (cached) return cached; const data = await this.fetchData(params); // Template method const report = this.formatReport(data); // Template method await this.cache.set(params.cacheKey, report); return report; } protected abstract fetchData(params: ReportParams): Promise<any>; protected abstract formatReport(data: any): Report;} class SalesReportGenerator extends BaseReportGenerator { protected async fetchData(params: ReportParams): Promise<SalesData[]> { // Uses inherited database - can't mock it return this.database.query('SELECT * FROM sales WHERE date BETWEEN...'); } protected formatReport(data: SalesData[]): Report { return { type: 'sales', rows: data }; }} // Testing SalesReportGenerator is painful:// - Can't isolate fetchData from base class generateReport flow// - Can't mock database or cache without reflection/hacks// - Testing formatReport requires going through generateReport // ✅ Composition enables clean testinginterface ReportCache { get<T>(key: string): Promise<T | null>; set<T>(key: string, value: T): Promise<void>;} interface DataFetcher<T> { fetch(params: ReportParams): Promise<T>;} interface ReportFormatter<TData, TReport> { format(data: TData): TReport;} class ReportGenerator<TData, TReport> { constructor( private readonly cache: ReportCache, private readonly fetcher: DataFetcher<TData>, private readonly formatter: ReportFormatter<TData, TReport> ) {} async generate(params: ReportParams): Promise<TReport> { const cached = await this.cache.get<TReport>(params.cacheKey); if (cached) return cached; const data = await this.fetcher.fetch(params); const report = this.formatter.format(data); await this.cache.set(params.cacheKey, report); return report; }} // Each component is independently testable: describe('SalesDataFetcher', () => { it('fetches sales data from database', async () => { const mockDb = { query: jest.fn().mockResolvedValue([...salesData]) }; const fetcher = new SalesDataFetcher(mockDb); const result = await fetcher.fetch(params); expect(result).toEqual(salesData); });}); describe('SalesReportFormatter', () => { it('formats sales data into report structure', () => { const formatter = new SalesReportFormatter(); const result = formatter.format(salesData); expect(result.type).toBe('sales'); });}); describe('ReportGenerator', () => { it('returns cached report if available', async () => { const mockCache = { get: jest.fn().mockResolvedValue(cachedReport), set: jest.fn() }; const mockFetcher = { fetch: jest.fn() }; const mockFormatter = { format: jest.fn() }; const generator = new ReportGenerator(mockCache, mockFetcher, mockFormatter); const result = await generator.generate(params); expect(result).toBe(cachedReport); expect(mockFetcher.fetch).not.toHaveBeenCalled(); // Skipped due to cache });});One of the most powerful ways to ensure testable design is to write tests before implementation—not as dogmatic adherence to TDD, but as a design validation technique. When you write the test first, you immediately experience the API from the consumer's perspective.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// Test-First Design Discovery // Step 1: Write the test first - this FORCES you to design the APIdescribe('OrderPricingService', () => { // Writing this test makes you think: // - What does OrderPricingService need? // - What's the interface for its dependencies? // - What does the API look like? it('calculates total with applicable discounts', async () => { // This setup REVEALS what dependencies are needed const mockPromoEngine: PromotionEngine = { findApplicablePromotions: jest.fn().mockResolvedValue([ { type: 'PERCENTAGE', value: 10 } // 10% off ]) }; const mockTaxCalculator: TaxCalculator = { calculate: jest.fn().mockResolvedValue(Money.of(9.00)) // Tax on $90 }; // Constructor signature emerges from what the test needs const service = new OrderPricingService( mockPromoEngine, mockTaxCalculator ); // This call DEFINES the API const order = new Order([ new LineItem('SKU-1', 2, Money.of(50)) // 2 x $50 = $100 ]); const result = await service.calculateTotal(order); // These assertions SPECIFY the contract expect(result.subtotal.amount).toBe(100); expect(result.discount.amount).toBe(10); // 10% of $100 expect(result.tax.amount).toBe(9); // Tax on $90 expect(result.total.amount).toBe(99); // $100 - $10 + $9 });}); // Step 2: Now implement to make the test pass// The design is already validated through the test interface PromotionEngine { findApplicablePromotions(order: Order): Promise<Promotion[]>;} interface TaxCalculator { calculate(subtotal: Money, region: TaxRegion): Promise<Money>;} class OrderPricingService { constructor( private readonly promoEngine: PromotionEngine, private readonly taxCalculator: TaxCalculator ) {} async calculateTotal(order: Order): Promise<PricingResult> { const subtotal = order.calculateSubtotal(); const promotions = await this.promoEngine.findApplicablePromotions(order); const discount = this.applyPromotions(subtotal, promotions); const discountedSubtotal = subtotal.subtract(discount); const tax = await this.taxCalculator.calculate( discountedSubtotal, order.shippingAddress.taxRegion ); const total = discountedSubtotal.add(tax); return new PricingResult(subtotal, discount, tax, total); } private applyPromotions(subtotal: Money, promotions: Promotion[]): Money { // Implementation details... }}Writing tests first isn't about test coverage—it's about design feedback. The test is a client of your code, and clients reveal usability problems. If you struggle to write the test, the design needs work. If the test is clean and obvious, the design is probably sound.
We've explored how to design code that is inherently testable—not by adding testing hooks, but by following design principles that naturally enable testing. Let's consolidate the key insights:
What's next:
With individual components designed for testability, we must now consider the larger picture: DIP in test architecture. How do we structure test code itself? How do we manage shared test infrastructure? How do we ensure our test suites remain maintainable as they grow? The final page explores these organizational and architectural concerns.
You now understand how to design code for testability—treating testability as a first-class design quality enabled by DIP and reinforced by all SOLID principles. Next, we'll explore how to apply these same principles to test code itself, building maintainable test architectures.