Question

Testing Private Methods in Angular TypeScript with Jasmine

typescriptangularunit-testingjasmine

Question

How can I unit test a private method in an Angular (TypeScript) class with Jasmine?

For example, this class calls a private initFooBar() method from its constructor. The method calls a dependency and stores a value that is exposed through a public getter:

class FooBar {
  private _status: number;

  constructor(private foo: Bar) {
    this.initFooBar();
  }

  private initFooBar(): void {
    this.foo.bar('data');
    this._status = this.foo.foo();
  }

  public get status(): number {
    return this._status;
  }
}

What is a practical and maintainable way to test this behavior, including cases where direct testing of the private method appears necessary?

Short Answer

You will learn why TypeScript private methods are usually tested through public behavior, how to verify collaborator calls with Jasmine spies, and when a controlled type assertion can access a TypeScript-private member. You will also see why JavaScript #private methods are different.

Concept

A private method is an implementation detail: it is code used internally by a class rather than part of the class's supported public API.

In this example, initFooBar() runs during construction. Its observable effects are:

  • It calls foo.bar('data').
  • It calls foo.foo().
  • It stores the returned value in _status.
  • The public status getter exposes that stored value.

A useful unit test normally verifies these effects through the class's public interface and its dependencies. This makes the test resilient when the class is refactored. For example, a developer may rename initFooBar() or split it into two private methods without changing what FooBar does for callers.

TypeScript's private keyword is primarily enforced by the TypeScript compiler. In traditional TypeScript output, the method is generally still a normal JavaScript property at runtime. A test can bypass the compiler check with a type assertion, but that couples the test to an internal method name and structure.

There is an important distinction:

  • private initFooBar() is TypeScript access control and can often be reached at runtime through an assertion.
  • #initFooBar() is a JavaScript private method. It has runtime privacy and cannot be accessed from outside the class, including from a Jasmine test.

Prefer behavior-focused tests. Directly access a TypeScript-private method only when there is a specific reason and you accept the tighter coupling.

Mental Model

Think of a class as a coffee machine.

  • The public API is the set of buttons customers can press and the coffee they receive.
  • A private method is an internal pump or heating step.

Customers do not need to know whether the machine uses one pump or two. They care that pressing the button produces the correct coffee. Similarly, a unit test should usually check what the class produces and which collaborators it uses, rather than requiring a particular internal helper method to exist.

A Jasmine spy is like a monitor placed on a dependency. It records whether FooBar asked its Bar dependency to perform the expected work.

Take Quiz

Syntax and Examples

Create a Jasmine spy for the dependency, configure its return value, construct the class, and assert the visible result and dependency interactions.

interface Bar {
  bar(value: string): void;
  foo(): number;
}

class FooBar {
  private _status: number;

  constructor(private foo: Bar) {
    this.initFooBar();
  }

  private initFooBar(): void {
    this.foo.bar('data');
    this._status = this.foo.foo();
  }

  get status(): number {
    return this._status;
  }
}

describe('FooBar', () => {
  it('initializes its status and calls its dependency', () => {
    const barSpy = jasmine.createSpyObj<Bar>('Bar', ['bar', 'foo']);
    barSpy.foo.and.returnValue(42);

    const subject = new FooBar(barSpy);

    expect(barSpy.bar).toHaveBeenCalledOnceWith('data');
    expect(barSpy.foo).toHaveBeenCalledTimes(1);
    expect(subject.status).toBe(42);
  });
});

jasmine.createSpyObj() creates an object with spy functions named bar and foo. The test controls foo() with and.returnValue(42), so it does not depend on a real service, network request, or database.

Because the constructor invokes the private method, constructing FooBar is enough to exercise it.

Step by Step Execution

Consider this test:

const barSpy = jasmine.createSpyObj<Bar>('Bar', ['bar', 'foo']);
barSpy.foo.and.returnValue(7);

const subject = new FooBar(barSpy);

