Unit Testing in Angular 15 Without TestBed


Angular 14 introduced the ability to use the inject function in classes like components, directives, and pipes. Library authors have embraced this feature and many have dropped constructor-based Dependency Injection (’DI’). It also inspired a reusable functions called DI Functions.

There are lots of approaches using TestBed that allow to you test and even mock dependencies provided by the Inject function.

However, what if you don’t want to use TestBed? Maybe because of performance concerns, maybe you prefer isolating unit tests from the DOM by default, or maybe you’ve noticed that Angular is introducing utilities to make it easier to run functions in an injection context with providers.

tl;dr:

  • run npm i @ngx-unit-test/inject-mocks -D to install utility functions to unit test any Angular class or function without TestBed.

There are simple approaches to mocking providers using constructor-based DI without TestBed but no clear guide bridging the gap between constructor-based DI and inject-based DI testing without TestBed.

This article (1) summarizes a Component that needs Unit Testing, (2) demonstrates how to test that Component in Angular 15+ without TestBed, and (3) shows how to test a DI Function without TestBed.

1. The Setup: A Component That Needs Unit Testing

Here is a simple HomeComponent written in Angular 15 that uses the inject function:

import { Component, inject, OnInit } from '@angular/core';
import { WidgetsFacade } from '@ngx-demo/widgets/data-access';

@Component({
  selector: 'ngx-demo-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.scss'],
})
export class HomeComponent implements OnInit {
  private readonly _widgetsFacade: WidgetsFacade = inject(WidgetsFacade);

  loaded$ = this._widgetsFacade.loaded$;
  widgets$ = this._widgetsFacade.allWidgets$;

  ngOnInit() {
    this._widgetsFacade.init();
  }
}

The component injects a WidgetsFacade, accesses some observables exposed by that facade, and triggers a facade method during ngOnInit.

2. Providing Mock Dependencies

So, how could you mock the injected WidgetsFacade without using TestBed?

If you just run the spec without TestBed you get this error:

Error NG0203 thrown when testing a Component without providing dependencies

In order to provide the service and mock its functions there are two steps:

First, create a utility method that wraps the @angular/core Injector:


import { Type, StaticProvider, Injector } from '@angular/core';

export const classWithProviders = <T>(config: {
  token: Type<T>;
  providers: StaticProvider[];
}): T => {
  const { providers, token } = config;
  const injector = Injector.create({
    providers: [...providers, { provide: token }],
  });
  return injector.get(token);
};

This utility leverages Angular’s built-in Injector class to automatically provide dependencies to a given class. You may notice the name is classWithProviders and not componentWithProviders. That is because the utility works with Components, Services, Directives, and Pipes!

Second, use classWithProviders in a spec file:

import { WidgetsFacade } from '@ngx-demo/widgets/data-access';
import { classWithProviders } from './class-with-providers.util';
import { HomeComponent } from './home.component';

describe('HomeComponent', () => {
  let component: HomeComponent;
  let facadeMock: Partial<WidgetsFacade>; // 1. Partial<T> for type safety

  beforeEach(() => {
    facadeMock = { init: jest.fn() }; // 2. Assign mock object
    component = classWithProviders({ // 3. Get Component with mocked dependencies
      token: HomeComponent,
      providers: [{ provide: WidgetsFacade, useValue: facadeMock }],
    });
  });

  it('component should create', () => {
    expect(component).toBeTruthy();
  });
  it('ngOnInit() should invoke facade.init', () => {
    component.ngOnInit();
    expect(facadeMock.init).toBeCalled();
  });
});

Let’s walk through each step in the spec file:

  1. Declare a variable facadeMock typed as Partial<WidgetsFacade>. Use Partial to get type inference in the next step.
  2. Assign mock object: create a JavaScript object and assign jest.fn() to the method you need to mock.
  3. Get Component with Injection Context: Thanks to the classWithProviders util, the returned component has the mocked provider!

FacadeMock is provided to HomeComponent as the WidgetsFacade

3. Testing DI Functions Without TestBed

One limitation of classWithProviders is that it does not work with Dependency Injection Functions (DI Functions). The current solution for testing DI Functions involves TestBed.runInInjectionContext, which was released in Angular 15.1.0.

Building on TestBed’s example, it is possible to create a standalone utility that provides a similar solution for testing DI Functions with mocked providers:

import { HttpClient } from '@angular/common/http';
import {
  createEnvironmentInjector,
  EnvironmentInjector,
  inject,
  Injector,
  Provider
} from '@angular/core';

const getPerson = (id: number) => {
  return inject(HttpClient).get(`https://swapi.dev/api/people/${id}/`);
};

const runFnInContext = (providers: Provider[]) => {
  const injector = createEnvironmentInjector(
    providers,
    Injector.create({ providers: [] }) as EnvironmentInjector
  );
  return injector.runInContext.bind(injector);
};

describe('getPerson()', () => {
  it('should invoke HttpClient.get', () => {
    // Arrange
    const id = 1;
    const httpMock: Partial<HttpClient> = { get: jest.fn() };
    const providers = [{ provide: HttpClient, useValue: httpMock }];
    // Act
    runFnInContext(providers)(() => getPerson(id));
    // Assert
    expect(httpMock.get).toBeCalled();
    expect(httpMock.get).toBeCalledWith(`https://swapi.dev/api/people/${id}/`);
  });
});

In fact, the Angular team is already working on a new runInInjectionContext utility that replaces and extends the runInContext method.

Conclusion

The classWithProviders and runFnInContext utilities were inspired by code from the Angular.io source code. I’ve taken the extra step of bundling these utilities into an npm package. Thanks to Josh Van Allen and Rafael Mestre for their insight and help developing these utilities.