An Angular Testing Cheatsheet
Esteban Hernández
Posted on October 22, 2018
Personal note
This is a redacted version of an internal document I prepared for a client. It is based off the most recent revision and is not identical to the client's version.
Angular Unit Testing Cheat Sheet
The following is a quick reference to code examples of common Angular testing scenarios and some tips to improve our testing practices. Remember to test first!
Testing Scenarios
- Isolating Logic
- Async Behavior
- Spies and Mocks
- User Input Events
- Inherited Functionality
- Application Events
- Services
- Input Variables
- Output Variables
- Life Cycle Methods
- Mock Method Chains
- HTTP Calls
Isolating Logic
Use helper functions to encapsulate logic from the rest of the application. Avoid placing logic within life cycle methods and other hooks. Avoid referencing the component's state from within a helper method despite it being available. This will make it easier to test in isolation.
Bad
ngOnInit() {
...
this.clientPhoneNumbers = this.allClients
.filter( ( client: Client ) => client.phone !== undefined && client.phone !== null )
.map( ( client: Client ) => ( { name: client.name, phone: client.phone } ) );
...
}
The above code example is hard to test. We have provide and/or mock every dependency of every operation within the ngOnInit
method to test just three lines of code.
Better
ngOnInit() {
...
this.collectClientPhoneNumbers();
...
}
collectClientPhoneNumbers() {
this.clientPhoneNumbers = this.allClients
.filter( ( client: Client ) => client.phone !== undefined && client.phone !== null )
.map( ( client: Client ) => ( { name: client.name, phone: client.phone } ) );
}
In our improved example, we no longer need to ensure that all other operations in ngOnInit
are successful since we are only testing the collectClientPhoneNumbers
method. However, we still have to mock or provide the component's state for the allClients field.
Best
ngOnInit() {
...
this.clientPhoneNumbers = this.collectClientPhoneNumbers( this.allClients );
...
}
collectClientPhoneNumbers( clients: Client[] ): Object[] {
return clients
.filter( ( client: Client ) => client.phone !== undefined && client.phone !== null )
.map( ( client: Client ) => ( { name: client.name, phone: client.phone } ) );
}
In our best implementation, the logic is completely independent of the component's state. We don't need to mock anything if our component compiles, just provide vanilla JS input.
Test Example
it( 'When collectClientPhoneNumbers receives a list of Clients, then it should return a list of phone numbers', () => {
// GIVEN - Load test data and define expected results.
const clients = loadFromMockData('valid-clients');
const firstClientPhoneNumber = { name: client[0].name, phone: client[0].number };
const clientsWithPhoneNumbers = clients.filter( c => client.phone !== undefined && client.phone !== null );
// WHEN - Perform the operation and capture results.
const filteredClients = component.collectClientPhoneNumbers( clients );
// THEN - Compare results with expected values.
expect( filteredClients.length ).toEqual( clientsWithPhoneNumbers.length );
expect( filteredClients[0] ).toEqual( firstClientPhoneNumber );
} );
Async Behavior
The Angular Testing module provides two utilities for testing asynchronous operations.
Notes on Async Testing Tools
- async: The test will wait until all asynchronous behavior has resolved before finishing. Best to test simple async behavior that shouldn't block for much time. Avoid using with async behavior that could hang or last a long time before resolving.
- fakeAsync: The test will intercept async behavior and perform it synchronously. Best for testing chains of async behavior or unreliable async behavior that might hang or take a long time to resolve.
- tick: Simulate the passage of time in a fakeAsync test. Expects a numeric argument representing elapsed time in milliseconds.
- flushMicrotasks: Force the completion of all pending microtasks such as Promises and Observables.
- flush: Force the completion of all pending macrotasks such as setInterval, setTimeout, etc. #### Code to Test
class SlowService {
names: BehaviorSubject<string[]> = new BehaviorSubject<>( [] );
getNames(): Observable<string[]> {
return this.names;
}
updateNames( names: string[] ) {
setTimeout( () => this.names.next( names ), 3000 );
}
}
class SlowComponent implements OnInit {
names: string[];
constructor( private slowService: SlowService ) {}
ngOnInit() {
this.slowService.getNames().subscribe( ( names: string[] ) => {
this.names = names;
} );
}
}
Test Example async()
it( 'When updatedNames is passed a list of names, Then the subscription will update with a list of names', async(
inject( [SlowService], ( slowService ) => {
// GIVEN - Create test data, initialize component and assert component's initial state
const names = [ "Bob", "Mark" ];
component.ngOnInit();
fixture.whenStable()
.then( () => {
expect( component.names ).toBeDefined();
expect( component.names.length ).toEqual( 0 );
// WHEN - Simulate an update in the service's state and wait for asynchronous operations to complete
slowService.updateNames( names );
return fixture.whenStable();
} )
.then( () => {
// THEN - Assert changes in component's state
expect( component.names.length ).toEqual( 2 );
expect( component.names ).toEqual( names );
} );
} ) ) );
TestExample fakeAsync()
, tick()
, flush()
, flushMicrotasks()
it( 'When updatedNames is passed a list of names, Then the subscription will update with a list of names', fakeAsync(
inject( [SlowService], ( slowService ) => {
// GIVEN - Create test data, initialize component and assert component's initial state
const names = [ "Bob", "Mark" ];
component.ngOnInit();
flushMicrotasks();
expect( component.names ).toBeDefined();
expect( component.names.length ).toEqual( 0 );
// WHEN - Simulate an update in the service's state and wait for asynchronous operations to complete
slowService.updateNames( names );
tick( 3001 );
// THEN - Assert changes in component's state
expect( component.names.length ).toEqual( 2 );
expect( component.names ).toEqual( names );
} ) ) );
Spies and Mocks
Spying on functions allows us to validate that interactions between components are ocurring under the right conditions. We use mock objects to reduce the amount of code that is being tested. Jasmine provides the spyOn()
function which let's us manage spies and mocks.
Case 1: Assert that a method was called.
const obj = { method: () => null };
spyOn( obj, 'method' );
obj.method();
expect( obj.method ).toHaveBeenCalled();
Warning: Spying on a method will prevent the method from actually being executed.
Case 2: Assert that a method was called and execute method.
const obj = { getName: () => 'Sam' };
spyOn( obj, 'getName' ).and.callThrough();
expect( obj.getName() ).toEqual( 'Sam' );
expect( obj.getName ).toHaveBeenCalled();
Case 3: Assert that a method was called and execute a function.
const obj = { getName: () => 'Sam' };
spyOn( obj, 'getName' ).and.callFake((args) => console.log(args));
expect( obj.getName() ).toEqual( 'Sam' );
expect( obj.getName ).toHaveBeenCalled();
Case 4: Mock a response for an existing method.
const obj = { mustBeTrue: () => false };
spyOn( obj, 'mustBeTrue' ).and.returnValue( true );
expect( obj.mustBeTrue() ).toBe( true );
Case 5: Mock several responses for an existing method.
const iterator = { next: () => null };
spyOn( iterator, 'next' ).and.returnValues( 1, 2 );
expect( iterator.next ).toEqual( 1 );
expect( iterator.next ).toEqual( 2 );
Case 6: Assert that a method was called more than once.
const obj = { method: () => null };
spyOn( obj, 'method' );
for ( let i = 0; i < 3; i++ {
obj.method();
}
expect( obj.method ).toHaveBeenCalledTimes( 3 );
Case 7: Assert that a method was called with arguments
const calculator = { add: ( x: number, y: number ) => x + y };
spyOn( calculator, 'add' ).and.callThrough();
expect( calculator.add( 3, 4 ) ).toEqual( 7 );
expect( calculator.add ).toHaveBeenCalledWith( 3, 4 );
Case 8: Assert that a method was called with arguments several times
const ids = [ 'ABC123', 'DEF456' ];
const db = { store: ( id: string) => void };
spyOn( db, 'store' );
ids.forEach( ( id: string ) => db.store( id ) );
expect( db.store ).toHaveBeenCalledWith( 'ABC123' );
expect( db.store ).toHaveBeenCalledWith( 'DEF456' );
User Input Events
We can simulate user input without having to interact with the DOM by simulating events on the DebugElement
. The DebugElement
is a browser-agnostic rendering of the Angular Component as an HTMLElement
. This means we can test elements without a browser to render the actual HTML.
Component to Test
@Component({
selector: 'simple-button',
template: `
<div class="unnecessary-container">
<button (click)="increment()">Click Me!</button>
</div>
`
})
class SimpleButtonComponent {
clickCounter: number = 0;
increment() {
this.clickCounter += 1;
}
}
Test Example
it( 'When the button is clicked, then click counter should increment', () => {
// GIVEN - Capture reference to DebugElement not NativeElement and verify initial state
const buttonDE = fixture.debugElement.find( By.css( 'button' ) );
expect( component.clickCounter ).toEqual( 0 );
// WHEN - Simulate the user input event and detect changes.
buttonDE.triggerEventHandler( 'click', {} );
fixture.detectChanges();
// THEN - Assert change in component's state
expect( component.clickCounter ).toEqual( 1 );
} );
Inherited Functionality
We shouldn't test a parent class's functionality in it's inheriting children. Instead, this inherited functionality should be mocked.
Parent Class
class ResourceComponent {
protected getAllResources( resourceName ): Resource[] {
return this.externalSource.get( resourceName );
}
}
Child Class
class ContactsComponent extends ResourceComponent {
getAvailableContacts(): Contact[] {
return this.getAllResources( 'contacts' )
.filter( ( contact: Contact ) => contact.available );
}
}
Test Example
it( 'When the getAvailableContacts method is called, Then it should return contacts where available is true', () => {
// GIVEN - Intercept call to inherited method and return a mocked response.
spyOn( component, 'getAllResources' ).and.returnValue( [
{ id: 1, name: 'Charles McGill', available: false },
{ id: 2, name: 'Tom Tso', available: true },
{ id: 3, name: 'Ruben Blades', available: true }
] );
// WHEN - Perform operation on inheriting class
const contacts = component.getAvailableContacts();
// THEN - Assert that interaction between inherited and inheriting is correctly applied.
expect( component.getAllResources ).toHaveBeenCalledWith( 'contacts' );
expect( contacts.length ).toEqual( 2 );
expect( contacts.any( c => name === 'Charles McGill' ) ).toBe( false );
} );
Services
Service objects are tested with the inject()
function. TestBed
will inject a new instance of the service object for each test. Use the async()
function when testing asynchronous behavior such as Observables and Promises. Use of()
to mock observables.
Code to Test
class NameService {
constructor( private cache: CacheService ) {}
getNames(): Observable<string[]> {
return this.cache.get( 'names' );
}
}
Test Example
it( 'When getNames is called Then return an observable list of strings', async(
inject( [CacheService, NameService], ( cache, nameService ) => {
// GIVEN - Mock service dependencies with expected value
const testNames = ["Raul", "Fareed", "Mark"];
spyOn( cache, 'get' ).and.returnValue( of( testNames ) );
// WHEN - Subscribe to observable returned by service method
nameService.getNames().subscribe( ( names: string[] ) => {
// THEN - Assert result matches expected value
expect( names ).toMatch( testNames );
} );
} ) );
Input Variables
As of Angular 5, Component inputs behave just like normal properties. We can test changes using the fixture change detection.
Code to Test
class CounterComponent implements OnChanges {
@Input() value: string;
changeCounter: number = 0;
ngOnChanges() {
changeCounter++;
}
}
Test Example
it( 'When the value input is changed, the changeCounter incrementsByOne', () => {
// GIVEN - Spy on the ngOnChanges lifecycle method and assert initial state.
spyOn( component, 'ngOnChanges' );
expect( component.value ).toBeUndefined();
expect( component.changeCouner ).toEqual( 0 );
// WHEN - Set the input variable and call on fixture to detect changes.
component.value = 'First Value';
fixture.detectChanges();
// THEN - Assert that lifecycle method was called and state has been updated.
expect( component.ngOnChanges ).toHaveBeenCalled();
expect( component.changeCounter ).toEqual( 1 );
} );
Output Variables
Components often expose event emitters as output variables. We can spy on these emitters directly to avoid having to test asynchronous subscriptions.
Code to Test
class EmittingComponent {
@Output() valueUpdated: EventEmitter<string> = new EventEmitter<>();
updateValue( value: string ) {
this.valueUpdated.emit( value );
}
}
Test Example
it( 'When the updateValue() method is called with a string, Then the valueUpdated output will emit with the string', () => {
// GIVEN - Create a test argument and spy on the emitting output variable.
const value = 'Test Value';
spyOn( component.valueUpdated, 'emit' );
// WHEN - Call a method that will trigger the output variable to emit.
component.updateValue( value );
// THEN - Assert that the output variable has emitted correctly with the test argument.
expect( component.valueUpdated.emit ).toHaveBeenCalledWith( value );
} );
Application Events
Testing event fired by a global object or parent component can be done by simulating the event dispatch in a fakeAsync environment. We can use the flush()
function to resolve all pending, asynchronous operations in a synchronous manner.
Code to Test
class ListeningComponent {
focus: string;
@HostListener( 'window:focus-on-dashboard', ['$event'] )
onFocusOnDashboard() {
this.focus = 'dashboard';
}
}
Test Example
it( 'When the window dispatches a focus-on-dashboard event, Then the focus is set to dashboard', fakeAsync( () => {
// GIVEN - Prepare spy for callback and validate initial state.
spyOn( component, 'onFocusOnDashboard' );
expect( component.focus ).not.toEqual( 'dashboard' );
// WHEN - Dispatch the event, resolve all pending, asynchronous operations and call fixture to detect changes.
window.dispatchEvent( new Event( 'focus-on-dashboard' ) );
flush();
fixture.detectChanges();
// THEN - Assert that callback was called and state has changed correctly.
expect( component.onFocusOnDashboard ).toHaveBeenCalled();
expect( component.focus ).toEqual( 'dashboard' );
} ) );
Life Cycle Methods
There is no real reason to test a life cycle method. This would be testing the framework, which is beyond our responsability. Any logic required by a life cycle method should be encapsulated in a helper method. Test that instead. See Async Behavior for tests that require calling the ngOnInit()
life cycle method.
Mock Method Chains
We may occassionally need to mock a series of method calls in the form of a method chain. This can be achieved using the spyOn
function.
Code to Test
class DatabseService {
db: DatabaseAdapter;
getAdultUsers(): User[] {
return this.db.get( 'users' ).filter( 'age > 17' ).sort( 'age', 'DESC' );
}
}
Test Example
it( 'When getAdultUsers is called, Then return users above 17 years of age', inject([DatabaseService], ( databaseService ) => {
// GIVEN - Mock the database adapter object and the chained methods
const testUsers = [
{ id: 1, name: 'Bob Odenkirk' },
{ id: 2, name: 'Ralph Morty' }
];
const db = { get: () => {}, filter: () => {}, sort: () => {} };
spyOn( db, 'get' ).and.returnValue( db );
spyOn( db, 'filter' ).and.returnValue( db );
spyOn( db, 'sort' ).and.returnValue( testUsers );
databaseService.db = db;
// WHEN - Test the method call
const users = databaseService.getAdultUsers();
// THEN - Test interaction with method chain
expect( db.get ).toHaveBeenCalledWith( 'users' );
expect( db.filter ).toHaveBeenCalledWith( 'age > 17' );
expect( db.sort ).toHaveBeenCalledWith( 'age', 'DESC' );
expect( users ).toEqual( testUsers );
} ) );
HTTP Calls
Angular provides several utilities for intercepting and mocking http calls in the test suite. We should never perform a real, http call during tests. A few important objects:
- XHRBackend: Intercepts requests performed by HTTP or HTTPClient.
- MockBackend: Test API for configuring how XHRBackend will interact with intercepted requests.
- MockConnection: Test API for configuring individual, intercepted requests and response.
Code to Test
class SearchService {
private url: string = 'http://localhost:3000/search?query=';
constructor( private http: Http ) {}
search( query: string ): Observable<string[]> {
return this.http.get( this.url + query, { withCredentials: true } ).pipe(
catchError( ( error: any ) => {
UtilService.logError( error );
return of( [] );
} )
);
}
}
Text Example
let backend: MockBackend;
let lastConnection: MockConnection;
beforeEach( () => {
TestBed.configureTestingModule( {
imports: [HttpModule],
providers: [
{ provide: XHRBackend, useClass: MockBackend },
SearchService
]
} );
backend = TestBed.get(XHRBackend) as MockBackend;
backend.connections.subscribe( ( connection: MockConnection ) => {
lastConnection = connection;
} );
} );
it( 'When a search request is sent, Then receive an array of string search results.',
fakeAsync( inject( [SearchService], ( searchService: SearchService ) => {
// GIVEN - Prepare mock search results in the form of a HTTP Response
const expectedSearchResults = [ ... ];
const mockJSON = JSON.stringify( { data: expectedSearchResults } );
const mockBody = new ResponseOptions( { body: mockJSON } );
const mockResponse = new Response( mockBody );
// WHEN - Perform the call and intercept the connection with a mock response.
let receivedSearchResults: string[];
searchService.search( 'reso' ).subscribe( ( searchResults: string[] ) => {
receivedSearchResults = searchResults;
} );
lastConnection.mockRespond( mockResponse );
// THEN - Complete the pending transaction and assert that the mock response
// was received and processed correctly.
flushMicrotasks();
expect( receivedSearchResults ).toBeDefined();
expect( receivedSearchResults ).toEqual( expectedSearchResults );
} ) )
);
Posted on October 22, 2018
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.