Setting Up the Test Environment

Although Standalone Components eliminate the necessity of NgModules, the TestBed continues to rely on a testing module. This module handles the test configuration and supplies all components, directives, pipes, and services required during testing:

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 provided example imports the Standalone Component under test and supplies the needed services through the providers array. This is precisely where the Standalone APIs become relevant. They offer the configuration functions for the HttpClient, the router, and NGRX.

The provideStore function configures the NGRX store, while provideState delivers a feature slice necessary for the test. Additionally, provideEffects wires up an associated effect. In the following sections, we will replace these configurations with mocks.

The provideHttpClientTesting function deserves special attention: it substitutes the HttpBackend, which the HttpClient uses internally, with an HttpTestingBackend that emulates HTTP requests. A crucial detail is that this function must be invoked after (!) provideHttpClient.

This means the HttpClient must first be configured in its standard form, and only then can specific settings be adjusted for testing. This approach mirrors the pattern we will encounter later when dealing with router testing.

Simulating HTTP Requests

Once the HttpClient and HttpTestingBackend are configured, individual test cases proceed in the customary manner: the test leverages the HttpTestingController to identify pending HTTP calls and to define the mock responses that should be returned:

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();
});

Subsequently, the test verifies that the component handled the simulated HTTP response correctly. In the illustrated scenario, the test expects the component to expose the received flights through its flights property.

To conclude, the test confirms that no unanswered HTTP requests remain. It achieves this by invoking the verify method available on the HttpTestingController. Should any requests still be outstanding at this stage, verify throws an exception, which in turn causes the test to fail.

Employing Shallow Testing

When testing a component, all child components, directives, and pipes referenced in its template are also exercised by default. This behavior is often undesirable, particularly for unit tests aiming to isolate a single piece of code. Moreover, it can slow down test execution when numerous dependencies are involved.

Shallow tests address this concern. In this approach, the test configuration replaces all dependencies with mocks. These mocks must share the same interface as the dependencies they replace. For components, this entails offering identical properties and events (inputs and outputs), as well as using the same selectors.

To swap out these dependencies, the TestBed provides the overrideComponent method:

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

In the example shown, the FlightSearchComponent relies on another Standalone Component within its template: the FlightCardComponent. Technically, this means the FlightCardComponent is listed in the imports array of FlightSearchComponent. For shallow testing, this entry is eliminated. In its place, the FlightCardMock is registered. The remove and add methods handle these operations.

As a result, the FlightSearchComponent is tested without its real dependencies. Nevertheless, the test can still confirm that the component behaves as expected. For instance, the following code verifies that the FlightSearchComponent creates an element called flight-card for each flight retrieved.

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);
});

Further Learning: Advanced Angular Testing Workshop (online, interactive)

Enhance your code quality and simplify your workflow with our Professional Angular Testing Workshop!Testing Angular Standalone Components — figure 1Sign Up (English Workshop) | Sign Up (German Workshop)

Creating Mocks for Router and Store

Up to this point, the test setup has only simulated the HttpClient. However, Standalone APIs also exist 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();
  });

  […]
});

Mirroring the HttpClient testing strategy, the test first configures the router in its standard manner. Following that, it employs provideLocationMocks to replace several internally used services, specifically Location and LocationStrategy. This technique enables route changes to be simulated within test cases. The MockStore, which ships alongside NGRX, is used in lieu of the conventional store. It permits the store's entire state to be defined at will. This is accomplished either through the provideMockStore function or via its setState method. Furthermore, provideMockActions offers the capability to substitute the actions$ observable, which NGRX effects frequently depend upon. A test case leveraging this arrangement might appear 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 presupposes that the FlightSearchComponent renders one link per flight from the (mock)store. It simulates a click on the first link and then checks whether the application navigates to the expected route. For Angular to process the simulated click and trigger the route change, change detection must be active. Regrettably, this is not automatic during tests. Instead, it needs to be triggered with the detectChanges method when necessary. The involved operations are asynchronous. Consequently, fakeAsync is employed to avoid having to handle this manually. It permits pending micro-tasks to be processed synchronously using flush.

Testing Effects

The MockStore does not automatically trigger reducers or effects. Reducers, being pure functions, can be tested straightforwardly. For testing effects, replacing the actions$ stream is a practical approach. The test configuration from the prior section has already set this up. A test built upon this foundation could then utilize the actions$ observable to dispatch an action to which the effect under test responds:

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 this scenario, the effect initiates an HTTP request that is answered by the HttpTestingController. The response consists of three flights, represented here by three empty objects for simplicity. Finally, the test verifies that the effect delivered these flights through the outgoing action.

Concluding Remarks

A growing number of libraries are offering Standalone APIs for mocking dependencies. These APIs either supply a mock implementation or at minimum override services within the actual implementation to enhance testability. The TestingModule continues to serve as the foundation for the test setup. However, a notable shift has occurred: instead of declaring the components, directives, and pipes under test, they are now imported directly. Moreover, the TestingModule now incorporates providers established by Standalone APIs.

Diving Deeper into Standalone Components

Gain comprehensive knowledge about Standalone Components with our complimentary eBook:

  • The conceptual framework behind Standalone Components
  • Migration strategies and compatibility with current codebases
  • Standalone Components in conjunction with the router and lazy loading
  • Standalone Components and their interaction with Web Components
  • Standalone Components and their relationship with DI and NGRX

Access our eBook right here: free Don't hesitate to grab your copy now!