---
title: "How to Test Private Methods in TypeScript"
date: "2022-02-06"
slug: "how-to-test-private-methods-in-typescript"
author: "Dany Paredes"
canonical: "https://danywalls.com/how-to-test-private-methods-in-typescript"
description: "Explore four practical ways to test private methods in TypeScript, understand TypeScript private versus JavaScript private fields, and learn best practices."
---


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:

```bash
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:

```typescript
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:

```typescript
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`:

```typescript
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 `private` keyword. It will NOT work with JavaScript `#private` fields!

---

## Crucial Gotcha: TypeScript `private` vs JavaScript `#private`

Many developers confuse TypeScript's `private` keyword with native JavaScript private fields (`#methodName`).

```typescript
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:

```typescript
// product.utils.ts
export function formatErrorMessage(error: Error): string {
  return `[ProductService Error]: ${error.message}`;
}
```

Then use the function inside your service:

```typescript
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:

```typescript
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:

```typescript
// 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:

```typescript
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:

1. **Remember compile vs runtime**: TypeScript `private` disappears at runtime, but `#private` is permanently protected.
2. **Prefer public behavior**: Test what the user or consumer sees, not internal wiring.
3. **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:

- [Why Avoid Using 'any' in TypeScript](https://danywalls.com/why-avoid-using-any-in-typescript)
- [How and When to Use Mocks or Spies in Unit Testing](https://danywalls.com/how-and-when-to-use-mocks-or-spies-on-unit-testing)
- [Abstract Classes in TypeScript](https://danywalls.com/abstract-classes-in-typescript)
- [How to Check Types in TypeScript](https://danywalls.com/how-to-check-types-in-typescript)

