Testing Angular Services and Dependency Injection
Angular services are where most of the application's business logic lives. They're also easier to test than components — no DOM, no templates, just TypeScript classes that take inputs and produce outputs. This guide covers every pattern you'll need to test services properly.
Testing a Service in Isolation
For services with no dependencies, TestBed is optional. You can instantiate them directly:
@Injectable({ providedIn: 'root' })
export class CartService {
private items: CartItem[] = [];
add(item: CartItem): void {
const existing = this.items.find(i => i.id === item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
this.items.push({ ...item });
}
}
remove(id: string): void {
this.items = this.items.filter(i => i.id !== id);
}
getTotal(): number {
return this.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
}
getItems(): CartItem[] {
return [...this.items];
}
}
describe('CartService', () => {
let service: CartService;
beforeEach(() => {
service = new CartService();
});
it('adds new items to the cart', () => {
service.add({ id: '1', name: 'Widget', price: 10, quantity: 1 });
expect(service.getItems()).toHaveSize(1);
});
it('increases quantity when adding an existing item', () => {
service.add({ id: '1', name: 'Widget', price: 10, quantity: 1 });
service.add({ id: '1', name: 'Widget', price: 10, quantity: 2 });
const items = service.getItems();
expect(items).toHaveSize(1);
expect(items[0].quantity).toBe(3);
});
it('calculates total correctly', () => {
service.add({ id: '1', name: 'A', price: 5, quantity: 2 });
service.add({ id: '2', name: 'B', price: 3, quantity: 1 });
expect(service.getTotal()).toBe(13);
});
it('removes an item by id', () => {
service.add({ id: '1', name: 'A', price: 5, quantity: 1 });
service.add({ id: '2', name: 'B', price: 3, quantity: 1 });
service.remove('1');
expect(service.getItems().map(i => i.id)).toEqual(['2']);
});
});Direct instantiation gives you the fastest test cycle. No Angular overhead, no zone.js, just plain function calls. Use it whenever the service has no Angular dependencies.
Using TestBed for Angular-Aware Services
When the service uses Angular's DI (injected dependencies), you need TestBed to wire the injector:
describe('CartService via TestBed', () => {
let service: CartService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CartService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});TestBed.inject retrieves a service from the test injector. Angular uses the same injection tokens it would use at runtime.
Mocking HTTP with HttpClientTestingModule
HTTP services require HttpClientTestingModule instead of the real HttpClientModule. This module provides a testing controller that intercepts requests and lets you flush mock responses.
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
@Injectable({ providedIn: 'root' })
export class UserService {
private apiUrl = '/api/users';
constructor(private http: HttpClient) {}
getUser(id: string): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`);
}
createUser(user: Omit<User, 'id'>): Observable<User> {
return this.http.post<User>(this.apiUrl, user);
}
updateUser(id: string, changes: Partial<User>): Observable<User> {
return this.http.patch<User>(`${this.apiUrl}/${id}`, changes);
}
deleteUser(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // fails if any expected requests were not matched
});
it('fetches a user by id', () => {
const mockUser: User = { id: '1', name: 'Alice', email: 'alice@example.com' };
service.getUser('1').subscribe(user => {
expect(user).toEqual(mockUser);
});
const req = httpMock.expectOne('/api/users/1');
expect(req.request.method).toBe('GET');
req.flush(mockUser);
});
it('sends a POST request when creating a user', () => {
const newUser = { name: 'Bob', email: 'bob@example.com' };
const createdUser: User = { id: '2', ...newUser };
service.createUser(newUser).subscribe(user => {
expect(user.id).toBe('2');
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newUser);
req.flush(createdUser);
});
it('handles 404 errors gracefully', () => {
service.getUser('999').subscribe({
next: () => fail('should have failed'),
error: (err) => expect(err.status).toBe(404),
});
const req = httpMock.expectOne('/api/users/999');
req.flush('Not found', { status: 404, statusText: 'Not Found' });
});
it('sends PATCH with only changed fields', () => {
service.updateUser('1', { email: 'newemail@example.com' }).subscribe();
const req = httpMock.expectOne('/api/users/1');
expect(req.request.method).toBe('PATCH');
expect(req.request.body).toEqual({ email: 'newemail@example.com' });
req.flush({ id: '1', name: 'Alice', email: 'newemail@example.com' });
});
});httpMock.verify() in afterEach catches forgotten expectations. If your service makes an unexpected request, or an expected request was never made, verify() throws and fails the test. This prevents silent mismatch bugs.
Testing Services with Injected Dependencies
When a service injects another service, create a spy and provide it through the test module.
@Injectable({ providedIn: 'root' })
export class OrderService {
constructor(
private http: HttpClient,
private authService: AuthService,
private cartService: CartService
) {}
placeOrder(): Observable<Order> {
const userId = this.authService.getCurrentUserId();
const items = this.cartService.getItems();
if (!userId) {
return throwError(() => new Error('Not authenticated'));
}
if (items.length === 0) {
return throwError(() => new Error('Cart is empty'));
}
return this.http.post<Order>('/api/orders', { userId, items });
}
}
describe('OrderService', () => {
let service: OrderService;
let httpMock: HttpTestingController;
let authServiceSpy: jasmine.SpyObj<AuthService>;
let cartServiceSpy: jasmine.SpyObj<CartService>;
beforeEach(() => {
authServiceSpy = jasmine.createSpyObj('AuthService', ['getCurrentUserId']);
cartServiceSpy = jasmine.createSpyObj('CartService', ['getItems']);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
{ provide: AuthService, useValue: authServiceSpy },
{ provide: CartService, useValue: cartServiceSpy },
],
});
service = TestBed.inject(OrderService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('places an order when authenticated with items in cart', () => {
authServiceSpy.getCurrentUserId.and.returnValue('user-123');
cartServiceSpy.getItems.and.returnValue([
{ id: '1', name: 'Widget', price: 10, quantity: 1 }
]);
service.placeOrder().subscribe(order => {
expect(order.id).toBeTruthy();
});
const req = httpMock.expectOne('/api/orders');
expect(req.request.body.userId).toBe('user-123');
req.flush({ id: 'order-456', status: 'pending' });
});
it('throws when user is not authenticated', (done) => {
authServiceSpy.getCurrentUserId.and.returnValue(null);
cartServiceSpy.getItems.and.returnValue([
{ id: '1', name: 'Widget', price: 10, quantity: 1 }
]);
service.placeOrder().subscribe({
next: () => fail('should have thrown'),
error: (err) => {
expect(err.message).toBe('Not authenticated');
done();
}
});
httpMock.expectNone('/api/orders');
});
it('throws when cart is empty', (done) => {
authServiceSpy.getCurrentUserId.and.returnValue('user-123');
cartServiceSpy.getItems.and.returnValue([]);
service.placeOrder().subscribe({
error: (err) => {
expect(err.message).toBe('Cart is empty');
done();
}
});
});
});Each dependency gets its own spy object. Spy configuration (and.returnValue(...)) is set per test, which means you can test different dependency states without resetting the entire module.
Spying on Method Calls
Sometimes you need to verify that a dependency method was called — not just that the service returned the right value.
it('calls cartService.getItems exactly once per order attempt', () => {
authServiceSpy.getCurrentUserId.and.returnValue('user-1');
cartServiceSpy.getItems.and.returnValue([
{ id: '1', name: 'A', price: 5, quantity: 1 }
]);
service.placeOrder().subscribe();
const req = httpMock.expectOne('/api/orders');
req.flush({ id: 'order-1' });
expect(cartServiceSpy.getItems).toHaveBeenCalledTimes(1);
});
it('does not call the HTTP layer when validation fails', () => {
authServiceSpy.getCurrentUserId.and.returnValue(null);
cartServiceSpy.getItems.and.returnValue([]);
service.placeOrder().subscribe({ error: () => {} });
httpMock.expectNone('/api/orders');
expect(cartServiceSpy.getItems).not.toHaveBeenCalled();
});toHaveBeenCalledTimes and toHaveBeenCalledWith let you assert on interaction patterns, not just return values. Use this when the service's correctness depends on coordinating multiple dependencies correctly.
Testing RxJS Operators and Transformations
Services often pipe operators onto HTTP observables — retrying on error, mapping responses, catching and transforming errors. Test these transformations explicitly.
@Injectable({ providedIn: 'root' })
export class DataService {
constructor(private http: HttpClient) {}
getWithRetry(url: string): Observable<unknown> {
return this.http.get(url).pipe(
retry(2),
catchError((err) => {
if (err.status === 404) {
return of(null);
}
return throwError(() => err);
})
);
}
}
describe('DataService', () => {
let service: DataService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ imports: [HttpClientTestingModule] });
service = TestBed.inject(DataService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('retries twice before giving up on server errors', () => {
let errorCount = 0;
service.getWithRetry('/api/data').subscribe({
error: () => errorCount++,
});
// First attempt + 2 retries = 3 total requests
for (let i = 0; i < 3; i++) {
const req = httpMock.expectOne('/api/data');
req.flush('Server error', { status: 500, statusText: 'Internal Server Error' });
}
expect(errorCount).toBe(1);
});
it('returns null for 404 responses instead of throwing', () => {
let result: unknown = 'not-set';
service.getWithRetry('/api/data').subscribe(val => (result = val));
// 404 is not retried — catchError handles it immediately
// With retry(2), 404 also triggers retries before catchError
const reqs = httpMock.match('/api/data');
reqs.forEach(req =>
req.flush('Not found', { status: 404, statusText: 'Not Found' })
);
expect(result).toBeNull();
});
});Testing Subjects and BehaviorSubjects
Services often use subjects to share state across components. Test both the emission pattern and the current value behavior:
@Injectable({ providedIn: 'root' })
export class ThemeService {
private theme$ = new BehaviorSubject<'light' | 'dark'>('light');
currentTheme$ = this.theme$.asObservable();
toggle(): void {
this.theme$.next(this.theme$.value === 'light' ? 'dark' : 'light');
}
setTheme(theme: 'light' | 'dark'): void {
this.theme$.next(theme);
}
}
describe('ThemeService', () => {
let service: ThemeService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ThemeService);
});
it('starts with light theme', (done) => {
service.currentTheme$.subscribe(theme => {
expect(theme).toBe('light');
done();
});
});
it('toggles from light to dark', (done) => {
service.toggle();
service.currentTheme$.subscribe(theme => {
expect(theme).toBe('dark');
done();
});
});
it('emits all theme changes in sequence', () => {
const emitted: string[] = [];
service.currentTheme$.subscribe(t => emitted.push(t));
service.toggle();
service.toggle();
service.setTheme('dark');
expect(emitted).toEqual(['light', 'dark', 'light', 'dark']);
});
});BehaviorSubject emits the current value immediately on subscription, which is why emitted starts with 'light' without any explicit emission.
What Service Tests Should Cover
A service test suite should answer: given specific inputs and dependency behaviors, does the service produce the correct output and side effects? Tests should not know about the internals — the specific RxJS operators used, the exact order of operations — only the observable contract.
For services handling authentication, permissions, or financial calculations, test error paths as thoroughly as the happy path. The edge cases are where bugs live.
Service-level tests are fast and reliable but can't verify that your entire system integrates correctly. When you need to confirm that an Angular application behaves correctly from a user's perspective — clicking buttons, filling forms, navigating — HelpMeTest provides continuous end-to-end testing written in plain English that runs against your deployed environment around the clock.