expect(subject.status).toBe(7);
expect(barSpy.bar).toHaveBeenCalledWith('data');

Execution proceeds as follows:

  1. createSpyObj creates a fake Bar object. Its methods are Jasmine spies, so calls can be inspected.
  2. barSpy.foo.and.returnValue(7) tells the fake foo() method to return 7.
  3. new FooBar(barSpy) passes the fake dependency into the constructor.
  4. The constructor calls initFooBar().
  5. initFooBar() calls barSpy.bar('data'). Jasmine records this call.
  6. initFooBar() calls barSpy.foo(), which returns 7 because of the configured spy behavior.
  7. The class assigns 7 to _status.
  8. subject.status returns _status, so the expectation receives 7.
  9. The second expectation confirms that the dependency received the expected argument.

The test proves the meaningful result of initialization without naming or calling the private method directly.

Real World Use Cases

Private initialization helpers are common in application code:

  • Angular services: load configuration, set default state, or prepare an API client when a service is created.
  • Components: derive display state from injected services or input values.
  • Authentication: transform a token response into internal session state.
  • Data processing: normalize incoming records before exposing a public result.
  • API clients: build headers, validate options, and map server responses.

In each case, tests usually provide fake dependencies and check observable state, returned values, emitted events, or calls made to collaborators.

Take Quiz

Real Codebase Usage

In real Angular codebases, classes with dependencies are commonly tested in one of two ways.

Test a plain class directly

If the class only needs constructor arguments and Angular does not need to create it, instantiate it directly. This is fast and keeps the test focused.

const api = jasmine.createSpyObj<ApiClient>('ApiClient', ['getUser']);
api.getUser.and.returnValue(of({ id: 1, name: 'Ada' }));

const service = new ProfileService(api);

Use Angular TestBed for Angular-managed dependencies

Use TestBed when testing a component, directive, pipe, or a service requiring Angular providers.

await TestBed.configureTestingModule({
  providers: [
    FooBar,
    { provide: Bar, useValue: barSpy }
  ]
}).compileComponents();

const subject = TestBed.inject(FooBar);

For the FooBar example, direct construction is sufficient.

Prefer explicit public operations when possible

If initialization needs to happen more than once, a public method can express that supported operation clearly:

public refresh(): void {
  this.initFooBar();
}

Then tests can call refresh() and verify its result. Do not make a method public solely to satisfy a test; make it public only if it is genuinely part of the class's intended contract.

Extract substantial logic

If a private method has many branches or complicated calculations, it may represent a separate responsibility. Move that logic into a focused class or pure function and test it through its public API.

export function calculateStatus(response: ApiResponse): number {
  return response.active ? 1 : 0;
}

This reduces the need to reach into another class's internals.

Common Mistakes

Testing a private method by default

This is possible for TypeScript private, but it makes a test fail after harmless refactors such as renaming or splitting an internal method.

// Works for TypeScript-private members, but is tightly coupled to internals.
const subject = new FooBar(barSpy);
(subject as unknown as { initFooBar(): void }).initFooBar();

Use this sparingly. Prefer asserting status and spy calls.

Calling the method without handling TypeScript access rules

This does not compile because initFooBar is private:

subject.initFooBar(); // Error: Property 'initFooBar' is private.

Do not change production visibility just to silence this error. First consider whether a public behavior test is sufficient.

Forgetting to configure a spy return value

If foo() must return a number, configure it explicitly:

const barSpy = jasmine.createSpyObj<Bar>('Bar', ['bar', 'foo']);
barSpy.foo.and.returnValue(42);

Without configuration, a Jasmine spy normally returns undefined, which can produce misleading failures or invalid state.

Testing only interactions, not the result

This test checks a call but misses whether the class stored the correct value:

expect(barSpy.foo).toHaveBeenCalled();

Also assert the observable output:

expect(subject.status).toBe(42);

Confusing TypeScript private with JavaScript #private

This cannot be bypassed from a test:

