Testing in Ionic 2

Test Driven Development in Ionic 2: Services and Templates



·

Throughout this tutorial series, we have been learning various methods for unit testing Ionic 2 applications and using a Test Driven Development approach to building an application. In the previous tutorials we have covered:

  • How to set up unit testing in an Ionic 2 application
  • What Test Driven Development (TDD) and Behaviour Driven Development (BDD) is
  • How to use Jasmine to create unit tests, and Karma to run them
  • How to unit test pages in Ionic 2
  • How to write tests for HTTP requests
  • How to test navigation
  • How to create test doubles that mock dependencies
  • How to use spies to track if certain functions have been called

These posts have only scratched the surface of unit testing and test-driven development, like development in general, writing tests is something that you will improve at over time. Although there is still a lot more to learn, the concepts we have covered should allow you to create good tests for most scenarios.

In this tutorial, we are going to finish off building the functionality for this application using what we have learned so far. There won’t really be any new concepts introduced in this tutorial, we will just be following the test driven development methodology of RED, GREEN, REFACTOR. That is, write a test and watch it fail (RED), write code to satisfy the test (GREEN), optionally change the code knowing that the tests you have created will ensure nothing breaks (REFACTOR).

Before We Get Started

Before you go through this tutorial, you should have at least a basic understanding of Ionic 2 concepts. You must also already have Ionic 2 installed on your machine.

If you’re not familiar with Ionic 2 already, I’d recommend reading my Ionic 2 Beginners Guide first to get up and running and understand the basic concepts. If you want a much more detailed guide for learning Ionic 2, then take a look at Building Mobile Apps with Ionic 2.

IMPORTANT: This tutorial follows on from Test Driven Development in Ionic 2: Navigation and Spies, if you want to follow along with the example you will need to have completed the previous tutorial first. If you just want to get a testing environment set up in your project, you can skip back to the first tutorial in the testing series.

1. Run the Tests

Before we get started, we are going to run the tests that we currently have to make sure that they are all passing. If we run npm test now we should see something like the following:

All Tests Passing

We will keep these tests running automatically whilst we continue to develop the application so that we know if we ever break anything.

2. Adding Products to the Wishlist

In the last tutorial, we created a wishlist page, but we don’t currently have any way of adding product to that wishlist or viewing them. We are going to implement that functionality now, and to do that we will start out with a test.

Add the following test to src/pages/product/product.spec.ts:

    it('should add product to wishlist when add to wishlist button clicked', () => {

        let wishlistService = fixture.debugElement.injector.get(WishlistService);
        spyOn(wishlistService, 'addProduct');

        let productsService = fixture.debugElement.injector.get(Products);
        let firstProduct = productsService.products[0];

        fixture.detectChanges();

        de = fixture.debugElement.query(By.css('ion-item-sliding button'));
        de.triggerEventHandler('click', null);

        expect(wishlistService.addProduct).toHaveBeenCalledWith(firstProduct);

    });

This will test that we can add a product to the wishlist by clicking on a button on the product. Remember, we are writing these tests in the way we want them to work – a lot of the stuff we are referencing here doesn’t even exist yet so of course it is going to cause issues. The WishlistService does not exist, and the CSS selector is wrong because we aren’t using a sliding ion-item yet.

Let’s look at our tests and see what errors we run into so that we can start satisfying the test.

File not found

We immediately run into an issue because we are referencing a file that does not exist yet. Let’s create the WishlistService now to fix that.

Run the following command to create the WishlistService

ionic g provider WishlistService

Add the WishlistService to the src/app/app.module.ts file:

import { NgModule, ErrorHandler } from '@angular/core';
import { IonicApp, IonicModule, IonicErrorHandler } from 'ionic-angular';
import { MyApp } from './app.component';
import { ProductPage } from '../pages/product/product';
import { WishlistPage } from '../pages/wishlist/wishlist';
import { WishlistService } from '../providers/wishlist-service';
import { Products } from '../providers/products';

@NgModule({
  declarations: [
    MyApp,
    ProductPage,
    WishlistPage
  ],
  imports: [
    IonicModule.forRoot(MyApp)
  ],
  bootstrap: [IonicApp],
  entryComponents: [
    MyApp,
    ProductPage,
    WishlistPage
  ],
  providers: [{provide: ErrorHandler, useClass: IonicErrorHandler}, Products, WishlistService]
})
export class AppModule {}

