How to Test Private Methods in TypeScript

Every software engineer writing unit tests eventually faces this dilemma: you write a complex class with a private helper method, and you want to write a direct unit test for that specific logic.
When you try to call service.handleError() in your test file, the TypeScript compiler blocks you:
Property 'handleError' is private and only accessible within class 'ProductService'.Is testing private methods an anti-pattern? What are your options when you have to test critical internal logic?
In this post, I will show you four ways to test private methods in TypeScript, explain the crucial difference between TypeScript private and JavaScript #private fields, and discuss when to refactor.
The Example: A Service with a Private Error Handler
Consider this Angular or Node.js service that handles HTTP requests and formats errors internally:
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ProductService {
#http = inject(HttpClient);
async getProducts(): Promise<string[]> {
try {
const response = await firstValueFrom(
this.#http.get<{ data: string[] }>('/api/products')
);
return response.data;
} catch (error) {
const formatted = this.formatErrorMessage(error as Error);
console.error(formatted);
return [];
}
}
private formatErrorMessage(error: Error): string {
return `[ProductService Error]: ${error.message}`;
}
}The method formatErrorMessage is marked as private. Let us review the four strategies to test it.
Strategy 1: Bracket Notation (The Pragmatic Shortcut)
TypeScript's private keyword is purely a compile-time check. Once your TypeScript compiles to JavaScript, the private keyword disappears completely.
At runtime, standard TypeScript private methods are normal JavaScript properties on the object prototype. You can bypass the compiler check using string index access:
import { ProductService } from './product.service';
describe('ProductService', () => {
it('should format error messages correctly', () => {
const service = new ProductService();
const testError = new Error('Database connection failed');
// Bypass TypeScript compiler check using bracket notation
const result = service['formatErrorMessage'](testError);
expect(result).toBe('[ProductService Error]: Database connection failed');
});
});You can also cast the instance to any:
const result = (service as any).formatErrorMessage(testError);When to use this:
Use this when you are working on legacy code and need quick test coverage without refactoring production classes.
Warning: This only works with TypeScript's
privatekeyword. It will NOT work with JavaScript#privatefields!
Crucial Gotcha: TypeScript private vs JavaScript #private
Many developers confuse TypeScript's private keyword with native JavaScript private fields (#methodName).
export class ModernService {
// TypeScript compile-time private
private tsPrivate(): string {
return 'Accessible via bracket notation';
}
// ECMAScript runtime private field
#jsPrivate(): string {
return 'Enforced by the JS engine';
}
}| Feature | TypeScript private | ECMAScript #private |
|---|---|---|
| Enforcement | Compile-time only | Native JavaScript runtime engine |
Accessible via obj['name'] | Yes (bypasses compiler) | No (throws runtime error) |
Accessible via (obj as any) | Yes | No |
| Best Used For | General application classes | Hard security boundaries |
If your class uses #formatErrorMessage, bracket notation will fail. That brings us to our cleaner, architectural solutions.
Strategy 2: Extracting to Standalone Pure Helper Functions (Recommended)
When a private method contains non-trivial business logic, calculations, or string formatting, it usually does not belong inside the class itself.
Move the helper logic to a standalone pure function in the same file or a utility file:
// product.utils.ts
export function formatErrorMessage(error: Error): string {
return `[ProductService Error]: ${error.message}`;
}Then use the function inside your service:
import { formatErrorMessage } from './product.utils';
export class ProductService {
private format(error: Error) {
return formatErrorMessage(error);
}
}Now you can test formatErrorMessage directly without needing any class instances, mock dependencies, or type casting:
import { formatErrorMessage } from './product.utils';
describe('formatErrorMessage', () => {
it('formats error with prefix', () => {
const error = new Error('Network timeout');
expect(formatErrorMessage(error)).toBe('[ProductService Error]: Network timeout');
});
});This approach creates code that is easier to test, reuse, and maintain.
Strategy 3: Using protected and a Test Sandbox Subclass
If the method must access internal protected state on the class instance, change the modifier from private to protected.
Then, in your test file, create a test subclass that exposes the protected method publicly:
// In your test file:
class TestableProductService extends ProductService {
public exposeFormatErrorMessage(error: Error): string {
return this.formatErrorMessage(error);
}
}
describe('ProductService with Subclass Sandbox', () => {
it('exposes protected method for verification', () => {
const testService = new TestableProductService();
const result = testService.exposeFormatErrorMessage(new Error('Auth failed'));
expect(result).toBe('[ProductService Error]: Auth failed');
});
});This pattern keeps the method hidden from consumer code while giving your test suite a type-safe entry point.
Strategy 4: Testing Through Public Methods (Behavioral Testing)
The purest philosophy in testing is: test behavior, not implementation details.
Instead of testing formatErrorMessage directly, test the public getProducts() method and verify its observable output when an error occurs:
it('returns empty array and logs error when request fails', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
// Arrange mock to reject
mockHttp.get.mockReturnValue(throwError(() => new Error('Server 500')));
const products = await service.getProducts();
expect(products).toEqual([]);
expect(consoleSpy).toHaveBeenCalledWith('[ProductService Error]: Server 500');
consoleSpy.mockRestore();
});By testing the public contract, your tests will not break if you later refactor or rename internal private methods.
Summary Comparison
| Strategy | Type Safety | Refactoring Required | Resilience to Changes |
|---|---|---|---|
Bracket Notation (service['method']) | Low (any) | None | Low (breaks if method is renamed) |
| Extract Pure Function | High | Minor (move function) | High (isolated unit) |
protected Test Subclass | High | Low (change modifier) | Medium |
| Public Behavioral Test | High | None | High (survives refactoring) |
My Recommendation:
- For existing code: Use bracket notation or test the public method.
- For new code: Extract complex logic into pure functions and test them directly.
Conclusion & Next Steps
Testing private methods in TypeScript comes down to choosing the right tool for the job:
- Remember compile vs runtime: TypeScript
privatedisappears at runtime, but#privateis permanently protected. - Prefer public behavior: Test what the user or consumer sees, not internal wiring.
- Extract pure logic: Pure functions without class side-effects are always the easiest to test.
If you found this guide helpful, check out these related TypeScript and testing articles:
Mastering the language of the web. From basic types to advanced generics and patterns.
Frequently Asked Questions
Should you test private methods in TypeScript?
In general, you should test private methods indirectly through the public API of your class. If a private method contains complex logic that feels difficult to test through public methods, it usually means the logic should be extracted into a separate, focused service or pure helper function.
How can I access a TypeScript private method in Jest or Vitest?
Because TypeScript's 'private' keyword is only enforced at compile-time, you can bypass the compiler in tests using bracket notation (service['privateMethod']()) or type assertion ((service as any).privateMethod()).
Can bracket notation access JavaScript private fields (#private)?
No. ECMAScript private fields using the # prefix are enforced by the JavaScript runtime engine. Bracket notation or type casting will throw a runtime syntax error.
What is the cleanest alternative to testing private methods?
Extract the private calculation or transformation logic into a standalone, exported pure function. You can test the pure function directly and call it from your class.
Related Articles
Why You Should Avoid Using 'any' in TypeScript
Why using any in TypeScript leads to hidden runtime bugs, and how to replace it with unknown, custom type guards, utility types, and the satisfies operator.
How to Check Types in TypeScript (The Complete Guide)
Learn how to check and validate types in TypeScript at compile-time and runtime using typeof, instanceof, the in operator, discriminated unions, custom type guards, and Zod or Valibot.
How and When to Use Mocks vs Spies in Unit Testing (Vitest, Jest, and Jasmine)
Learn the practical differences between Mocks and Spies in unit testing. Explore practical examples using modern Vitest (vi.fn, vi.spyOn), Jest, and Jasmine in Angular.
Abstract Classes in Typescript
In Typescript, the classes can inherit from another class to share methods and properties between classes. Also, Typescript support abstract class. Let me show you why and when to use it. For example, we have a base class Subscription and create the ...
Share this article
If you found this guide helpful, consider sharing it with your team or fellow developers.
Real Software. Real Lessons.
I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.
Join 13,800+ developers and readers.
No spam ever. Unsubscribe at any time.