Testing Angular Standalone Components

New Standalone APIs provide mocks for test automation.

  1. Angular’s Future Without NgModules – Part 1: Lightweight Solutions Using Standalone Components
  2. Angular’s Future Without NgModules – Part 2: What Does That Mean for Our Architecture?
  3. 4 Ways to Prepare for Angular’s Upcoming Standalone Components
  4. Routing and Lazy Loading with Angular’s Standalone Components
  5. Angular Elements: Web Components with Standalone Components
  6. The Refurbished HttpClient in Angular 15 – Standalone APIs and Functional Interceptors
  7. Testing Angular Standalone Components
  8. Automatic Migration to Standalone Components in 3 Steps

With Standalone Components, Angular becomes a lot more lightweight: NgModules are optional and hence we can work with lesser indirections. To make this possible, components now refer directly to their dependencies: other components, but also directives and pipes. There are so-called Standalone APIs for configuring services such as the HttpClient.

Additional Standalone APIs provide mocks for test automation. Here, I'm going to present these APIs. For this, I focus on on-board tools supplied with Angular. The 📂 examples used can be found here

If you don't want to be go with the on-board resources alone, you will find the same examples based on the new Cypress Component Test Runner and on Testing Library in the third-party-testing branch.

Big thanks to my colleague and Angular testing expert Rainer Hahnekamp who reviewed this article and also provided the mentioned examples using Cypress and the Testing Library.

Test Setup

Even though Standalone Components make modules optional, the TestBed still comes with a testing module. It takes care of the test setup and provides all components, directives, pipes, and services for the test:

import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } 
    from '@angular/common/http/testing';

[…]