Now let’s try our tests again:

Failing Test: Missing Provider

Great, our tests are at least running now. But we get the following error:

Error: No provider for WishlistService!

We haven’t added a provider for WishlistService to our test file yet which is causing some problems. Remember that we want to keep our tests isolated, though, so we don’t want to actually add the WishlistService into our ProductPage tests. Instead, we will create a mock for it.

Modify src/mocks.ts to include the following mock:

export class WishlistServiceMock {

  public products = [
    {title: 'Test 1', description: 'Test 1', price: '39.99'},
    {title: 'Test 2', description: 'Test 2', price: '29.99'},
    {title: 'Test 3', description: 'Test 3', price: '19.99'}
  ];

  public addProduct(product: Object): void {}

  public deleteProduct(product: Object): void {}

}

This will provide a basic implementation of the WishlistService that we can use as a test double. Now we just need to add that into our ProductPage tests.

Make the following changes to src/pages/product/product.spec.ts:

import { TestBed, ComponentFixture, async } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { IonicModule, NavController } from 'ionic-angular';
import { MyApp } from '../../app/app.component';
import { ProductPage } from './product';
import { WishlistPage } from '../wishlist/wishlist';
import { WishlistService } from '../../providers/wishlist-service';
import { Products } from '../../providers/products';
import { ProductsMock, NavMock, WishlistServiceMock } from '../../mocks';

let comp: ProductPage;
let fixture: ComponentFixture<ProductPage>;
let de: DebugElement;
let el: HTMLElement;

