Testing Angular’s Latest Features

1. Standalone & Mocking

If you prefer the kind of tests where you minimize your mock as much as possible, you will be quite happy with Standalone Components. Forgotten are the days when we had to carefully select only those dependencies from an NgModule that belong to the component under test and move it over to the TestingModule.

The Standalone Component has everything in it. Just add it to the imports property of the TestingModule. All its "visual elements", like sub components, directives, and pipes, and their dependencies are now part of the test as well. In terms of code coverage, that's quite huge.

When we write a test, we check what services our component requires. Typical candidates are HttpClient, ActivatedRoute. We need to mock them. That's doable.

Unfortunately, the components' dependencies also require services, which - some of them - we also have to provide.

Let's look at the following component RequestInfo, we want to test:

A huge amount of services derive from RequestInfoHolidayCard. With NgRx, we have quite an additional heavy dependency.

Looking at the necessary setup of the TestingModule, there is quite a lot to consider:

const fixture = TestBed.configureTestingModule({
  imports: [RequestInfoComponent],
  providers: [
    provideNoopAnimations(),
    {
      provide: HttpClient,
      useValue: {
        get: (url: string) => {
          if (url === "/holiday") {
            return of([createHoliday()]);
          }
          return of([true]).pipe(delay(125));
        },
      },
    },
    {
      provide: ActivatedRoute,
      useValue: {
        paramMap: of({ get: () => 1 }),
      },
    },
    provideStore({}),
    provideState(holidaysFeature),
    provideEffects([HolidaysEffects]),
    {
      provide: Configuration,
      useValue: { baseUrl: "https://somewhere.com" },
    },
  ],
}).createComponent(RequestInfoComponent);

What we want to achieve is that we mock only the RequestInfoHolidayCard. That would free us from quite a lot of service dependencies:

To mock a component, we do it the manual way. There are third-party libraries available, like ng-mocks, which provide functions to automate that.

We add the code of the mocked Component directly into the test file

@Component({
  selector: "app-request-info-holiday-card",
  template: ``,
  standalone: true,
})
class MockedRequestInfoHolidayCard {}

The MockedRequestInfoHolidayCard is a simple component without any dependencies. What it has in common with the original is the selector. So when Angular sees the tag <app-request-info-holiday-card>, it would use the mocked version.

The next step is to import the mock into the TestingModule. With all its dependencies gone, the TestingModule setup slims down quite a bit:

const fixture = TestBed.configureTestingModule({
  imports: [RequestInfoComponent, MockedRequestInfoHolidayCard],
  providers: [
    provideNoopAnimations(),
    {
      provide: HttpClient,
      useValue: {
        get: (url: string) => of([true]).pipe(delay(125)),
      },
    },
  ],
}).createComponent(RequestInfoComponent);

Unfortunately, that does not work. The test fails because ActivatedRoute (dependency of RequestInfoHolidayCard) is not available.

The reason should be clear. RequestInfoHolidayCard is not part of the imports property of some NgModule but directly of the RequestInfoComponent. Although the mocked version is now part of the TestingModule, the imports coming from RequestInfoComponent internally override it.

Our only chance is to access the imports property of the component itself. Luckily, there is a TestBed.overrideComponent. A method, made for that use case. After replacing the original with the mock, we configure the TestingModule and proceed with the actual test.

TestBed.overrideComponent(RequestInfoComponent, {
  remove: { imports: [RequestInfoComponentHolidayCard] },
  add: { imports: [MockedRequestInfoHolidayCard] },
});

const fixture = TestBed.configureTestingModule({
  imports: [RequestInfoComponent],
  providers: [
    provideNoopAnimations(),
    {
      provide: HttpClient,
      useValue: {
        get: (url: string) => of([true]).pipe(delay(125)),
      },
    },
  ],
}).createComponent(RequestInfoComponent);

Et voila, that's much better!

A set method would override the complete imports, providers, etc. properties.

Please note that I highly recommend that you use ng-mocks. It is way more comfortable to mock Components, Pipes, and Directives with it.

2. inject()

