Question
Testing Private Methods in Angular TypeScript with Jasmine
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
statusgetter 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.
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:
createSpyObjcreates a fakeBarobject. Its methods are Jasmine spies, so calls can be inspected.barSpy.foo.and.returnValue(7)tells the fakefoo()method to return7.new FooBar(barSpy)passes the fake dependency into the constructor.- The constructor calls
initFooBar(). initFooBar()callsbarSpy.bar('data'). Jasmine records this call.initFooBar()callsbarSpy.foo(), which returns7because of the configured spy behavior.- The class assigns
7to_status. subject.statusreturns_status, so the expectation receives7.- 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.
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
| Approach | What it verifies | Best use | Trade-off |
|---|---|---|---|
| Test public state or return values | Observable behavior | Default choice | May not identify the exact internal helper that failed |
| Test dependency calls with spies | Collaboration with services or helpers | Side effects such as API, logging, or initialization calls | Can over-specify implementation if every call is asserted |
| Access TypeScript-private method with a type assertion | A specific internal helper | Rare legacy code or narrowly targeted tests | Fragile; coupled to names and implementation |
| Extract logic to a public pure function/class | Complex calculations and branches | Logic with its own responsibility | Requires a small design change |
Use JavaScript #private | Runtime-enforced encapsulation | Strongly hidden implementation details | Cannot 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.
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
TestBedonly 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.
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
AuthClientinterface withrecordEventandgetCurrentUserIdmethods. - Create a
SessionInitializerclass 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.
Keep learning
Related questions
@Directive vs @Component in Angular: Differences, Use Cases, and When to Use Each
Learn the difference between @Directive and @Component in Angular, including use cases, examples, and when to choose each.
Accessing Input Value from EventTarget in TypeScript
Learn why EventTarget has no value property in TypeScript and safely read values from HTML input events in Angular applications.
Angular (change) vs (ngModelChange): What’s the Difference?
Learn the difference between Angular (change) and (ngModelChange), when each fires, and which one to use in forms and inputs.