describe('FlightSearchComponent', () => {
  let component: FlightSearchComponent;
  let fixture: ComponentFixture<FlightSearchComponent>;
  beforeEach(async () => {

    await TestBed.configureTestingModule({
      imports: [ FlightSearchComponent ],
      providers: [
        provideHttpClient(),
        provideHttpClientTesting(),

        provideRouter([]),

        provideStore(),
        provideState(bookingFeature),
        provideEffects(BookingEffects),
      ],
    })
    .compileComponents();

    fixture = TestBed.createComponent(FlightSearchComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should search for flights', () => { […] });
});

The example shown imports the Standalone Component to be tested and provides the required services via the providers array. This is exactly where the mentioned Standalone APIs come into play. They provide the services for the HttpClient, the router and NGRX.

The provideStore function sets up the NGRX store, provideState provides a feature slice required for the test and provideEffects registers an associated effect. Below we will swap out these constructs for mocks.

The provideHttpClientTesting method is interesting: it overrides the HttpBackend used behind the scenes by the HttpClient with an HttpTestingBackend that simulates HTTP calls. It should be noted that it must be called after (!) provideHttpClient.

It is therefore first necessary to set up the HttpClient by default in order to then overwrite individual details for testing. This is a pattern we will see again below when testing the router.

The HttpClient Mock

Once the HttpClient and HttpTestingBackend have been set up, the individual tests are implemented as usual: The test uses the HttpTestingController to find out about pending HTTP requests and to specify the HTTP responses to be simulated:

it('should search for flights', () => {
  component.from = 'Paris';
  component.to = 'London';
  component.search();

  const ctrl = TestBed.inject(HttpTestingController);

  const req = ctrl.expectOne('https://[…]/flight?from=Paris&to=London');
  req.flush([{}, {}, {}]); // return 3 empty objects as dummy flights

  component.flights$.subscribe(flights => {
    expect(flights.length).toBe(3);
  });

  ctrl.verify();
});

The test then checks whether the component processed the simulated HTTP response as intended. In the case shown, the test assumes that the component offers the received flights via its flights property.

At the end, the test ensures that there are no further HTTP requests that have not yet been answered. To do this, it calls the verify method provided by the HttpTestingController. If there are still open requests at this point, verify throws an exception that causes the test to fail.

Shallow Testing

If you test a component, all sub-components, directives, and pipes used in the template are automatically tested as well. This is undesirable, especially for unit tests that focus on a single code unit. Also, this behavior slows down test execution when there are many dependencies.

Shallow tests are used to prevent this. This means that the test setup replaces all dependencies with mocks. These mocks must have the same interface as the replaced dependencies. In the case of components, this means -- among other things -- that the same properties and events (inputs and outputs) must be offered, but also that the same selectors must be used.

The TestBed offers the overrideComponent method for exchanging these dependencies:

await TestBed.configureTestingModule([…])
  .overrideComponent(FlightSearchComponent, {
    remove: { imports: [ FlightCardComponent ] },
    add: { imports: [ FlightCardMock ] }
  })
  .compileComponents();

In the case shown, the FlightSearchComponent uses another Standalone Component in its template: the FlightCardComponent. Technically, this means that the FlightCardComponent appears in the imports array of FlightSearchComponent. For implementing a shallow Test, this entry is removed. As a replacement, the FlightCardMock is added. The remove and add methods take care of this task.

The FlightSearchComponent is thus used in the test without real dependencies. Nevertheless, the test can check whether components behave as desired. For example, the following listing checks whether the FlightSearchComponent sets up an element named flight-card for each flight found.

it('should display a flight-card for each found flight', () => {
  component.from = 'Paris';
  component.to = 'London';
  component.search();

  const ctrl = TestBed.inject(HttpTestingController);

  const req = ctrl.expectOne('https://[…]/flight?from=Paris&to=London');
  req.flush([{}, {}, {}]);

  fixture.detectChanges();

  const cards = fixture.debugElement.queryAll(By.css('flight-card'));
  expect(cards.length).toBe(3);
});

More: Professional Angular Testing Workshop (online, interactive, advanced)

Improve your software quality and make your live easier with our Professional Angular Testing Workshop!All Details (English Workshop) | All Details (German Workshop)

Mock Router and Store

The test setup used so far only simulated the HttpCient. However, there are also Standalone APIs for mocking the router and NGRX:

import { provideRouter } from '@angular/router';
import { provideLocationMocks } from '@angular/common/testing';

import { provideMockStore } from '@ngrx/store/testing';
import { provideMockActions } from '@ngrx/effects/testing';

[…]

describe('FlightSearchComponent (at router level)', () => {
  let component: FlightSearchComponent;
  let fixture: ComponentFixture<FlightSearchComponent>;
  let actions$ = new Subject<Action>();

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      providers: [
        provideHttpClient(),
        provideHttpClientTesting(),

        provideRouter([
          { path: 'flight-edit/:id', component: FlightEditComponent }
        ]),
        provideLocationMocks(),

        provideMockStore({
          initialState: {
            [BOOKING_FEATURE_KEY]: {
              flights: [{ id:1 }, { id:2 }, { id:3 }],
            },
          },
        }),

        provideMockActions(() => actions$),
      ],
      imports: [FlightSearchComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(FlightSearchComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  […]
});

As with testing the HttpClient, the test first sets up the router in the usual way. Then, it uses provideLocationMocks to override a couple of internally used services, namely Location and LocationStrategy. This procedure allows the route change to be simulated in the test cases. The MockStore which also ships with NGRX is used instead of the traditional one. It allows the entire content of the store to be freely defined. This is done either by calling provideMockStore or via its setState method. Also, provideMockActions gives us the ability to swap out the actions$ observable that NGRX effects often rely on. A test case using this setup could look like as follows:

it('routes to flight-card', fakeAsync(() => {

  const link = fixture.debugElement.query(By.css('a[class*=btn-default ]'))
  link.nativeElement.click();

  flush();
  fixture.detectChanges();

  const location = TestBed.inject(Location);
  expect(location.path()).toBe('/flight-edit/1;showDetails=false')

}));

This test assumes that the FlightSearchComponent displays one link per flight in the (mock)store. It simulates a click on the first link and checks whether the application then switches to the expected route. In order for Angular to process the simulated click and trigger the route change, the change detection must be running. Unfortunately, this is not automatically the case with tests. Instead, it is to be triggered with the detectChanges method when required. The operations involved are asynchronous. Hence, fakeAsync is used so that the we don't need to burdened ourselves with this. It allows pending micro-tasks to be processed synchronously using flush.## Testing Effects

The MockStore does not trigger reducers or effects. The former are just functions and can be tested in a straight forward way. Replacing action$ is a good way to test effects. The test setup in the last section has already taken care of that. A test based on this could now use the observable action$ to send an action to which the tested effect reacts:

it('load flights', () => {
  const effects = TestBed.inject(BookingEffects);
  let flights: Flight[] = [];

  effects.loadFlights$.subscribe(action => {
    flights = action.flights; // Action returned from Effect
  });

  actions$.next(loadFlights({ from: 'Paris', to: 'London' }));
    // Action sent to store to invoke Effect

  const ctrl = TestBed.inject(HttpTestingController);
  const req = ctrl.expectOne('https://[…]/flight?from=Paris&to=London');
  req.flush([{}, {}, {}]);

  expect(flights.length).toBe(3);
});

In the case under consideration, the effect triggers an HTTP call answered by the HttpTestingController. The response contains three flights, represented by three empty objects for the sake of simplicity. Finally, the test checks whether the effect provided these flights via the outbound action. ## Summary

More and more libraries offer Standalone APIs for mocking dependencies. These either provide a mock implementation or at least overwrite services in the actual implementation to increase testability. The TestingModule is still used to provide the test setup. Unlike before, however, it now imports the standalone components, directives, and pipes to be tested. Their classic counterparts, on the other hand, were declared. In addition, the TestingModule now includes providers setup by Standalone APIs. ## More on Standalone Components?

Learn all about Standalone Components in our free eBook: - The mental model behind Standalone Components

  • Migration scenarios and compatibility with existing code
  • Standalone Components and the router and lazy loading
  • Standalone Components and Web Components
  • Standalone Components and DI and NGRX

Please find our eBook here: freeFeel free to download it here now!