Testing Services & DI
Services hold the business logic of an Angular application — HTTP calls, state, calculations, and orchestration between collaborators. Because they are plain classes wired together through dependency injection, they are the easiest and fastest part of your app to test in isolation. The key skill is configuring the testing injector with TestBed, pulling out the service under test with TestBed.inject, and swapping real collaborators for lightweight fakes so each test exercises one behavior at a time.
Resolving services from the testing injector
TestBed.configureTestingModule builds a throwaway injector for each test. You register providers there, then call TestBed.inject(Token) to obtain a fully constructed instance. Because the injector resolves the dependency graph for you, any inject() calls or constructor parameters inside the service are satisfied automatically.
import { TestBed } from '@angular/core/testing';
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class PricingService {
applyDiscount(amount: number, percent: number): number {
if (percent < 0 || percent > 100) {
throw new Error('percent must be between 0 and 100');
}
return Math.round(amount * (1 - percent / 100) * 100) / 100;
}
}
describe('PricingService', () => {
let service: PricingService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(PricingService);
});
it('applies a percentage discount', () => {
expect(service.applyDiscount(200, 10)).toBe(180);
});
it('rejects an out-of-range percent', () => {
expect(() => service.applyDiscount(200, 150)).toThrow();
});
});
A service marked providedIn: 'root' does not need to be listed in providers — the root injector already knows how to create it. You only add it explicitly when you want to override how it is constructed.
Tip: For a pure-logic service with no dependencies you can skip
TestBedentirely and just writenew PricingService(). Reach forTestBedonce DI gets involved — it is what makes overrides clean.
Providing fakes for dependencies
Real services usually depend on other services. To keep a unit test focused, replace each collaborator with a fake that returns predictable data. Register the fake against the same token using { provide: Token, useValue / useClass }, and the injector hands your fake to the service under test.
import { Injectable, inject } from '@angular/core';
export interface User {
id: number;
name: string;
}
@Injectable({ providedIn: 'root' })
export class UserApi {
getUser(id: number): Promise<User> {
return fetch(`/api/users/${id}`).then((r) => r.json());
}
}
@Injectable({ providedIn: 'root' })
export class GreetingService {
private api = inject(UserApi);
async welcome(id: number): Promise<string> {
const user = await this.api.getUser(id);
return `Welcome, ${user.name}!`;
}
}
The test never touches the network. It substitutes UserApi with a fake whose getUser is a Jasmine spy:
import { TestBed } from '@angular/core/testing';
describe('GreetingService', () => {
let service: GreetingService;
let apiSpy: jasmine.SpyObj<UserApi>;
beforeEach(() => {
apiSpy = jasmine.createSpyObj<UserApi>('UserApi', ['getUser']);
TestBed.configureTestingModule({
providers: [
GreetingService,
{ provide: UserApi, useValue: apiSpy },
],
});
service = TestBed.inject(GreetingService);
});
it('builds a greeting from the fetched user', async () => {
apiSpy.getUser.and.resolveTo({ id: 1, name: 'Ada' });
const message = await service.welcome(1);
expect(message).toBe('Welcome, Ada!');
expect(apiSpy.getUser).toHaveBeenCalledWith(1);
});
});
Output:
GreetingService
✓ builds a greeting from the fetched user
Executed 1 of 1 SUCCESS (0.012 secs / 0.004 secs)
Choosing a substitution strategy
The provider syntax supports several ways to supply a fake. Pick based on how much control you need.
| Strategy | When to use | Example |
|---|---|---|
useValue | A plain object or spy with fixed behavior | { provide: UserApi, useValue: apiSpy } |
useClass | A reusable fake implementation across many specs | { provide: UserApi, useClass: FakeUserApi } |
useFactory | The fake needs setup or other deps | { provide: Cfg, useFactory: () => ({ debug: true }) } |
TestBed.overrideProvider | Tweak one provider after module setup | TestBed.overrideProvider(UserApi, { useValue: apiSpy }) |
A useClass fake is handy when several tests share the same collaborator. Implement only the methods you actually call:
class FakeUserApi {
getUser(id: number): Promise<User> {
return Promise.resolve({ id, name: `User ${id}` });
}
}
Isolating business logic
The goal of a service test is to verify decisions, not infrastructure. Keep formatting, branching, validation, and state transitions in the service, and push side effects (HTTP, storage, timers) into thin collaborators you can fake. When a method computes a value from inputs, assert the value directly. When it coordinates collaborators, assert the calls with toHaveBeenCalledWith and let the fake stand in for the real effect.
For services that wrap HttpClient, do not hand-roll a fake — use provideHttpClient(withInterceptorsFromDi()) together with provideHttpClientTesting() and the HttpTestingController so you exercise the real request-building code. For everything else, a spy keeps the test fast and deterministic.
Warning: If
TestBed.inject(MyService)throwsNullInjectorError: No provider for X, a dependency is missing fromproviders. Add the real provider or a fake forX— the testing injector does not fall back to the app’s root configuration unless the service declaresprovidedIn: 'root'.
Best Practices
- Inject the service with
TestBed.injectinsidebeforeEachso every test starts from a fresh instance and injector. - Fake every external collaborator (HTTP, router, storage) so tests stay fast, offline, and deterministic.
- Prefer
jasmine.createSpyObjfor one-off fakes and auseClassimplementation when several specs share the same collaborator. - Assert both the returned value and the interactions (
toHaveBeenCalledWith) so you catch wrong arguments, not just wrong results. - Skip
TestBedfor dependency-free pure logic — a directnew Service()is simpler and faster. - Test each branch and edge case (errors, empty input, boundary values) as its own focused spec.