class SecureFooBar {
  #initFooBar(): void {
    // true runtime-private implementation
  }
}

Test behavior exposed by public methods instead.

Hiding a design problem with casts

Repeatedly casting to access several private methods often signals that the class has too many responsibilities. Consider extracting the complicated logic into a separately testable collaborator.

Comparisons

ApproachWhat it verifiesBest useTrade-off
Test public state or return valuesObservable behaviorDefault choiceMay not identify the exact internal helper that failed
Test dependency calls with spiesCollaboration with services or helpersSide effects such as API, logging, or initialization callsCan over-specify implementation if every call is asserted
Access TypeScript-private method with a type assertionA specific internal helperRare legacy code or narrowly targeted testsFragile; coupled to names and implementation
Extract logic to a public pure function/classComplex calculations and branchesLogic with its own responsibilityRequires a small design change
Use JavaScript #privateRuntime-enforced encapsulationStrongly hidden implementation detailsCannot be called directly by tests

TypeScript private and JavaScript #private are not interchangeable. TypeScript private is checked by the type system, while #private is enforced by the JavaScript runtime.

Take Quiz

Cheat Sheet

// Create a typed Jasmine spy object
const dependency = jasmine.createSpyObj<Bar>('Bar', ['bar', 'foo']);

// Configure a return value
dependency.foo.and.returnValue(42);

// Create the subject directly when Angular DI is unnecessary
const subject = new FooBar(dependency);

// Assert public behavior
expect(subject.status).toBe(42);

// Assert interaction with a dependency
expect(dependency.bar).toHaveBeenCalledOnceWith('data');
expect(dependency.foo).toHaveBeenCalledTimes(1);
  • Test public outputs, state, events, and collaborator calls first.
  • A constructor call can exercise private initialization logic.
  • private method() is TypeScript-private; it can be reached through a cast, but this is usually a last resort.
  • #method() is runtime-private and cannot be accessed outside its class.
  • Configure return values for spies whose results are used.
  • Instantiate plain classes directly; use Angular TestBed only when Angular setup is needed.

FAQ

Can Jasmine test private methods in TypeScript?

Jasmine runs JavaScript, so a traditional TypeScript private method can often be called after bypassing the compiler with a type assertion. However, testing the public behavior is usually more maintainable.

How do I test a private method called in a constructor?

Configure spies for the constructor dependencies, create the instance, then assert public state and calls recorded by the spies. Construction triggers the private method.

Should I make a private method public for testing?

Usually no. Public visibility should describe the class's supported API, not accommodate a test. Extract complex logic into another testable unit when needed.

Can I access a private method with bracket notation?

For TypeScript-private members, code such as (subject as any)['initFooBar']() may work at runtime, but any removes type safety. A narrow type assertion is preferable if direct access is unavoidable.

Can I test JavaScript #private methods directly?

No. #private fields and methods are enforced at runtime. Test them through public methods and observable effects.

Do I need Angular TestBed to test this class?

No. For a plain class such as FooBar, create Jasmine spies and call new FooBar(spy) directly. Use TestBed when Angular's dependency injection, templates, or framework features are involved.

Why test both the getter result and the spy call?

The getter assertion verifies the outcome. The spy assertion verifies an important collaboration. Use both when the collaborator call is part of the required behavior.

Take Quiz

Mini Project

Description

Build a small SessionInitializer class that initializes session state from an injected authentication client. The initialization helper remains private, while the test verifies the public state and the calls made to the dependency.

Goal

Write Jasmine tests that prove constructor initialization produces the correct public session state without directly calling the private helper.

Requirements

  • Create an AuthClient interface with recordEvent and getCurrentUserId methods.
  • Create a SessionInitializer class that initializes itself in its constructor.
  • Keep the initialization helper private.
  • Use a Jasmine spy object instead of a real authentication client.
  • Verify the public user ID and both expected dependency interactions.
Take Quiz

Keep learning

Related questions

View more