Introduction
Starting with version 14 of Angular, the inject function has become usable beyond the injection context, which simplifies composing screens and refactoring guards, resolvers, and interceptors into plain functions.
Yet, testing a class-based service is considerably simpler than testing a function that relies on the inject function internally.
This piece explores how to approach testing guards, resolvers, and interceptors implemented as functions.
Injector hierarchy
To properly test functions like guards, it helps to first grasp how injection operates and how a service's value gets resolved.
Angular provides several flavors of injector, or to be exact, multiple injector levels:
- Element Injector: This operates at the component level, for instance.
@Component({
template: '',
providers: [UserService]
})
export class UserComponent {}
- Module injector: This corresponds to the injector dedicated to a particular module.
@NgModule({
declarations: [UserComponent],
providers: [UserService]
})
export class UserModule {}
Environment Injector: More recently, Angular introduced the
EnvironmentInjectorclass, which enables injection from anywhere in the application. This capability is anticipated to be valuable for building ergonomic APIs. It's important to note thatEnvironmentInjectoris entirely decoupled from the component tree. Since it behaves like a module injector, it cannot resolve dependencies that aren't provided at the module or router level.Root Injector: The topmost injector level. Providers registered here are accessible globally throughout the app.
@Injectable({ provideIn: 'root'})
export class UserService {}
When a component requests a service, Angular resolves its value by walking up the injector hierarchy in a process known as injector bubbling:
If the service cannot be located in any injector, Angular throws an error indicating that it couldn't resolve the service's value.
Testing functional guards, resolvers, interceptors
The real challenge with functions acting as guards isn't the function itself—it's the inject call embedded within.
Consider the following example:
export function UserDetailsResolver(
route: ActivatedRouteSnapshot): Observable<User> {
const userService = inject(UserService);
return userService.getUserDetails(route.paramMap.get('id'))
}
To test such a function, it must be invoked within an injection context, which implicitly creates an environment injector.
In a typical application setting, the runInInjectionContext function is the tool for this job:
runInInjectionContext(injector, () => {});
This function requires two arguments:
- the injector that holds the service to be resolved via the
injectfunction - the callback function to execute within that injection context
In practice, this is precisely the mechanism you'd use to mock the injection of a service like UserService.
But the testing scenario introduces a different setting than the application—it's the test environment instead.
Angular accounts for this as well; the same function is available in tests via the TestBed utility.
To leverage runInInjectionContext, an injection context must be set up:
const MOCK_USER_SERVICE = {
getUserDetails: jest.fn(),
};
const MOCK_ROUTE = {
paramMap: new Map(['id', 123]),
};
describe('UserDetailsResolver', () => {
let service: UserService;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: [{ provide: UserService, useValue: MOCK_USER_SERVICE }]
});
service = TestBed.inject(UserService);
}));
test('should create an instance of UserService', () => {
expect(service).toBeInstanceOf(Userservice);
});
});
Once the context is established, invoking the guard function within it is straightforward:
test('should return the details of the user', fakeAsync(() => {
MOCK_USER_SERVICE.getUserDetails.mockResolvedValue(of({ name: 'Nicolas' }));
let user: User | null = null;
const resolver: Observable<User> = TestBed.runInInjectionContext(() => {
return UserDetailsResolver(MOCK_ROUTE);
});
resolver.subscribe(response => (user = response));
tick();
expect(MOCK_USER_SERVICE.getUserDetails).toHaveBeenCalledTimes(1);
expect(MOCK_USER_SERVICE.getUserDetails).toHaveBeenCalledWith(123);
expect(user).toEqual({ name: 'Nicolas' })
});
The value returned by runInInjectionContext matches whatever the executed callback returns:
runInInjectionContext<T>(fn: () => T): T
For instance, UserDetailsResolver produces an Observable.
Putting it all together, here's what the complete test file for UserDetailsResolver might look like:
const MOCK_USER_SERVICE = {
getUserDetails: jest.fn(),
};
const MOCK_ROUTE = {
paramMap: new Map(['id', 123]),
};
describe('UserDetailsResolver', () => {
let service: UserService;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: [{ provide: UserService, useValue: MOCK_USER_SERVICE }]
});
service = TestBed.inject(UserService);
}));
test('should create an instance of UserService', () => {
expect(service).toBeInstanceOf(Userservice);
});
test('should return the details of the user', fakeAsync(() => {
MOCK_USER_SERVICE.getUserDetails.mockResolvedValue(of({ name: 'Nicolas' }));
let user: User | null = null;
const resolver: Observable<User> = TestBed.runInInjectionContext(() => {
return UserDetailsResolver(MOCK_ROUTE);
});
resolver.subscribe(response => (user = response));
tick();
expect(MOCK_USER_SERVICE.getUserDetails).toHaveBeenCalledTimes(1);
expect(MOCK_USER_SERVICE.getUserDetails).toHaveBeenCalledWith(123);
expect(user).toEqual({ name: 'Nicolas' })
});
});
The example in this article uses a resolver as an illustration. Interceptors and guards implemented as functions rely on the same principles covered here, so the testing strategy for them mirrors what's demonstrated with the resolver example.

