Sebastian Gomez
Three ways to inject services in unit tests with Angular
When we write unit tests for our services in Angular, we have several options to inject the service into each of our it blocks. In this post we'll explore the advantages and disadvantages of each way.
A look at the service we want to test
Let's review a super simple service we want to test. So that all three examples run as is, the service exposes a list of names, a state property myVar, and a method to update it:
// Import the Injectable decorator from the @angular/core package
import { Injectable } from '@angular/core';
// Use the Injectable decorator to indicate NamesService can be injected elsewhere in the app
@Injectable({
providedIn: 'root', // Specifies this class is provided at the root level of the dependency injector
})
export class NamesService {
// Private list of names initialized with two values
private names: string[] = ['Juan', 'Mati'];
// State property we'll use in Form 3 to demonstrate persistence
public myVar = 0;
constructor() {}
// Public method that returns the names array
public getNames(): string[] {
return this.names;
}
// Public method that updates the value of myVar
public setMyVar(value: number): void {
this.myVar = value;
}
}Form 1: Inject the service directly in each it block
For a long time, the test file generated by the Angular CLI proposed injecting the service directly into each it block using the functional inject(...) helper from @angular/core/testing, like this:
// Import TestBed and inject from @angular/core/testing, needed to test in Angular
import { TestBed, inject } from '@angular/core/testing';
// Import the NamesService class
import { NamesService } from './names.service';
// Define the test suite for the NamesService class
describe('NamesService', () => {
// Before each test, configure the testing module with TestBed
beforeEach(() => {
TestBed.configureTestingModule({
providers: [NamesService], // Register NamesService as a provider in the testing module
});
});
// Verify the service instance is created correctly
it('should be created', inject([NamesService], (service: NamesService) => {
expect(service).toBeTruthy();
}));
it('should be something', inject([NamesService], (service: NamesService) => {
// The test code would go here
}));
it('should be .....', inject([NamesService], (service: NamesService) => {
// The test code would go here
}));
});Keep in mind this is the legacy pattern. The inject([...], (service) => {...}) helper from @angular/core/testing still exists, but the Angular CLI no longer generates it by default. Today the recommended way to get the service inside an it is TestBed.inject(NamesService), which is also type safe:
it('should be created', () => {
const service = TestBed.inject(NamesService);
expect(service).toBeTruthy();
});Advantage: We start with a fresh, clean service in each it block.
Disadvantage: It requires repeating the same line in every test.
Form 2: Use Angular TestBed to avoid repeating the line
We can lean on Angular TestBed to avoid repeating that line in every block. Something like this:
let service: NamesService;
// Before each test, run the following function
beforeEach(() => {
// Configure the testing module with TestBed
TestBed.configureTestingModule({
providers: [NamesService], // Register NamesService as a provider in the testing module
});
// Get an instance of NamesService with TestBed.inject()
// (replaces the old TestBed.get(), which was removed in Angular 14)
service = TestBed.inject(NamesService);
});
// The service is already available in each test
it('should be created', () => {
expect(service).toBeTruthy();
});Note: If you come from older tutorials you'll see TestBed.get(NamesService). That method was deprecated in Angular 9 and removed entirely in Angular 14, so on any version supported today it won't compile. Always use TestBed.inject(NamesService), which is the current, type safe API.
Advantage: Each it block gets a fresh version of the service, avoiding dragging along a service polluted by previous tests.
Disadvantage: It doesn't let you share the same service instance across different it blocks.
Form 3: Persist the service state across the different it blocks
To have exactly the same service instance across the it blocks, we can use the following approach with beforeAll:
let service: NamesService;
// Before all tests, get a single instance of NamesService
beforeAll(() => {
TestBed.configureTestingModule({
providers: [NamesService],
});
service = TestBed.inject(NamesService);
});
// Verify the value of myVar is set to 13
it('should set myVar to 13', () => {
service.setMyVar(13);
expect(service.myVar).toBe(13);
});
// Verify myVar is still 13 (persisted state) and then change it to 12
it('should keep myVar at 13 and then set it to 12', () => {
expect(service.myVar).toBe(13);
service.setMyVar(12);
expect(service.myVar).toBe(12);
});Notice that the second test expects myVar to still be 13 because the instance is the same one from the first test. This only works because they both share state.
Caution: Sharing state with beforeAll creates order dependent tests, which is an anti pattern if you generalize it. If you run the it blocks in a different order or in isolation, they'll fail. Treat it as a deliberate, narrow technique (for example, to follow the traceability of a CRUD flow), not as your default. In most cases, a fresh instance per test (Form 2) is safer and easier to maintain.
Advantage: It lets you persist the service state across the different it blocks, which can be useful in tests related to CRUD processes to avoid repeating code and keep the traceability of the whole flow.
Disadvantage: It can make tests harder to maintain if you don't carefully manage state persistence, and it couples them to the execution order.
Conclusions
Reviewing the three strategies to inject services into our tests, we have:
it('should be created', inject([NamesService], (service: NamesService) => { /* ... */ }));
beforeEach(() => service = TestBed.inject(NamesService));
beforeAll(() => service = TestBed.inject(NamesService));Form 1 is the legacy old CLI pattern; today we prefer TestBed.inject() inside the it. Form 2 gives you a clean instance in each test, and Form 3 shares a single instance when you need to persist state, accepting the cost of order dependent tests.
Exercises to practice
- Create a simple service that manages a list of pending tasks (ToDo) and use the three ways of injecting services in tests to cover different cases.
- Compare the three approaches in a scenario that requires testing a service with state persistence across
itblocks and another that doesn't. Which approach seems most suitable for each case? - Research how to manage state persistence in tests properly to avoid maintenance problems and order dependent tests.
3-point summary
- There are three main ways to inject services in unit tests with Angular, each with its advantages and disadvantages, and all of them use
TestBed.inject()instead of the removedTestBed.get(). - The choice of approach depends on whether you need a fresh service instance in each
itblock or you need to persist state throughout the tests. - Practicing with different scenarios will help you determine which approach is most suitable for each situation.
I hope this post is useful to you. If you have any questions, don't hesitate to leave me a comment below. And remember, if you liked it, you can also share it using the social links below.
Sebastian Gomez
Creador de contenido principalmente acerca de tecnología.