Introduction
Since version 14 of Angular, the inject function can be used outside the injection context, making it easier to compose screens and transform gards, resolvers and interceptors into simple functions.
However, testing a service in the form of classes is much more straightforward than testing a function in which the inject function is used.
This article looks at testing of guards, resolvers and interceptors as functions.
Injector level
To understand how to properly test our guards, etc. as functions, it's important to understand how injection works and how the value of a service is retrieved.
In Angular, there are different types of injector, or to be more precise, different levels of injector:
- Element Injector: This is the injector at the component level for exemple.
@Component({
template: '',
providers: [UserService]
})
export class UserComponent {}
- module injector: This is the injector specific to the module.
@NgModule({
declarations: [UserComponent],
providers: [UserService]
})
export class UserModule {}
Environment Injector: Now Angular introduced a new class called EnvironmentInjector. Thanks to that we are able to make an injection wherever we want. They expect this functionality to be very useful for designing ergonomic APIs.
There is something we need to not forget. EnvironmentInjector is completely independent from Component tree. It’s a module injector, so it can’t inject anything that is not in module or router.Root Injector: This is the top level of injector. Provider register in the root injector is accessible everywhere in the application
@Injectable({ provideIn: 'root'})
export class UserService {}
When a service is called in a component, Angular will attempt to resolve the value of that service by performing injector bubbling as follows
If the service is not found in any injector, Angular will raise an error specifying that it has failed to resolve the value of the requested service.
Test your functional guard, resolver, interceptors
What's difficult to test in guards written as functions is not the function itself, but the inject function used within it.
Example:
export function UserDetailsResolver(
route: ActivatedRouteSnapshot): Observable<User> {
const userService = inject(UserService);
return userService.getUserDetails(route.paramMap.get('id'))
}
If we want to be able to test our function correctly, we'll need to be able to call it in an injection context (and therefore implicitly create an environment injector).
In the application context, to carry out this type of process, the function to use is the runInInjectionContext
function.
runInInjectionContext(injector, () => {});
This function takes the following parameters
- the injector in which the value of the service you wish to resolve with the inject function is located
- the function to run in the injector context
Typically, this is exactly the function you'd use to mock the injection of the UserService service.
However, our context is a little different in that it's the test context and not the application context.
Once again, Angular has thought of everything, and this same function can be found in tests using the TestBed class.
If you wish to use the runInjectionContext function, you must create an injection context.
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 injection context is set, simply call the guard function in the injection context.
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 return type of the runInjectionContext function corresponds to the return type of the function called in the injection context.
runInInjectionContext<T>(fn: () => T): T
In our case, the return type of the UserDetailsResolver function is an Observable.
To summarize, the test file corresponding to the UserDetailsResolver function is as follows:
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' })
});
});
In this article, the example corresponds to a resolver written as a function.
Interceptors and guards written as functions use exactly the same concepts as the resolver shown as an example in this article. The method used to test them will therefore be the same as that used to test the example resolver.
Top comments (0)