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 {}
Enter fullscreen mode Exit fullscreen mode
  • Module injector: This corresponds to the injector dedicated to a particular module.
@NgModule({
  declarations: [UserComponent],
  providers: [UserService]
})
export class UserModule {}
Enter fullscreen mode Exit fullscreen mode
  • Environment Injector: More recently, Angular introduced the EnvironmentInjector class, which enables injection from anywhere in the application. This capability is anticipated to be valuable for building ergonomic APIs. It's important to note that EnvironmentInjector is 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 {}
Enter fullscreen mode Exit fullscreen mode

When a component requests a service, Angular resolves its value by walking up the injector hierarchy in a process known as injector bubbling:

Bubbling Injector

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'))
}
Enter fullscreen mode Exit fullscreen mode

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, () => {});
Enter fullscreen mode Exit fullscreen mode

This function requires two arguments:

  • the injector that holds the service to be resolved via the inject function
  • 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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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' })
});
Enter fullscreen mode Exit fullscreen mode

The value returned by runInInjectionContext matches whatever the executed callback returns:

 runInInjectionContext<T>(fn: () => T): T
Enter fullscreen mode Exit fullscreen mode

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' })
  });
});
Enter fullscreen mode Exit fullscreen mode

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.