With the introduction of the inject function in Angular 14, it became quite clear that we ought to use it over the constructor. To give you some reasons:

  • In contrast to the constructor, the inject function works without parameter decorators. Except for the parameter decorators, TC39 finished the standardization of decorators in general. All of them have to be final to make the full switch from experimental to standardized parameters. Thus, the DI via constructor causes potential risks for future breaking changes.
  • The type of inject is also available during runtime. With the constructor, only the variable name survives the compilation. So, the TypeScript compiler has to add certain metadata where type information is part of the bundle.
  • In terms of inheritance, it is also a little bit easier.
  • Injection-Tokens are type-safe.
  • Some functional-based approaches (like the NgRx Store) that don't have a constructor can only work with inject.

At the time of this writing, things shifted a little bit. According to Alex Rickabaugh, property decorators are in Stage 1 regarding standardization. There are also some drawbacks with inject, especially with testing. That's why he recommends using whatever fits best and waiting for the results of TC39.

There is a video where I show how to instantiate a component manually and not use the TestBed. The conclusion at the end is, though, to go with the TestBed. Manual instantiation with inject is simply not worth it.

After a bit of context, let's get to it.

First, whenever we are dealing with a Service/@Injectable, we can call TestBed.instantiate from everywhere in our test. At the beginning, in the middle, or even at the end.

We have the following service, which we want to test:

@Injectable({ providedIn: "root" })
export class AddressAsyncValidator {
  #httpClient = inject(HttpClient);

  validate(ac: AbstractControl<string>): Observable<ValidationErrors | null> {
    return this.#httpClient
      .get<unknown[]>("https://nominatim.openstreetmap.org/search.php", {
        params: new HttpParams().set("format", "jsonv2").set("q", ac.value),
      })
      .pipe(
        map((addresses) =>
          addresses.length > 0 ? null : { address: "invalid" }
        )
      );
  }
}

AddressAsyncValidatorinjects the HttpClient. So we have to mock that one. There is no need to import or create any component in our TestingModule. It is just "logic testing" - no UI:

describe("AddressAsyncValidator", () => {
  it("should validate an invalid address", waitForAsync(async () => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: HttpClient,
          useValue: { get: () => of([]).pipe(delay(0)) },
        },
      ],
    });

    const validator = TestBed.inject(AddressAsyncValidator);
    const isValid = await lastValueFrom(
      validator.validate({ value: "Domgasse 5" } as AbstractControl)
    );
    expect(isValid).toEqual({ address: "invalid" });
  }));
});

That test will run. Two remarks.

First, if the {providedIn: 'root'} is missing in AddressAsyncValidator(only @Injectable is available), we can very easily provide the service in our test.

That would be:

@Injectable()
export class AddressAsyncValidator {
  // ...
}

describe("AddressAsyncValidator", () => {
  it("should validate an invalid address", waitForAsync(async () => {
    TestBed.configureTestingModule({
      providers: [
        AddressAsyncValidator,
        {
          provide: HttpClient,
          useValue: { get: () => of([]).pipe(delay(0)) },
        },
      ],
    });

    // rest of the test
  }));
});

Second, what you cannot do is to run inject inside the test. That will fail with the familiar error message: NG0203: inject() must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with runInInjectionContext.

describe("AddressAsyncValidator", () => {
  it("should validate an invalid address", waitForAsync(async () => {
    TestBed.configureTestingModule({
      providers: [
        AddressAsyncValidator,
        {
          provide: HttpClient,
          useValue: { get: () => of([]).pipe(delay(0)) },
        },
      ],
    });

    const validator = inject(AddressAsyncValidator); // not good
  }));
});

Now, would we ever want to do that? Well, whenever we have a function that runs in the injection context, we have no other chance. In the Angular framework, that could be an HttpInterceptorFnor one of the router guards, like CanActivateFn.

Especially in the Angular community, we currently see quite a lot of experiments with patterns that are function-based. A good start might be https://github.com/nxtensions/nxtensions

We will stick to native features, though and test a CanActivateFn:

export const apiCheckGuard: CanActivateFn = (route, state) => {
  const httpClient = inject(HttpClient);

  return httpClient.get("/holiday").pipe(map(() => true));
};

That is a simple function that verifies if a request to the url "/holiday" succeeds, and it makes use of the inject function.

What do we do if we want to test the function in isolation?

A test could look like this:

it("should return true", waitForAsync(async () => {
  TestBed.configureTestingModule({
    providers: [
      { provide: HttpClient, useValue: { get: () => of(true).pipe(delay(1)) } },
    ],
  });

  expect(await lastValueFrom(apiCheckGuard())).toBe(true);
}));

That will also not work. We get the NG0203 error again.

There is a solution, though. For those cases only, we can make use TestBed.runInInjectionContext:

describe("Api Check Guard", () => {
  it("should return true", waitForAsync(async () => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: HttpClient,
          useValue: { get: () => of(true).pipe(delay(1)) },
        },
      ],
    });

    await TestBed.runInInjectionContext(async () => {
      const value$ = apiCheckGuard();
      expect(await lastValueFrom(value$)).toBe(true);
    });
  }));
});

Please note that the call to inject needs to happen synchronously. In our guard, that's the case.

Our examples give us the illusion that the injection context also exists for await. That's not the case, but it doesn't matter. As seen as the request is out, the inject already did its job.

3. Router Parameter Binding with RouterTestingHarness

Writing a test where the routing plays a role and where we don't want to mock it completely was always hard. Especially because the official documentation about the RouterTestingModule or provideLocationMock was not the best.

Some community projects, especially Spectacular from Lars Nielsen, have helped us in the meantime.

In Angular 16.2, we could fetch router parameters via the @Input. With 17.1, we have Signal Inputs as an alternative to the input function.

Still, the question persists: How do you test it without mocking too much?

The answer has already been available since Angular 15.2. It introduced the RouterTestingHarness. It makes testing with minimal mocking a breeze.

This is the component we want to test:

@Component({
  selector: "app-detail",
  template: `<p>Current Id: {{ id() }}</p>`,
  standalone: true,
})
export class DetailComponent {
  id = signal(0);

  constructor() {
    inject(ActivatedRoute)
      .paramMap.pipe(takeUntilDestroyed())
      .subscribe((paramMap) => this.id.set(Number(paramMap.get("id") || "0")));
  }
}

The RouterTestingHarness replaces the common TestBed::createComponent pattern. It creates the component but wraps it inside a "testing routing context".

Harnesses are quite popular in Angular Material. They are testing helper classes that make testing very convenient by managing parts of asynchronous execution and triggering the change detection.

There is one requirement, though. Running Harness commands are usually asynchronous. So we always end up with async/await tests.

For every routing, we require a configuration. It is the same here:

describe("Detail Component", () => {
  it("should test verify the id is 5", waitForAsync(async () => {
    TestBed.configureTestingModule({
      providers: [
        provideRouter([{ path: "detail/:id", component: DetailComponent }]),
      ],
    });
  }));
});

Next, we instantiate the RouterTestingHarness and use it to navigate to "/detail/5".

const harness = await RouterTestingHarness.create("detail/5");

Internally, our component's subscription to the route runs, and we should already see that the id in the template shows the value 5:

const p: HTMLParagraphElement = harness.fixture.debugElement.query(
  By.css("p")
).nativeElement;

expect(p.textContent).toBe("Current Id: 5");

We can now even continue our test. For example, we might want to stay at the same route but want to switch to a different id.

No problem with the RouterTestingHarness. For the sake of completeness, here's the full code of the test:

describe("Detail Component", () => {
  it("should test verify the id is 5", waitForAsync(async () => {
    TestBed.configureTestingModule({
      providers: [
        provideRouter([{ path: "detail/:id", component: DetailComponent }]),
      ],
    });

    const harness = await RouterTestingHarness.create("detail/5");

    const p: HTMLParagraphElement = harness.fixture.debugElement.query(
      By.css("p")
    ).nativeElement;

    expect(p.textContent).toBe("Current Id: 5");

    await harness.navigateByUrl("detail/6");
    expect(p.textContent).toBe("Current Id: 6");
  }));
});

Please note that we didn't have to trigger the change detection or do anything else when we switched to "/detail/6". The Harness did everything for us internally. The only thing which we must not forget is to use the await.

Testing with the RouterTestingHarness is much better than mocking the ActivatedRouter.

Whenever you make functions outside of your control, like ActivatedRouter, you can never be sure that your mocking behaves exactly as the original.

The original runs some asynchronous tasks; maybe it misses triggering the change detection or something else. Quite a lot of traps you might fall into.

Better be safe and leave the internal functions to the internal functions 😀.