Loading content...
If the consensus is to avoid testing private methods directly, how do we ensure they're tested at all? The answer lies in a fundamental shift of perspective: we don't test private methods—we test the behaviors they contribute to.
This page explores the discipline of testing through public interfaces. It's not simply about avoiding private method calls; it's about developing mastery in crafting tests that exercise internal logic indirectly, achieving comprehensive coverage while maintaining the flexibility to refactor freely.
Testing through public interfaces requires more thought upfront, but the payoff is immense: a test suite that documents intended behavior, enables confident refactoring, and remains stable across implementation changes. This is what separates professional testing from mere code coverage exercises.
By the end of this page, you will understand how to design tests that thoroughly exercise private method behavior without calling them directly. You'll learn techniques for ensuring complete coverage, crafting inputs that trigger specific code paths, and organizing tests that serve as living documentation of your class's contract.
Testing through public interfaces is grounded in a fundamental insight: tests should verify what a class does, not how it does it.
Consider what a client of your class actually cares about:
This is precisely what your tests should verify. If your tests mirror what clients care about, they'll remain valid as long as client behavior remains correct—regardless of internal refactoring.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
class ShoppingCart { private items: Map<string, CartItem> = new Map(); private discountCalculator: DiscountCalculator; public addItem(product: Product, quantity: number): void { const existing = this.items.get(product.id); if (existing) { this.updateQuantity(product.id, existing.quantity + quantity); } else { this.items.set(product.id, { product, quantity }); } this.recalculateDiscounts(); } public getTotal(): Money { /* ... */ } private updateQuantity(productId: string, quantity: number): void { /* ... */ } private recalculateDiscounts(): void { /* ... */ } private applyBulkDiscount(): void { /* ... */ }} // ❌ IMPLEMENTATION-FOCUSED TESTtest('items map contains entry after addItem', () => { const cart = new ShoppingCart(); cart.addItem(someProduct, 2); // Testing internal state! expect(cart['items'].get(someProduct.id)).toEqual({ product: someProduct, quantity: 2 });}); // ❌ IMPLEMENTATION-FOCUSED TESTtest('updateQuantity is called when adding existing item', () => { const cart = new ShoppingCart(); cart.addItem(someProduct, 2); // Spying on private method! const spy = jest.spyOn(cart as any, 'updateQuantity'); cart.addItem(someProduct, 3); expect(spy).toHaveBeenCalledWith(someProduct.id, 5);}); // ✅ BEHAVIOR-FOCUSED TESTtest('adding same product twice combines quantities in total', () => { const cart = new ShoppingCart(); const product = { id: '1', price: Money.of(10) }; cart.addItem(product, 2); cart.addItem(product, 3); // Testing observable behavior // Don't care HOW it's combined, just that it IS expect(cart.getTotal()).toEqual(Money.of(50)); // 5 items × $10}); // ✅ BEHAVIOR-FOCUSED TEST test('bulk discount applies when quantity exceeds threshold', () => { const cart = new ShoppingCart(); const product = { id: '1', price: Money.of(100) }; cart.addItem(product, 10); // Triggers bulk discount // Testing the behavior we care about // recalculateDiscounts and applyBulkDiscount are exercised, // but we're not coupled to their existence expect(cart.getTotal()).toEqual(Money.of(900)); // 10% bulk discount});Before writing any test, ask: 'If I were a client of this class, what would I need to know about this method's behavior?' The answer forms your test specification. Private method logic is exercised as a side effect of testing observable behavior, not as a direct goal.
The key to testing private methods through public interfaces is crafting inputs that force execution through specific private code paths. This requires understanding the internal logic well enough to know what inputs trigger which behaviors.
This isn't a contradiction of 'behavior over implementation'—you need to know the implementation to design comprehensive tests, but the tests themselves verify behavior, not implementation details.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
class PasswordValidator { public validate(password: string): ValidationResult { const errors: string[] = []; if (!this.hasMinimumLength(password)) { errors.push('Password must be at least 8 characters'); } if (!this.hasUppercase(password)) { errors.push('Password must contain uppercase letter'); } if (!this.hasLowercase(password)) { errors.push('Password must contain lowercase letter'); } if (!this.hasNumber(password)) { errors.push('Password must contain a number'); } if (!this.hasSpecialChar(password)) { errors.push('Password must contain special character'); } if (this.containsCommonPattern(password)) { errors.push('Password contains common pattern'); } return { isValid: errors.length === 0, errors }; } private hasMinimumLength(password: string): boolean { return password.length >= 8; } private hasUppercase(password: string): boolean { return /[A-Z]/.test(password); } private hasLowercase(password: string): boolean { return /[a-z]/.test(password); } private hasNumber(password: string): boolean { return /[0-9]/.test(password); } private hasSpecialChar(password: string): boolean { return /[!@#$%^&*(),.?":{}|<>]/.test(password); } private containsCommonPattern(password: string): boolean { const commonPatterns = ['password', '12345', 'qwerty', 'admin']; return commonPatterns.some(p => password.toLowerCase().includes(p) ); }} // STRATEGIC TEST DESIGN: Each test exercises specific private paths describe('PasswordValidator', () => { const validator = new PasswordValidator(); // Test case: exercises hasMinimumLength (false branch) test('rejects passwords shorter than 8 characters', () => { const result = validator.validate('Abc1!'); // 5 chars expect(result.isValid).toBe(false); expect(result.errors).toContain('Password must be at least 8 characters'); }); // Test case: exercises hasMinimumLength (true) but hasUppercase (false) test('rejects passwords without uppercase letters', () => { const result = validator.validate('abcdefgh1!'); // no uppercase expect(result.isValid).toBe(false); expect(result.errors).toContain('Password must contain uppercase letter'); expect(result.errors).not.toContain('Password must be at least 8 characters'); }); // Test case: exercises containsCommonPattern (true branch) test('rejects passwords containing common patterns', () => { const result = validator.validate('MyPassword1!'); // contains 'password' expect(result.isValid).toBe(false); expect(result.errors).toContain('Password contains common pattern'); }); // Test case: exercises all private methods returning success test('accepts valid password meeting all criteria', () => { const result = validator.validate('SecurePass1!'); expect(result.isValid).toBe(true); expect(result.errors).toEqual([]); }); // Test case: exercises multiple failures test('accumulates all validation errors', () => { const result = validator.validate('weak'); // fails multiple criteria expect(result.isValid).toBe(false); expect(result.errors).toHaveLength(5); // all but special char }); // Test case: boundary condition for hasMinimumLength test('accepts password exactly at minimum length', () => { const result = validator.validate('Valid1!a'); // exactly 8 chars expect(result.isValid).toBe(true); });});The Input Design Process:
Identify the private code paths — Read the code to understand what conditions trigger different private method behaviors.
Map inputs to paths — Determine what public method inputs will exercise each path. Focus on boundary conditions, error conditions, and normal cases.
Verify through outputs — For each input, determine what observable output or side effect would indicate the private method executed correctly.
Minimize overlap — Design inputs that isolate specific behaviors when possible. If testing 'uppercase required' fails, you want to know it's the uppercase check, not something else.
Use code coverage tools to verify that your carefully designed inputs actually exercise the private methods. Coverage should be the validation of your input design, not the goal itself. 100% coverage with meaningless tests is worthless; strategic coverage that verifies real behaviors is invaluable.
Real-world classes often have public methods that orchestrate multiple private methods in sequence. Testing these chains through the public interface requires understanding the cumulative effect and designing test scenarios that exercise the complete flow.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
class DocumentProcessor { public process(document: RawDocument): ProcessedDocument { const validated = this.validateStructure(document); const parsed = this.parseContent(validated); const normalized = this.normalizeData(parsed); const enriched = this.enrichMetadata(normalized); return this.formatOutput(enriched); } private validateStructure(doc: RawDocument): ValidatedDocument { // Validates XML/JSON structure, throws on malformed input // Edge cases: malformed quotes, unclosed tags, encoding issues } private parseContent(doc: ValidatedDocument): ParsedDocument { // Extracts data from validated structure // Edge cases: missing fields, null values, empty collections } private normalizeData(doc: ParsedDocument): NormalizedDocument { // Standardizes formats, trims strings, converts types // Edge cases: date formats, number formats, unicode issues } private enrichMetadata(doc: NormalizedDocument): EnrichedDocument { // Adds computed fields, infers missing data // Edge cases: inference failures, missing references } private formatOutput(doc: EnrichedDocument): ProcessedDocument { // Creates final output structure // Edge cases: large documents, special characters in output }} // TESTING THE CHAIN: Each test verifies end-to-end behavior// but focuses on specific aspects describe('DocumentProcessor', () => { const processor = new DocumentProcessor(); describe('validation phase', () => { test('rejects malformed XML structure', () => { const malformed = createDocumentWithUnclosedTag(); expect(() => processor.process(malformed)) .toThrow('Invalid document structure'); }); test('rejects document with encoding errors', () => { const badEncoding = createDocumentWithInvalidEncoding(); expect(() => processor.process(badEncoding)) .toThrow('Encoding error'); }); }); describe('parsing phase', () => { test('handles missing optional fields gracefully', () => { const doc = createDocumentWithMissingOptionalFields(); const result = processor.process(doc); expect(result.optionalField).toBeNull(); expect(result.status).toBe('processed'); }); test('handles empty collections', () => { const doc = createDocumentWithEmptyItems(); const result = processor.process(doc); expect(result.items).toEqual([]); expect(result.itemCount).toBe(0); }); }); describe('normalization phase', () => { test('normalizes various date formats to ISO', () => { const doc = createDocumentWithMixedDateFormats(); const result = processor.process(doc); expect(result.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(result.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); test('handles unicode normalization', () => { const doc = createDocumentWithUnicode(); const result = processor.process(doc); expect(result.title).toBe('Café Résumé'); // NFC normalized }); }); describe('enrichment phase', () => { test('computes derived fields correctly', () => { const doc = createDocumentWithItems([ { quantity: 5, unitPrice: 100 }, { quantity: 3, unitPrice: 50 } ]); const result = processor.process(doc); expect(result.totalValue).toBe(650); // 5*100 + 3*50 expect(result.itemCount).toBe(2); }); test('infers category from content when missing', () => { const doc = createDocumentWithoutCategory(); const result = processor.process(doc); expect(result.category).toBe('INFERRED'); }); }); describe('complete flow', () => { test('processes valid document through entire pipeline', () => { const doc = createCompleteValidDocument(); const result = processor.process(doc); // Verify final output reflects all processing stages expect(result.status).toBe('complete'); expect(result.validatedAt).toBeDefined(); expect(result.processedAt).toBeDefined(); expect(result.checksum).toBeDefined(); }); });});createDocumentWithMixedDateFormats() that clearly indicate what scenario is being tested.Private methods often contain conditional logic with multiple branches. Each branch represents different behavior that needs testing. The challenge is ensuring all branches are exercised through public method inputs.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
class PricingEngine { public calculatePrice(order: Order): Money { const basePrice = this.calculateBasePrice(order); const discount = this.calculateDiscount(order); const shipping = this.calculateShipping(order); const tax = this.calculateTax(order); return basePrice.minus(discount).plus(shipping).plus(tax); } private calculateDiscount(order: Order): Money { // Multiple branches based on different discount rules // Branch 1: No discount for small orders if (order.total.lessThan(Money.of(50))) { return Money.ZERO; } // Branch 2: Percentage discount for loyalty members if (order.customer.loyaltyTier === 'GOLD') { return order.total.times(0.10); // 10% off } if (order.customer.loyaltyTier === 'PLATINUM') { return order.total.times(0.15); // 15% off } // Branch 3: Coupon discount (takes precedence) if (order.couponCode) { const coupon = this.lookupCoupon(order.couponCode); if (coupon && coupon.isValid()) { return coupon.calculateDiscount(order.total); } } // Branch 4: Bulk order discount if (order.itemCount >= 10) { return order.total.times(0.05); // 5% bulk } // Branch 5: Default - no discount return Money.ZERO; } private calculateShipping(order: Order): Money { // Branch 1: Free shipping for large orders if (order.total.greaterThan(Money.of(100))) { return Money.ZERO; } // Branch 2: Expedited shipping if (order.shippingMethod === 'EXPRESS') { return Money.of(15.99); } // Branch 3: Heavy item surcharge if (order.totalWeight > 50) { // pounds return Money.of(12.99); } // Branch 4: Standard shipping return Money.of(5.99); }} // SYSTEMATIC BRANCH COVERAGE through test input design describe('PricingEngine discount calculation', () => { const engine = new PricingEngine(); // Branch 1: Order too small for discount test('no discount for orders under $50', () => { const order = createOrder({ total: Money.of(49.99) }); const price = engine.calculatePrice(order); expect(price).toEqual( order.total.plus(standardShipping).plus(tax) ); }); // Branch 2a: Gold loyalty discount test('10% discount for gold members', () => { const order = createOrder({ total: Money.of(100), customer: { loyaltyTier: 'GOLD' } }); const price = engine.calculatePrice(order); // $100 - 10% discount = $90, plus shipping and tax expect(price.baseAmount).toBe(90); }); // Branch 2b: Platinum loyalty discount test('15% discount for platinum members', () => { const order = createOrder({ total: Money.of(100), customer: { loyaltyTier: 'PLATINUM' } }); const price = engine.calculatePrice(order); expect(price.baseAmount).toBe(85); }); // Branch 3: Valid coupon test('coupon discount applied when valid', () => { const order = createOrder({ total: Money.of(100), couponCode: 'SAVE20' // 20% off coupon }); const price = engine.calculatePrice(order); expect(price.baseAmount).toBe(80); }); // Branch 3: Invalid coupon falls through test('expired coupon is ignored', () => { const order = createOrder({ total: Money.of(100), couponCode: 'EXPIRED_CODE' }); const price = engine.calculatePrice(order); // Falls through to bulk or no discount expect(price.baseAmount).toBe(100); }); // Branch 4: Bulk discount test('5% discount for bulk orders (10+ items)', () => { const order = createOrder({ total: Money.of(100), itemCount: 10 }); const price = engine.calculatePrice(order); expect(price.baseAmount).toBe(95); }); // Branch 5: No applicable discount test('no discount when no rules apply', () => { const order = createOrder({ total: Money.of(75), customer: { loyaltyTier: 'STANDARD' }, couponCode: null, itemCount: 3 }); const price = engine.calculatePrice(order); expect(price.baseAmount).toBe(75); });});| Branch Condition | Input Design | Expected Output |
|---|---|---|
| total < $50 | Order with total = $49.99 | No discount applied |
| loyaltyTier === 'GOLD' | Order with GOLD customer, total >= $50 | 10% discount |
| loyaltyTier === 'PLATINUM' | Order with PLATINUM customer, total >= $50 | 15% discount |
| Valid coupon | Order with valid coupon code | Coupon discount |
| Invalid coupon | Order with expired/invalid coupon | Falls through to next rule |
| itemCount >= 10 | Order with 10+ items, no loyalty/coupon | 5% bulk discount |
| No rules apply | Standard customer, <10 items, no coupon, total >= $50 | No discount |
Create a matrix mapping private method branches to test cases. Each branch should have at least one test that specifically exercises it. Use coverage tools to verify you haven't missed any branches. If coverage shows a branch isn't hit, you have a gap in your input design.
Private methods often contain error handling logic—validation failures, edge case detection, and exception throwing. These error paths are critical to test, as they define the class's defensive behavior.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
class FileUploader { public async uploadFile(file: UploadedFile): Promise<UploadResult> { this.validateFile(file); const sanitized = this.sanitizeFilename(file); const compressed = await this.compressIfNeeded(sanitized); const stored = await this.storeToCloud(compressed); return this.createResult(stored); } private validateFile(file: UploadedFile): void { // Multiple error conditions if (!file) { throw new ValidationError('File is required'); } if (file.size === 0) { throw new ValidationError('File is empty'); } if (file.size > this.maxFileSize) { throw new ValidationError( `File exceeds maximum size of ${this.maxFileSize} bytes` ); } if (!this.allowedMimeTypes.includes(file.mimeType)) { throw new ValidationError( `File type '${file.mimeType}' is not allowed` ); } if (this.containsMaliciousContent(file)) { throw new SecurityError('File contains potentially malicious content'); } } private sanitizeFilename(file: UploadedFile): UploadedFile { let name = file.name; // Remove path components (directory traversal attack) if (name.includes('..') || name.includes('/') || name.includes('\\')) { throw new SecurityError('Invalid filename: path traversal detected'); } // Remove dangerous characters name = name.replace(/[<>:"|?*]/g, '_'); // Limit length if (name.length > 255) { const ext = this.getExtension(name); name = name.substring(0, 250 - ext.length) + ext; } return { ...file, name }; } private async compressIfNeeded(file: UploadedFile): Promise<UploadedFile> { if (file.size < this.compressionThreshold) { return file; // Skip compression for small files } try { return await this.compress(file); } catch (error) { // Compression failed, proceed with original this.logger.warn('Compression failed, using original', { error }); return file; } }} // TESTING ERROR PATHS through public interface describe('FileUploader validation errors', () => { const uploader = new FileUploader(); test('throws when file is null', async () => { await expect(uploader.uploadFile(null)) .rejects.toThrow('File is required'); }); test('throws when file is empty', async () => { const emptyFile = createFile({ size: 0 }); await expect(uploader.uploadFile(emptyFile)) .rejects.toThrow('File is empty'); }); test('throws when file exceeds size limit', async () => { const largeFile = createFile({ size: 100 * 1024 * 1024 }); // 100MB await expect(uploader.uploadFile(largeFile)) .rejects.toThrow('exceeds maximum size'); }); test('throws for disallowed MIME types', async () => { const executable = createFile({ mimeType: 'application/x-msdownload' }); await expect(uploader.uploadFile(executable)) .rejects.toThrow('File type'); }); test('throws for malicious content', async () => { const malicious = createFileWithMaliciousContent(); await expect(uploader.uploadFile(malicious)) .rejects.toThrow(SecurityError); });}); describe('FileUploader security', () => { const uploader = new FileUploader(); test('rejects path traversal in filename', async () => { const pathTraversal = createFile({ name: '../../../etc/passwd' }); await expect(uploader.uploadFile(pathTraversal)) .rejects.toThrow('path traversal detected'); }); test('rejects Windows path separators', async () => { const windowsPath = createFile({ name: 'C:\\Windows\\System32\\file.txt' }); await expect(uploader.uploadFile(windowsPath)) .rejects.toThrow('path traversal detected'); });}); describe('FileUploader graceful degradation', () => { const uploader = new FileUploader(); test('proceeds with original file when compression fails', async () => { const file = createLargeFile(); // Mock compression to fail jest.spyOn(uploader as any, 'compress').mockRejectedValue(new Error('Compression error')); const result = await uploader.uploadFile(file); // Upload succeeded despite compression failure expect(result.success).toBe(true); expect(result.compressed).toBe(false); });});Some private methods modify internal state rather than returning values. Testing these through public interfaces requires verifying the state changes through subsequent public method calls or observable outputs.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
class GameSession { private currentLevel: number = 1; private score: number = 0; private lives: number = 3; private powerUps: Set<PowerUp> = new Set(); private achievements: Achievement[] = []; public processAction(action: GameAction): ActionResult { switch (action.type) { case 'COLLECT_COIN': this.addScore(action.points); this.checkScoreAchievements(); break; case 'COLLECT_POWER_UP': this.activatePowerUp(action.powerUp); break; case 'ENEMY_CONTACT': this.handleDamage(); break; case 'LEVEL_COMPLETE': this.advanceLevel(); break; } return this.getStatus(); } public getStatus(): GameStatus { return { level: this.currentLevel, score: this.score, lives: this.lives, activePowerUps: Array.from(this.powerUps), achievements: [...this.achievements] }; } private addScore(points: number): void { const multiplier = this.powerUps.has(PowerUp.DOUBLE_POINTS) ? 2 : 1; this.score += points * multiplier; } private checkScoreAchievements(): void { if (this.score >= 1000 && !this.hasAchievement('SCORE_1000')) { this.achievements.push(Achievements.SCORE_1000); } if (this.score >= 10000 && !this.hasAchievement('SCORE_10000')) { this.achievements.push(Achievements.SCORE_10000); } } private activatePowerUp(powerUp: PowerUp): void { this.powerUps.add(powerUp); // Auto-expire after time setTimeout(() => this.powerUps.delete(powerUp), 30000); } private handleDamage(): void { if (this.powerUps.has(PowerUp.SHIELD)) { this.powerUps.delete(PowerUp.SHIELD); // Shield absorbs hit } else { this.lives -= 1; } } private advanceLevel(): void { this.currentLevel += 1; this.powerUps.clear(); // Reset power-ups between levels }} // TESTING STATE-MODIFYING PRIVATE METHODS through observable behavior describe('GameSession score system', () => { test('addScore increases score correctly', () => { const session = new GameSession(); session.processAction({ type: 'COLLECT_COIN', points: 100 }); expect(session.getStatus().score).toBe(100); }); test('addScore applies double points multiplier', () => { const session = new GameSession(); session.processAction({ type: 'COLLECT_POWER_UP', powerUp: PowerUp.DOUBLE_POINTS }); session.processAction({ type: 'COLLECT_COIN', points: 100 }); expect(session.getStatus().score).toBe(200); // Doubled! }); test('checkScoreAchievements awards at threshold', () => { const session = new GameSession(); // Get to 999 points for (let i = 0; i < 9; i++) { session.processAction({ type: 'COLLECT_COIN', points: 111 }); } expect(session.getStatus().achievements).not.toContainEqual( expect.objectContaining({ id: 'SCORE_1000' }) ); // Cross 1000 threshold session.processAction({ type: 'COLLECT_COIN', points: 1 }); expect(session.getStatus().achievements).toContainEqual( expect.objectContaining({ id: 'SCORE_1000' }) ); });}); describe('GameSession power-up system', () => { test('activatePowerUp adds to active power-ups', () => { const session = new GameSession(); session.processAction({ type: 'COLLECT_POWER_UP', powerUp: PowerUp.SHIELD }); expect(session.getStatus().activePowerUps).toContain(PowerUp.SHIELD); }); test('shield absorbs damage without losing life', () => { const session = new GameSession(); session.processAction({ type: 'COLLECT_POWER_UP', powerUp: PowerUp.SHIELD }); const initialLives = session.getStatus().lives; session.processAction({ type: 'ENEMY_CONTACT' }); expect(session.getStatus().lives).toBe(initialLives); expect(session.getStatus().activePowerUps).not.toContain(PowerUp.SHIELD); }); test('damage reduces lives when no shield', () => { const session = new GameSession(); const initialLives = session.getStatus().lives; session.processAction({ type: 'ENEMY_CONTACT' }); expect(session.getStatus().lives).toBe(initialLives - 1); });}); describe('GameSession level progression', () => { test('advanceLevel increments level', () => { const session = new GameSession(); session.processAction({ type: 'LEVEL_COMPLETE' }); expect(session.getStatus().level).toBe(2); }); test('advanceLevel clears power-ups', () => { const session = new GameSession(); session.processAction({ type: 'COLLECT_POWER_UP', powerUp: PowerUp.SHIELD }); session.processAction({ type: 'COLLECT_POWER_UP', powerUp: PowerUp.DOUBLE_POINTS }); session.processAction({ type: 'LEVEL_COMPLETE' }); expect(session.getStatus().activePowerUps).toEqual([]); });});When private methods modify state, use this pattern: (1) Set up initial conditions, (2) Invoke public method that triggers private state modification, (3) Verify state change through getters or subsequent method behavior. The key is that state is always verified through public channels, never by peeking at private fields.
When testing through public interfaces, test organization becomes crucial. Well-organized tests serve as documentation, making it clear what behaviors the class provides without revealing implementation details.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
describe('EmailService', () => { // BEHAVIOR GROUP: Sending individual emails describe('send()', () => { describe('with valid recipient', () => { test('delivers email successfully', async () => { /* ... */ }); test('returns message ID on success', async () => { /* ... */ }); test('records delivery in audit log', async () => { /* ... */ }); }); describe('with invalid recipient', () => { test('rejects malformed email address', async () => { /* ... */ }); test('rejects empty recipient', async () => { /* ... */ }); test('does not record failed attempts in audit log', async () => { /* ... */ }); }); describe('with attachments', () => { test('includes attachments in sent email', async () => { /* ... */ }); test('rejects attachments exceeding size limit', async () => { /* ... */ }); test('rejects prohibited file types', async () => { /* ... */ }); }); describe('when external service is unavailable', () => { test('retries failed deliveries', async () => { /* ... */ }); test('throws after retry limit exceeded', async () => { /* ... */ }); test('logs failures for monitoring', async () => { /* ... */ }); }); }); // BEHAVIOR GROUP: Sending batch emails describe('sendBatch()', () => { describe('with valid recipients', () => { test('delivers to all recipients', async () => { /* ... */ }); test('returns individual results for each recipient', async () => { /* ... */ }); }); describe('with mixed valid/invalid recipients', () => { test('delivers to valid recipients', async () => { /* ... */ }); test('reports failures for invalid recipients', async () => { /* ... */ }); test('continues after individual failures', async () => { /* ... */ }); }); describe('with large batches', () => { test('processes batches in chunks to avoid rate limits', async () => { /* ... */ }); test('handles partial batch failures gracefully', async () => { /* ... */ }); }); }); // BEHAVIOR GROUP: Template rendering describe('sendTemplate()', () => { describe('with valid template', () => { test('renders template with provided variables', async () => { /* ... */ }); test('sends rendered content', async () => { /* ... */ }); }); describe('with missing variables', () => { test('throws for required variables', async () => { /* ... */ }); test('uses defaults for optional variables', async () => { /* ... */ }); }); });});A well-organized test suite tells the story of what the class does. Someone new to the codebase should be able to understand the class's responsibilities by scanning test names, without reading implementation code. This is the pinnacle of testing through public interfaces—tests that document behaviors, not implementations.
Testing through public interfaces is both a discipline and a skill. It requires more thought than direct testing but delivers a test suite that's more valuable—one that documents behavior, enables refactoring, and remains stable over time.
What's Next:
Sometimes, despite our best efforts, testing through public interfaces remains impractical. Legacy code, extreme complexity, or time constraints may necessitate making methods more accessible. The next page explores when to make methods testable—legitimate techniques for improving testability without compromising design.
You now have the techniques to test private methods thoroughly without calling them directly. By designing strategic inputs, organizing tests by behavior, and verifying through observable outputs, you can achieve comprehensive coverage while maintaining the freedom to refactor. This discipline separates professional test suites from fragile ones.