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: Feel free to download it here now!