describe('Page: Product Page', () => {

    beforeEach(async(() => {

        TestBed.configureTestingModule({

            declarations: [MyApp, ProductPage],

            providers: [
                {
                    provide: NavController,
                    useClass: NavMock
                },
                { 
                    provide: Products, 
                    useClass: ProductsMock
                },
                {
                    provide: WishlistService,
                    useClass: WishlistServiceMock
                }
            ],

            imports: [
                IonicModule.forRoot(MyApp)
            ]

        }).compileComponents();

    }));

Notice that we have imported the WishlistService and then we have added a provider for it, but instead of using the provider itself we are using the WishlistServiceMock class instead (you will also need to make sure you are importing that mock).

Time to try our tests again:

Failing Test: triggerEventHandler

The same test is failing, but we are getting a new error now:

TypeError: Cannot read property 'triggerEventHandler' of null

That’s because we are trying to select a button inside of an ion-item-sliding in the template, and then trigger the click event on it. The button does not exist, though, and neither does the sliding item, so this is failing.

Modify src/pages/product/product.html to reflect the following:

<ion-header no-border>

  <ion-navbar color="secondary">
    <ion-title>Browse</ion-title>
    <ion-buttons end>
        <button ion-button icon-only (click)="launchWishlist()"><ion-icon name="heart"></ion-icon></button>
    </ion-buttons>
  </ion-navbar>

</ion-header>

<ion-content>

    <ion-list no-lines>
        <ion-item-sliding *ngFor="let product of productsService.products">
            <ion-item>
                <h3>{{product.title}}</h3>
                <span>${{product.price}}</span>
                <p>{{product.description}}</p>
            </ion-item>
            <ion-item-options>
                <button ion-button icon-only (click)="addToWishlist(product)" color="light"><ion-icon name="heart"></ion-icon></button>
            </ion-item-options>
        </ion-item-sliding>
    </ion-list>

</ion-content>

Now we have added the button to add the product to the wishlist, and we’ve changed from using a normal ion-item to an ion-item-sliding that will reveal the button when the user slides the item.

Failing test: Missing function

We’re making some more progress, but now we are trying to call a function from the template that does not exist in the TypeScript file.

Modify src/pages/product/product.ts to reflect the following:

import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
import { Products } from '../../providers/products';
import { WishlistPage } from '../wishlist/wishlist';
import { WishlistService } from '../../providers/wishlist-service';

@Component({
  selector: 'page-product',
  templateUrl: 'product.html'
})
export class ProductPage {

  constructor(public navCtrl: NavController, public productsService: Products, public wishlistService: WishlistService) {}

  ionViewDidLoad() {
    this.productsService.load();
  }

  launchWishlist() {
    this.navCtrl.push(WishlistPage);
  }

  addToWishlist(product){
    this.wishlistService.addProduct(product);
  }

}

Now we have created the addToWishlist function which will call the addProduct function in the WishlistService.

Tests passing

Done! We have now verified that when the button is clicked that it is calling the addProduct method in the WishlistService. This doesn’t verify that the product is actually added to the wishlist (we will do that in a moment), just that the appropriate method has been called.

3. Verify that Products are Added to the Wishlist

We’ve verified that the ProductPage is able to make a call to add a product to the wishlist, now we need to make sure that the WishlistService is doing its job. To do that, we are going to create a new test file and add some tests to it.

Create a file at src/provides/wishlist-service.spec.ts and add the following:

import { TestBed, inject, async } from '@angular/core/testing';
import { WishlistService } from './wishlist-service';

describe('Provider: Wishlist Service', () => {

    let testProduct;

    beforeEach(async(() => {

        TestBed.configureTestingModule({

            declarations: [

            ],

            providers: [
                WishlistService
            ],

            imports: [

            ]

        }).compileComponents();

    }));

    beforeEach(() => {

        testProduct = {
            title: 'Test Product',
            description: 'Test Description',
            price: '39.99'
        };

    });

    it('should be able to add a single product to product array', inject([WishlistService], (wishlistService) => {

        let arrayLengthBefore = wishlistService.products.length;
        wishlistService.addProduct(testProduct);

        expect(wishlistService.products).toContain(testProduct);
        expect(wishlistService.products.length).toEqual(arrayLengthBefore + 1);

    }));

});

We have just added a single test for now which will test if we can successfully add a product to the products array for the WishlistService. This is how we want it to work, but we haven’t done any coding for the WishlistService yet, so it’s time for some more failing tests!

Failing Test: Missing Http Provider

The first error we get is:

Error: No provider for Http!

This is because the default provider created by the ionic generate command adds in the Http service by default, but we don’t have a provider for it in our test. We don’t need it, though, so we are going to remove it.

Modify src/providers/wishlist-service.ts to reflect the following:

import { Injectable } from '@angular/core';

@Injectable()
export class WishlistService {

  constructor() {
    console.log('Hello WishlistService Provider');
  }

}

Now we can move on to the real work.

Failing Test: Missing Array

Our test is referencing the products array which doesn’t exist now, so we get an error for that. We are also trying to call an addProduct method which doesn’t exist so that will cause issues as well. Let’s add those now.

import { Injectable } from '@angular/core';

@Injectable()
export class WishlistService {

    products: any[] = [];

    constructor() {

    }

    addProduct(product: Object): void {
        this.products.push(product);
    }

}

and then check our tests:

Passing Tests

Great! All tests are passing again.

4. No Duplicates and Deleting Products

Our WishlistService is working now, but it’s pretty basic. We are going to extend its functionality by creating two more tests. One that will check that no duplicate products are added to the wishlist, and one that will check that we can also delete products from the wishlist.

Modify src/providers/wishlist-service.spec.ts to include the following two tests:

    it('should not add a product to wishlist if it already exists in the wishlist', inject([WishlistService], (wishlistService) => {

        let arrayLengthBefore = wishlistService.products.length;
        wishlistService.addProduct(testProduct);

        // Array length should have increased by one
        expect(wishlistService.products.length).toEqual(arrayLengthBefore + 1);

        wishlistService.addProduct(testProduct);

        // Array length should NOT have increased by one
        expect(wishlistService.products.length).toEqual(arrayLengthBefore + 1);

    }));

    it('should remove a product from wishlist when delete function called', inject([WishlistService], (wishlistService) => {

        wishlistService.addProduct(testProduct);

        expect(wishlistService.products).toContain(testProduct);

        wishlistService.deleteProduct(testProduct);

        expect(wishlistService.products).not.toContain(testProduct);

    }));

The first test tries to add the same product twice to the WishlistService but expects that it is only added once. The second test tries to add a product to the WishlistService and expects that it is added to the products array, and then it tries to remove the same product and expects that it is not contained within the products array.

Let’s see what’s going on with our tests now.

Failing test: Expected 2 to equal 1

We are getting the following error:

Expected 2 to equal 1

This is because in our first test, the same product is getting added to the products array twice instead of just once. Let’s fix that.

Modify src/providers/wishlist-service.ts to reflect the following:

import { Injectable } from '@angular/core';

@Injectable()
export class WishlistService {

    products: any[] = [];

    constructor() {

    }

    addProduct(product: Object): void {

        if(!(this.products.indexOf(product) > -1)){
            this.products.push(product);            
        }

    }

}

Now we first check if a product exists in the array before adding it.

Failing Test: Missing function

Now we are getting an error that the deleteProduct function doesn’t exist, and rightly so, because it doesn’t. Let’s add it.

Modify src/providers/wishlist-service.ts to reflect the following:

import { Injectable } from '@angular/core';

@Injectable()
export class WishlistService {

    products: any[] = [];

    constructor() {

    }

    addProduct(product: Object): void {

        if(!(this.products.indexOf(product) > -1)){
            this.products.push(product);            
        }

    }

    deleteProduct(product: Object): void {

        let index = this.products.indexOf(product);

        if(index > -1){
            this.products.splice(index, 1);
        }

    }

}

We have our deleteProduct function now that will get the index of the product if it exists, and then remove it from the array.

Passing Tests

Now our tests are satisfied once more!

5. Display and Delete Products on the Wishlist Page

We just need to create one more set of tests now, and that is for the WishlistPage itself. We know our WishlistService works, but we don’t know whether or not the products are displaying properly on the WishlistPage.

Modify src/pages/wishlist/wishlist.spec.ts to reflect the following:

import { TestBed, ComponentFixture, async, inject } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { IonicModule, NavController } from 'ionic-angular';
import { MyApp } from '../../app/app.component';
import { WishlistPage } from './wishlist';
import { WishlistService } from '../../providers/wishlist-service';
import { NavMock, WishlistServiceMock } from '../../mocks';

let comp: WishlistPage;
let fixture: ComponentFixture<WishlistPage>;
let de: DebugElement;
let el: HTMLElement;

describe('Page: Wishlist Page', () => {

    beforeEach(async(() => {

        TestBed.configureTestingModule({

            declarations: [MyApp, WishlistPage],

            providers: [
                {
                    provide: NavController,
                    useClass: NavMock
                },
                {
                    provide: WishlistService,
                    useClass: WishlistServiceMock
                }
            ],

            imports: [
                IonicModule.forRoot(MyApp)
            ]

        }).compileComponents();

    }));

    beforeEach(() => {

        fixture = TestBed.createComponent(WishlistPage);
        comp    = fixture.componentInstance;

    });

    afterEach(() => {
        fixture.destroy();
        comp = null;
        de = null;
        el = null;
    });

    it('is created', () => {

        expect(fixture).toBeTruthy();
        expect(comp).toBeTruthy();

    });

    it('should display all products contained in wishlist', () => {

        let wishlistService = fixture.debugElement.injector.get(WishlistService);

        fixture.detectChanges();

        de = fixture.nativeElement.getElementsByTagName('h3');

        wishlistService.products.forEach((product, index) => {

            el = de[index]
            expect(el.innerHTML).toContain(product.title);

        });

    });

    it('should make a call to remove a prouct from wishlist when delete button clicked', () => {

        let wishlistService = fixture.debugElement.injector.get(WishlistService);
        spyOn(wishlistService, 'deleteProduct');

        let firstWishlistProduct = wishlistService.products[0];

        fixture.detectChanges();

        de = fixture.debugElement.query(By.css('ion-item-sliding button'));
        de.triggerEventHandler('click', null);

        expect(wishlistService.deleteProduct).toHaveBeenCalledWith(firstWishlistProduct);        

    });

});

We have created two more tests. One that will check if products are being displayed in the wishlist, and one that makes sure that we are able to delete a product by clicking a button on the wishlist page.

In the first test, we do something a little different, we actually loop over all of the products to make sure every single one is displayed in the list. Notice that we use the native getElementsByTagName method instead of Angular’s By.css method that we have been using previously. This is because the By.css method will only ever return the first match, whereas getElementsByTagName will return an array of all matches.

Notice that once again we are using our WishlistServiceMock because we want to test this page in isolation, not the WishlistService (we have already verified that the WishlistService works).

Let’s try our tests.

Failing Test: Missing Template Item

We are trying to access the innerHTML of elements that don’t exist yet because we haven’t added them to our template, so we get an error.

Modify src/pages/wishlist/wishlist.ts to reflect the following:

import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
import { WishlistService } from '../../providers/wishlist-service'

@Component({
  selector: 'page-wishlist',
  templateUrl: 'wishlist.html'
})
export class WishlistPage {

  constructor(public navCtrl: NavController, public wishlistService: WishlistService) {}

  ionViewDidLoad() {
    console.log('ionViewDidLoad WishlistPage');
  }

}

Modify src/pages/wishlist/wishlist.html to reflect the following:

<ion-header>

  <ion-navbar>
    <ion-title>Wishlist</ion-title>
  </ion-navbar>

</ion-header>


<ion-content>

    <ion-list>
        <ion-item-sliding *ngFor="let product of wishlistService.products">
            <ion-item>
                <h3>{{product.title}}</h3>
                <span>${{product.price}}</span>
                <p>{{product.description}}</p>
            </ion-item>
            <ion-item-options>
                <button ion-button icon-only (click)="deleteFromWishlist(product)" color="light"><ion-icon name="trash"></ion-icon></button>
            </ion-item-options>
        </ion-item-sliding>
    </ion-list>

</ion-content>

Failing test: Missing function

Our first test is actually passing now, so we know that our products are being displayed in the list, but the second test is failing because the deleteFromWishlist function we are referencing in the template does not exist yet.

Modify src/pages/wishlist/wishlist.ts to reflect the following:

import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
import { WishlistService } from '../../providers/wishlist-service'

@Component({
  selector: 'page-wishlist',
  templateUrl: 'wishlist.html'
})
export class WishlistPage {

  constructor(public navCtrl: NavController, public wishlistService: WishlistService) {}

  ionViewDidLoad() {
    console.log('ionViewDidLoad WishlistPage');
  }

  deleteFromWishlist(product){
    this.wishlistService.deleteProduct(product);
  }

}

All tests passing

Success! All our tests are now passing. Try launching the application now with ionic serve to verify that everything works. Doesn’t it feel strange doing so much development without ever actually looking at the application and having it all work?

6. A Little Styling

The application works now, but some elements look a bit wonky because of some changes we have made throughout this tutorial. We are just going to make a couple of CSS changes to neaten things up a bit.

Modify src/pages/product/product.scss to reflect the following:

.ios, .md {

    page-product {

        ion-item-sliding {
            width: 96%;
            margin-left: 2%;
            margin-top: 20px;
            position: relative;
            background-color: #fff;
        }

        ion-item {

            background-color: #fff;
            padding: 20px;

            h3 {
                font-size: 1.3em;
            }

            span {
                color: #cecece;
                font-size: 0.8em;
                position: absolute;
                top: 20px;
                right: 20px;
            }

        }

    }

}

Modify src/page/wishlist/wishlist.scss to reflect the following:

.ios, .md {

    page-wishlist {

        ion-item-sliding {
            width: 96%;
            margin-left: 2%;
            margin-top: 20px;
            position: relative;
            background-color: #fff;
        }

        ion-item {

            background-color: #fff;
            padding: 20px;

            h3 {
                font-size: 1.3em;
            }

            span {
                color: #cecece;
                font-size: 0.8em;
                position: absolute;
                top: 20px;
                right: 20px;
            }

        }
    }

}

Now the application should look something like this:

Application Screenshot

Application Screenshot

Summary

We have now built a working application from start to finish using Test Driven Development. The majority of our code is covered by tests that will automatically let us know if we break things in the future, and we can be confident that our code does what we want it to do as we created tests to satisfy the specific requirements. If we had have added tests after having already built the application, it is likely that there would be more bugs in the application and that not as much of the codebase would be covered by tests.

What I really enjoy about the Test Driven Development approach is the confidence it gives you in your codebase. It is astounding to be able to write so much of an application before even attempting to run it, and being confident that it will work because it is satisfying all of your tests. Of course, tests aren’t infallible. The test could be written incorrectly, or you may have missed writing a test for some functionality, but you will certainly notice an improvement in code quality if you rigidly stick to the Test Driven Development methodology.

What to watch next...