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