DEV Community

Esteban Hernández
Esteban Hernández

Posted on • Edited on

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

Top comments (16)

Collapse
 
layzee profile image
Lars Gyrup Brink Nielsen

Thanks, Esteban!

Too few resources on Angular testing even though testability is a core concern for the Angular framework.

Collapse
 
sourvinos profile image
John Sourvinos

The state of Angular Testing as of today is: Non-exinstent documentation, obsolete books that focus around the big problem if 1+1 equals 2, incomplete tutorials and posts by "professionals" who in their spare time are fathers, tech enthusiasts and pet lovers, youtubers advertising themselves as experts and mvp's that the only thing they achive is to break the 100 subscriber barrier, non-native english speakers with horrible accent who record their so called 'tutorials' by using their phone's microphone and/or with incomprehensible accents. Books are written and advertised as professional series, and they leave testing for the very end, instead of being at the very beginning after the creation of the new project. Of course, they only cover the basics such as checking if a component has been created or if a fake service returns the expected results. That's it. A trained cat can check if an h1 tag contains 'Hello world'. Not a single attempt to go deeper, as if the subject must be avoided like the plague. At the end of each day, I wonder whether to burn the .spec.ts files and do everything manually with dubious results, spend endless hours of researching in sources here and there, or to look somewhere else, maybe React, even though I have my doubts that the situation over there is any better. And above all I also have 'experts' telling me not to test the private methods. Well, it's my f***ing projects and I will test whatever I want.

Collapse
 
layzee profile image
Lars Gyrup Brink Nielsen

I would offer you a listing of my articles on testing Angular applications, but I'm afraid that I'm a father, a tech enthusiast, and a Microsoft MVP, so you might not be interested 😄

Thread Thread
 
sourvinos profile image
John Sourvinos

Yourself and Net Basal are the only ones I have discovered so far to be of any interest. As a continuation to my response, since I'm fussy, picky and perfectionist, I'm looking for the 0.5% of knowledge which the 99.5% don't seem to care about - this is the reason for my initial reply and I dedicate it to the vast majority. So, throw me anything you've got! Hope I'm making sence! Greetings from 39°38'N / 19°55'E.

Thread Thread
 
layzee profile image
Lars Gyrup Brink Nielsen • Edited
Thread Thread
 
sourvinos profile image
John Sourvinos

Lars,

Super awesome, thanks a million!

Have a great weekend!
Regards,
John

Collapse
 
sergiorom92 profile image
sergiorom92

Thanks a lot!!

Nice article! Helped me a lot!! Testing definitely makes software better, this is a great resource. Every software developer must understant its importance, it's one of best practices. Greetings from Colombia.

Collapse
 
vinaymcscet profile image
vinay kumar

I need to help on writing testcase for this functions.

private element: any;
@Input() id: string;
constructor(private el: ElementRef) {
this.element = el.nativeElement;
}
ngOnInit(): void {
if (!this.id) {
return;
}
this.element.addEventListener("click", (el: any) => {
if (el.target.className === "tfb-modal-background") {
this.close();
}
});
}

Collapse
 
vinaymcscet profile image
vinay kumar

I need to help on writing testcase for this functions.
private element: any;
@Input() id: string;

constructor(private el: ElementRef) {
this.element = el.nativeElement;
}

ngOnInit(): void {
if (!this.id) {
return;
}
this.element.addEventListener("click", (el: any) => {
if (el.target.className === "tfb-modal-background") {
this.close();
}
});
}

Collapse
 
Sloan, the sloth mascot
Comment deleted
Collapse
 
lysofdev profile image
Esteban Hernández

Hey, try separating your large function into smaller ones that can be tested in isolation.

If you have several simple pipes that depend on each other, try combining them in a custom pipe and test that with a mock observable.

So for RxJS, you can:

  • Test a simple function by using mocked inputs.
  • Test a custom pipe by using a mocked observable.
  • Test a custom observable by mocking the sources or events.

My general rule of thumb is that a function should hardly ever have more than two or three tests. When a function requires more than that, you can probably break it down into two or more.

Collapse
 
chan_austria777 profile image
chan 🤖

Nice article but i hope this gets updated with the latest api

Collapse
 
aerojeyenth profile image
Jeyenthaaran N

Thanks for this very nice article on testing!
Is it possible to use Jasmine Marbles to test services instead of mocking it using a spy?

Collapse
 
robbiesmith79 profile image
Robbie Smith

You have a typo in one of your examples: changeCouner

Collapse
 
deepachaurasia1 profile image
Deepa Chaurasia

Hey what if input variable is coming from another component where we have retrieved it from backend api service.

Do we still follow same approach as mentioned by you