Loading content...
The best solution to testing private methods isn't any of the techniques we've discussed—it's designing code that doesn't need those techniques.
Design for Testability is the discipline of writing code that is naturally and easily testable without compromising encapsulation. It's not about making things public, using reflection, or other workarounds. It's about structural choices that make testability a natural consequence of good design.
Remarkably, the design principles that maximize testability are the same principles that produce clean, maintainable, flexible code. Testability isn't an add-on concern—it's a quality indicator. Code that's hard to test is code that has design problems. Code that's easy to test is code that follows good design principles.
This page explores the architectural patterns and design principles that make testability effortless.
By the end of this page, you will understand how to design classes that are inherently testable. You'll learn the principles of dependency injection, interface segregation, single responsibility, and separation of concerns—not as abstract concepts, but as practical tools that make testing private methods a non-issue.
Before diving into specific techniques, let's establish a fundamental insight: difficulty testing is a design smell.
When you struggle to test a class, the class is telling you something:
Instead of fighting to test bad design, listen to the feedback and improve the design. Tests become easy naturally.
new inside methods hides dependencies that can't be mockedAsk: 'Can I test this class in complete isolation, with only in-memory test data, in under one second?' If no: the design needs work. This isn't about test speed—it's about coupling, dependencies, and complexity. A well-designed class with reasonable scope can always be tested quickly in isolation.
Dependency Injection (DI) is the single most important pattern for testability. The principle is simple: instead of creating dependencies internally, receive them from outside.
This simple shift has profound testability implications:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
// ❌ BEFORE: Hidden dependencies - hard to testclass OrderService { private emailService: EmailService; private paymentGateway: PaymentGateway; private inventoryRepository: InventoryRepository; constructor() { // Hidden dependencies created internally this.emailService = new SmtpEmailService(); this.paymentGateway = new StripePaymentGateway(); this.inventoryRepository = new PostgresInventoryRepository(); } public async processOrder(order: Order): Promise<OrderResult> { // Uses real email, real payments, real database // Testing requires actual infrastructure! }} // Testing this requires:// - A real SMTP server (or email goes out!)// - A real Stripe account (or payments fail!)// - A real Postgres database (or queries fail!)// Effectively UNTESTABLE in isolation // ✅ AFTER: Injected dependencies - easy to testclass OrderService { constructor( private readonly emailService: EmailService, private readonly paymentGateway: PaymentGateway, private readonly inventoryRepository: InventoryRepository ) {} public async processOrder(order: Order): Promise<OrderResult> { // Validate the order if (order.items.length === 0) { throw new EmptyOrderError('Order must contain items'); } // Process payment const paymentResult = await this.paymentGateway.charge( order.customerId, order.total ); if (!paymentResult.success) { throw new PaymentFailedError(paymentResult.error); } // Reserve inventory await this.inventoryRepository.reserve(order.items); // Send confirmation await this.emailService.sendOrderConfirmation(order); return { orderId: order.id, status: 'CONFIRMED' }; }} // Testing is now trivialdescribe('OrderService', () => { let service: OrderService; let mockEmailService: MockEmailService; let mockPaymentGateway: MockPaymentGateway; let mockInventoryRepository: MockInventoryRepository; beforeEach(() => { mockEmailService = new MockEmailService(); mockPaymentGateway = new MockPaymentGateway(); mockInventoryRepository = new MockInventoryRepository(); service = new OrderService( mockEmailService, mockPaymentGateway, mockInventoryRepository ); }); test('rejects empty orders', async () => { const emptyOrder = { items: [], total: Money.ZERO }; await expect(service.processOrder(emptyOrder)) .rejects.toThrow(EmptyOrderError); }); test('processes payment before reserving inventory', async () => { const order = createValidOrder(); await service.processOrder(order); // Verify order of operations const callOrder = [ ...mockPaymentGateway.callLog, ...mockInventoryRepository.callLog ].sort((a, b) => a.timestamp - b.timestamp); expect(callOrder[0].method).toBe('charge'); expect(callOrder[1].method).toBe('reserve'); }); test('does not send email if payment fails', async () => { mockPaymentGateway.simulateFailure('Insufficient funds'); const order = createValidOrder(); await expect(service.processOrder(order)) .rejects.toThrow(PaymentFailedError); expect(mockEmailService.sendOrderConfirmation).not.toHaveBeenCalled(); });});Dependency injection works best when dependencies are narrow and focused. Interface Segregation means depending on small, specific interfaces rather than large, general ones.
For testability, narrow interfaces mean simpler mocks. If your class depends on an interface with 20 methods but only uses 2, you have to implement 18 unused methods in your mock. Narrow interfaces mean small, focused mocks.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
// ❌ BEFORE: Fat interface makes testing verboseinterface UserRepository { findById(id: string): Promise<User | null>; findByEmail(email: string): Promise<User | null>; findAll(): Promise<User[]>; findByRole(role: string): Promise<User[]>; save(user: User): Promise<void>; update(user: User): Promise<void>; delete(id: string): Promise<void>; updatePassword(id: string, password: string): Promise<void>; updateEmail(id: string, email: string): Promise<void>; countByRole(role: string): Promise<number>; existsByEmail(email: string): Promise<boolean>; findActiveUsers(): Promise<User[]>; deactivate(id: string): Promise<void>; // ... 15 more methods ...} class PasswordResetService { constructor(private readonly userRepository: UserRepository) {} public async resetPassword(email: string): Promise<void> { // Only uses findByEmail and updatePassword const user = await this.userRepository.findByEmail(email); if (!user) throw new UserNotFoundError(); const newPassword = generateSecureToken(); await this.userRepository.updatePassword(user.id, newPassword); // send email... }} // Testing requires implementing ALL 20+ methods even though we use only 2const mockUserRepository: UserRepository = { findById: jest.fn(), findByEmail: jest.fn(), findAll: jest.fn(), findByRole: jest.fn(), save: jest.fn(), update: jest.fn(), delete: jest.fn(), updatePassword: jest.fn(), updateEmail: jest.fn(), countByRole: jest.fn(), existsByEmail: jest.fn(), findActiveUsers: jest.fn(), deactivate: jest.fn(), // ... tedious ...}; // ✅ AFTER: Segregated interfaces mean focused mocksinterface UserFinder { findByEmail(email: string): Promise<User | null>;} interface PasswordUpdater { updatePassword(id: string, password: string): Promise<void>;} class PasswordResetService { constructor( private readonly userFinder: UserFinder, private readonly passwordUpdater: PasswordUpdater ) {} public async resetPassword(email: string): Promise<void> { const user = await this.userFinder.findByEmail(email); if (!user) throw new UserNotFoundError(); const newPassword = generateSecureToken(); await this.passwordUpdater.updatePassword(user.id, newPassword); }} // Testing is minimal and focuseddescribe('PasswordResetService', () => { test('updates password for existing user', async () => { // Only need to mock what we use const mockFinder: UserFinder = { findByEmail: jest.fn().mockResolvedValue({ id: '123', email: 'test@example.com' }) }; const mockUpdater: PasswordUpdater = { updatePassword: jest.fn().mockResolvedValue(undefined) }; const service = new PasswordResetService(mockFinder, mockUpdater); await service.resetPassword('test@example.com'); expect(mockUpdater.updatePassword).toHaveBeenCalledWith( '123', expect.any(String) ); }); test('throws for non-existent user', async () => { const mockFinder: UserFinder = { findByEmail: jest.fn().mockResolvedValue(null) }; const mockUpdater: PasswordUpdater = { updatePassword: jest.fn() }; const service = new PasswordResetService(mockFinder, mockUpdater); await expect(service.resetPassword('unknown@example.com')) .rejects.toThrow(UserNotFoundError); expect(mockUpdater.updatePassword).not.toHaveBeenCalled(); });});Notice how the segregated interfaces describe roles: 'UserFinder' and 'PasswordUpdater'. These role-based interfaces can be implemented by different classes or combined in one class. The production UserRepository can implement both, while tests provide minimal implementations. This is the Interface Segregation Principle applied for testability.
The Single Responsibility Principle (SRP) states that a class should have only one reason to change. For testability, this means: small, focused classes are easier to test than large, multipurpose classes.
When a class does one thing, testing that one thing is straightforward. When it does ten things, testing becomes a combinatorial explosion of scenarios.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
// ❌ BEFORE: Class with multiple responsibilitiesclass UserManager { constructor( private readonly db: Database, private readonly emailService: EmailService, private readonly paymentService: PaymentService, private readonly auditLog: AuditLog ) {} // Authentication responsibility public async login(email: string, password: string): Promise<Session> { const user = await this.db.findUserByEmail(email); if (!user || !this.verifyPassword(password, user.passwordHash)) { await this.auditLog.recordFailedLogin(email); throw new AuthenticationError(); } const session = this.createSession(user); await this.auditLog.recordSuccessfulLogin(user.id); return session; } // Registration responsibility public async register(data: RegistrationData): Promise<User> { const hashedPassword = await this.hashPassword(data.password); const user = await this.db.createUser({ ...data, passwordHash: hashedPassword }); await this.emailService.sendWelcomeEmail(user); await this.auditLog.recordRegistration(user.id); return user; } // Subscription responsibility public async subscribe(userId: string, plan: Plan): Promise<Subscription> { const user = await this.db.findUserById(userId); const payment = await this.paymentService.createSubscription(user, plan); const subscription = await this.db.createSubscription(userId, plan, payment.id); await this.emailService.sendSubscriptionConfirmation(user, plan); await this.auditLog.recordSubscription(userId, plan.id); return subscription; } // Profile responsibility public async updateProfile(userId: string, data: ProfileData): Promise<User> { const user = await this.db.updateUser(userId, data); await this.auditLog.recordProfileUpdate(userId); return user; } // Password management responsibility public async changePassword(userId: string, oldPass: string, newPass: string): Promise<void> { // More complex logic... } // 10 more methods covering billing, notifications, etc. private verifyPassword(input: string, hash: string): boolean { /* ... */ } private hashPassword(password: string): Promise<string> { /* ... */ } private createSession(user: User): Session { /* ... */ }} // Testing this God class requires:// - Mocking 4+ dependencies for every test// - Understanding which methods interact with which dependencies// - Complex setup for each test scenario// - Risk of tests becoming interdependent // ✅ AFTER: Separate classes for each responsibilityclass AuthenticationService { constructor( private readonly userFinder: UserFinder, private readonly passwordVerifier: PasswordVerifier, private readonly sessionManager: SessionManager, private readonly auditLog: AuthAuditLog ) {} public async login(email: string, password: string): Promise<Session> { const user = await this.userFinder.findByEmail(email); if (!user || !await this.passwordVerifier.verify(password, user.passwordHash)) { await this.auditLog.recordFailedLogin(email); throw new AuthenticationError('Invalid credentials'); } const session = await this.sessionManager.createSession(user); await this.auditLog.recordSuccessfulLogin(user.id); return session; }} class RegistrationService { constructor( private readonly userCreator: UserCreator, private readonly passwordHasher: PasswordHasher, private readonly welcomeMailer: WelcomeMailer ) {} public async register(data: RegistrationData): Promise<User> { const hashedPassword = await this.passwordHasher.hash(data.password); const user = await this.userCreator.create({ ...data, passwordHash: hashedPassword }); await this.welcomeMailer.send(user); return user; }} class SubscriptionService { constructor( private readonly paymentProcessor: PaymentProcessor, private readonly subscriptionRepository: SubscriptionRepository, private readonly confirmationMailer: SubscriptionConfirmationMailer ) {} public async subscribe(user: User, plan: Plan): Promise<Subscription> { const payment = await this.paymentProcessor.createSubscription(user, plan); const subscription = await this.subscriptionRepository.create(user.id, plan, payment.id); await this.confirmationMailer.send(user, plan); return subscription; }} // Now each class is focused and testabledescribe('AuthenticationService', () => { let service: AuthenticationService; let mockUserFinder: UserFinder; let mockPasswordVerifier: PasswordVerifier; let mockSessionManager: SessionManager; let mockAuditLog: AuthAuditLog; beforeEach(() => { mockUserFinder = createMock<UserFinder>(); mockPasswordVerifier = createMock<PasswordVerifier>(); mockSessionManager = createMock<SessionManager>(); mockAuditLog = createMock<AuthAuditLog>(); service = new AuthenticationService( mockUserFinder, mockPasswordVerifier, mockSessionManager, mockAuditLog ); }); test('returns session for valid credentials', async () => { const user = createUser({ passwordHash: 'hash123' }); mockUserFinder.findByEmail.mockResolvedValue(user); mockPasswordVerifier.verify.mockResolvedValue(true); mockSessionManager.createSession.mockResolvedValue(createSession()); const session = await service.login('test@example.com', 'password123'); expect(session).toBeDefined(); expect(mockAuditLog.recordSuccessfulLogin).toHaveBeenCalledWith(user.id); }); test('throws for unknown user', async () => { mockUserFinder.findByEmail.mockResolvedValue(null); await expect(service.login('unknown@example.com', 'password')) .rejects.toThrow(AuthenticationError); expect(mockAuditLog.recordFailedLogin).toHaveBeenCalledWith('unknown@example.com'); });});If your class has more than 5-7 dependencies, or more than 10-15 public methods, it probably violates SRP. Consider splitting it. If tests for a class regularly require mocking many dependencies, that's a sign the class does too much.
One of the most powerful testability patterns is separating pure computation from side effects. Pure functions (no side effects, output depends only on input) are trivially testable. Side effects (I/O, database, network) require mocking.
By extracting pure logic into separate functions or classes, you make the core business logic testable without any mocking, while the I/O coordination becomes a thin, easily mocked layer.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
// ❌ BEFORE: Logic and I/O intertwinedclass InvoiceService { constructor( private readonly db: Database, private readonly taxService: ExternalTaxApi, private readonly pdfGenerator: PdfGenerator, private readonly emailService: EmailService ) {} public async generateAndSendInvoice(orderId: string): Promise<void> { // Fetch order from database const order = await this.db.findOrder(orderId); if (!order) throw new OrderNotFoundError(); // Calculate line items (pure logic buried here) let subtotal = 0; const lineItems = order.items.map(item => { const lineTotal = item.unitPrice * item.quantity; if (item.quantity >= 10) { // Bulk discount lineTotal *= 0.9; } subtotal += lineTotal; return { ...item, lineTotal }; }); // Fetch tax rate (external API) const taxRate = await this.taxService.getRateForAddress(order.shippingAddress); // Calculate totals (pure logic) const taxAmount = subtotal * taxRate; const total = subtotal + taxAmount; // Apply any credits (pure logic) const adjustedTotal = Math.max(0, total - (order.credits || 0)); // Generate PDF (side effect) const pdf = await this.pdfGenerator.generate({ lineItems, subtotal, taxRate, taxAmount, total, adjustedTotal }); // Send email (side effect) await this.emailService.sendInvoice(order.customerEmail, pdf); // Save record (side effect) await this.db.saveInvoice({ orderId, lineItems, subtotal, taxAmount, total: adjustedTotal, sentAt: new Date() }); }} // Testing requires mocking 4 dependencies, even to test simple calculation logic // ✅ AFTER: Separated pure logic from side effects // Pure domain logic - no dependencies, trivially testableclass InvoiceCalculator { public calculateLineItem(item: OrderItem): LineItemResult { let lineTotal = item.unitPrice * item.quantity; // Bulk discount for 10+ items if (item.quantity >= 10) { lineTotal *= 0.9; } return { description: item.description, quantity: item.quantity, unitPrice: item.unitPrice, discount: item.quantity >= 10 ? 0.1 : 0, lineTotal }; } public calculateSubtotal(lineItems: LineItemResult[]): number { return lineItems.reduce((sum, item) => sum + item.lineTotal, 0); } public calculateTax(subtotal: number, taxRate: number): number { return subtotal * taxRate; } public calculateTotal(subtotal: number, tax: number, credits: number): number { return Math.max(0, subtotal + tax - credits); } public buildInvoice( order: Order, lineItems: LineItemResult[], taxRate: number ): InvoiceData { const subtotal = this.calculateSubtotal(lineItems); const taxAmount = this.calculateTax(subtotal, taxRate); const total = this.calculateTotal(subtotal, taxAmount, order.credits || 0); return { orderId: order.id, lineItems, subtotal, taxRate, taxAmount, total, credits: order.credits || 0 }; }} // I/O coordination layer - thin, mocks are simpleclass InvoiceService { constructor( private readonly orderRepository: OrderRepository, private readonly taxService: TaxService, private readonly calculator: InvoiceCalculator, private readonly pdfGenerator: PdfGenerator, private readonly invoiceSender: InvoiceSender, private readonly invoiceRepository: InvoiceRepository ) {} public async generateAndSendInvoice(orderId: string): Promise<void> { // Fetch const order = await this.orderRepository.findById(orderId); if (!order) throw new OrderNotFoundError(); // Calculate (delegates to pure logic) const lineItems = order.items.map(item => this.calculator.calculateLineItem(item)); const taxRate = await this.taxService.getRateForAddress(order.shippingAddress); const invoiceData = this.calculator.buildInvoice(order, lineItems, taxRate); // Side effects const pdf = await this.pdfGenerator.generate(invoiceData); await this.invoiceSender.send(order.customerEmail, pdf); await this.invoiceRepository.save({ ...invoiceData, sentAt: new Date() }); }} // Pure logic is exhaustively testable with no mocksdescribe('InvoiceCalculator', () => { const calculator = new InvoiceCalculator(); describe('calculateLineItem', () => { test('calculates line total without discount', () => { const item = { unitPrice: 10, quantity: 5, description: 'Widget' }; const result = calculator.calculateLineItem(item); expect(result.lineTotal).toBe(50); expect(result.discount).toBe(0); }); test('applies 10% bulk discount for 10+ items', () => { const item = { unitPrice: 10, quantity: 10, description: 'Widget' }; const result = calculator.calculateLineItem(item); expect(result.lineTotal).toBe(90); // 100 * 0.9 expect(result.discount).toBe(0.1); }); test('applies 10% bulk discount for large quantities', () => { const item = { unitPrice: 100, quantity: 50, description: 'Widget' }; const result = calculator.calculateLineItem(item); expect(result.lineTotal).toBe(4500); // 5000 * 0.9 }); }); describe('calculateTotal', () => { test('combines subtotal and tax minus credits', () => { const result = calculator.calculateTotal(100, 8, 20); expect(result).toBe(88); // 100 + 8 - 20 }); test('never returns negative', () => { const result = calculator.calculateTotal(50, 4, 100); expect(result).toBe(0); // Max(0, 50 + 4 - 100) }); });});This pattern is sometimes called 'Functional Core, Imperative Shell'. The core contains pure business logic that's trivially testable. The shell handles I/O, coordinates the core, and is lightly tested (or tested with integration tests). Most testing effort focuses on the pure core, where it's easy and valuable.
Well-designed classes expose their behavior through observable outputs rather than requiring inspection of internal state. If you can only verify a class works by checking private fields, the design is missing something.
Observable behaviors include:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
// ❌ BEFORE: Behavior only visible through internal stateclass ShoppingCart { private items: CartItem[] = []; private appliedCoupons: string[] = []; private discount: number = 0; public addItem(product: Product, quantity: number): void { const existing = this.items.find(i => i.product.id === product.id); if (existing) { existing.quantity += quantity; } else { this.items.push({ product, quantity }); } // Discount is recalculated internally this.recalculateDiscount(); } public applyCoupon(code: string): void { this.appliedCoupons.push(code); this.recalculateDiscount(); } private recalculateDiscount(): void { // Complex discount logic this.discount = /* calculated value */; }} // To test, we'd need to peek at private fieldstest('discount is applied when adding 10+ items', () => { const cart = new ShoppingCart(); cart.addItem(product, 10); // ❌ How do we verify discount was applied? // Option 1: Reflection to check this.discount // Option 2: Hope there's a getDiscount() we forgot to show // Both are problematic}); // ✅ AFTER: Rich observable behaviorclass ShoppingCart { private items: CartItem[] = []; private appliedCoupons: string[] = []; public addItem(product: Product, quantity: number): CartUpdateResult { const existing = this.items.find(i => i.product.id === product.id); if (existing) { existing.quantity += quantity; } else { this.items.push({ product, quantity }); } return this.createUpdateResult(); } public applyCoupon(code: string): CouponResult { if (!this.isValidCoupon(code)) { return { success: false, error: 'Invalid coupon' }; } this.appliedCoupons.push(code); return { success: true, discountApplied: this.calculateCouponDiscount(code), newTotal: this.getTotal() }; } // Observable: computed from internal state public getSubtotal(): Money { return this.items.reduce( (sum, item) => sum.plus(item.product.price.times(item.quantity)), Money.ZERO ); } // Observable: discount amount public getDiscount(): Money { return this.calculateTotalDiscount(); } // Observable: final total public getTotal(): Money { return this.getSubtotal().minus(this.getDiscount()); } // Observable: list of items (immutable copy) public getItems(): ReadonlyArray<CartItem> { return [...this.items]; } // Observable: cart summary for UI public getSummary(): CartSummary { return { itemCount: this.items.reduce((count, item) => count + item.quantity, 0), uniqueProducts: this.items.length, subtotal: this.getSubtotal(), discount: this.getDiscount(), total: this.getTotal(), appliedCoupons: [...this.appliedCoupons] }; } private createUpdateResult(): CartUpdateResult { return { itemCount: this.items.reduce((c, i) => c + i.quantity, 0), subtotal: this.getSubtotal(), discount: this.getDiscount(), total: this.getTotal() }; } private calculateTotalDiscount(): Money { /* ... */ } private calculateCouponDiscount(code: string): Money { /* ... */ } private isValidCoupon(code: string): boolean { /* ... */ }} // Testing through observable behaviordescribe('ShoppingCart discounts', () => { test('bulk discount appears in total when adding 10+ items', () => { const cart = new ShoppingCart(); const product = { id: '1', price: Money.of(10) }; const result = cart.addItem(product, 10); // Observable: discount is reflected in total expect(result.subtotal).toEqual(Money.of(100)); expect(result.discount.greaterThan(Money.ZERO)).toBe(true); expect(result.total.lessThan(result.subtotal)).toBe(true); }); test('applying valid coupon returns applied discount', () => { const cart = new ShoppingCart(); cart.addItem(product, 5); const result = cart.applyCoupon('SAVE10'); // Observable: coupon application result expect(result.success).toBe(true); expect(result.discountApplied).toEqual(Money.of(5)); // 10% of $50 }); test('cart summary reflects all applied coupons', () => { const cart = new ShoppingCart(); cart.addItem(product, 5); cart.applyCoupon('SAVE10'); cart.applyCoupon('EXTRA5'); const summary = cart.getSummary(); // Observable: full cart state expect(summary.appliedCoupons).toEqual(['SAVE10', 'EXTRA5']); expect(summary.discount.greaterThan(Money.ZERO)).toBe(true); });});For every important behavior your class has, ask: 'How can a client observe this behavior?' If the answer requires peeking at private fields, add a public getter, return value, or event. The class should make its behaviors visible to clients and tests alike.
Hidden dependencies are anything your class needs that isn't visible in its constructor signature. They create testing nightmares because tests can't control or substitute them.
| Hidden Dependency | Testing Problem | Solution |
|---|---|---|
| Static method calls | Can't substitute with test implementation | Wrap in interface, inject instance |
| new inside methods | Can't control created objects | Inject factory or instance |
| Global/singleton access | Shared state across tests | Inject dependency explicitly |
| System.currentTimeMillis() | Time-dependent behavior untestable | Inject Clock interface |
| Random number generation | Non-deterministic tests | Inject RandomGenerator interface |
| Environment variables | Test environment variation | Inject Configuration object |
| File system access | Tests need real files | Inject FileSystem abstraction |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
// ❌ BEFORE: Hidden dependencies everywhereclass AuditLogger { public log(action: string, userId: string): void { // Hidden: current time from system const timestamp = new Date(); // Hidden: random ID generation const logId = Math.random().toString(36).substring(7); // Hidden: environment variable const environment = process.env.NODE_ENV || 'development'; // Hidden: file system access const logPath = `/var/log/audit-${timestamp.toISOString().split('T')[0]}.log`; require('fs').appendFileSync(logPath, JSON.stringify({ logId, timestamp, environment, action, userId }) + '\n'); }} // Testing this is nearly impossible:// - Time changes between runs// - Random IDs are unpredictable// - NODE_ENV varies by environment// - Writes to actual file system // ✅ AFTER: All dependencies explicitinterface Clock { now(): Date;} interface IdGenerator { generate(): string;} interface Configuration { environment: string;} interface LogWriter { write(path: string, content: string): void;} class AuditLogger { constructor( private readonly clock: Clock, private readonly idGenerator: IdGenerator, private readonly config: Configuration, private readonly logWriter: LogWriter ) {} public log(action: string, userId: string): void { const timestamp = this.clock.now(); const logId = this.idGenerator.generate(); const logEntry = { logId, timestamp: timestamp.toISOString(), environment: this.config.environment, action, userId }; const logPath = `/var/log/audit-${timestamp.toISOString().split('T')[0]}.log`; this.logWriter.write(logPath, JSON.stringify(logEntry) + '\n'); }} // Now testing is deterministicdescribe('AuditLogger', () => { let logger: AuditLogger; let mockClock: Clock; let mockIdGenerator: IdGenerator; let mockLogWriter: LogWriter; beforeEach(() => { mockClock = { now: jest.fn().mockReturnValue(new Date('2024-01-15T10:30:00Z')) }; mockIdGenerator = { generate: jest.fn().mockReturnValue('test-id-123') }; mockLogWriter = { write: jest.fn() }; logger = new AuditLogger( mockClock, mockIdGenerator, { environment: 'test' }, mockLogWriter ); }); test('writes log entry with correct timestamp', () => { logger.log('USER_LOGIN', 'user123'); expect(mockLogWriter.write).toHaveBeenCalledWith( '/var/log/audit-2024-01-15.log', expect.stringContaining('"timestamp":"2024-01-15T10:30:00.000Z"') ); }); test('includes generated ID in log entry', () => { logger.log('USER_LOGIN', 'user123'); expect(mockLogWriter.write).toHaveBeenCalledWith( expect.any(String), expect.stringContaining('"logId":"test-id-123"') ); }); test('includes environment from configuration', () => { logger.log('USER_LOGIN', 'user123'); expect(mockLogWriter.write).toHaveBeenCalledWith( expect.any(String), expect.stringContaining('"environment":"test"') ); });});Design for testability isn't about tricks or workarounds—it's about following design principles that naturally produce testable code. The same principles that create clean, maintainable, flexible software also create easily testable software.
Module Complete:
Throughout this module, we've explored the nuanced world of testing private methods and internals:
The master insight is that testability concerns are design concerns in disguise. When testing is hard, the design needs improvement. When design is clean, testing is effortless. Private methods become a non-issue when classes are small, dependencies are injected, and behavior is observable.
You now have a comprehensive understanding of testing private methods and internals—from the philosophical debate to practical techniques to foundational design principles. Apply the judgment you've developed: prefer testing through public interfaces, use accessibility techniques sparingly when needed, and design for testability from the start. This approach produces both testable code and excellent design.