An Angular Testing Cheatsheet

lysofdev

Esteban Hernández

Posted on October 22, 2018

An Angular Testing Cheatsheet

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

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 } ) );
    ...
}
Enter fullscreen mode Exit fullscreen mode

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 } ) );
}
Enter fullscreen mode Exit fullscreen mode

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 } ) );
}
Enter fullscreen mode Exit fullscreen mode

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

} );
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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

} ) ) );
Enter fullscreen mode Exit fullscreen mode

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

} ) ) );
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Case 4: Mock a response for an existing method.

const obj = { mustBeTrue: () => false };
spyOn( obj, 'mustBeTrue' ).and.returnValue( true );
expect( obj.mustBeTrue() ).toBe( true );
Enter fullscreen mode Exit fullscreen mode

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 );
Enter fullscreen mode Exit fullscreen mode

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 );
Enter fullscreen mode Exit fullscreen mode

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 );
Enter fullscreen mode Exit fullscreen mode

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' );
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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

} );
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

Child Class

class ContactsComponent extends ResourceComponent {

    getAvailableContacts(): Contact[] {
        return this.getAllResources( 'contacts' )
            .filter( ( contact: Contact ) => contact.available );
    }

}
Enter fullscreen mode Exit fullscreen mode

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

} );
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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

    } );

} ) );

Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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

} );
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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

} );
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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

} ) );
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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

} ) );
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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 );
    } ) )
);
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
lysofdev
Esteban Hernández

Posted on October 22, 2018

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

An Angular Testing Cheatsheet
angular An Angular Testing Cheatsheet

October 22, 2018