Testing in Ionic 2

Test Driven Development in Ionic 2: Navigation and Spies



·

In the previous tutorial in this series, we covered the concept of mocks and creating a fake backend that we could make HTTP requests to. The general idea is that we want to isolate the thing we are testing, we don’t want external dependencies complicating our unit tests.

We have been taking a Test Driven Development to developing a simple eCommerce application, which means that we write the tests for our code before we write the code itself. If you are not familiar with this concept, I’d recommend going back and reading earlier tutorials in this series.

At this point, we have a pretty basic app that just displays a product list loaded via an HTTP request. Functionality wise, we haven’t done a whole lot yet, but we have been adhering to TDD principles, and the code that we do have has great test coverage. We can be quite confident that the code we do have is working as intended.

Now we’re going to continue to develop the application using the Test Driven Development approach. We will be adding a few new requirements to the application:

  • Add the ability to add products to a wishlist
  • Add the ability to view products in the wishlist

In this tutorial, we will just be focusing on creating the wishlist page and creating a test to ensure that navigation between our two pages is working. To do this, we will introduce the concept of spies.

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: Http and Mocks, 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. Let’s Pretty Things up a Bit

Sure, our application works and all our tests are passing, but it looks ugly. We’re going to take a minute to pretty things up a bit. As I mentioned in the last tutorial, this falls into the refactor step of the TDD approach. Since we have our tests to ensure that the application is working as expected, we can refactor the application as we wish without having to worry so much about creating regressions (breaking existing code in the application by adding new features).

Modify src/theme/variables/scss to reflect the following Shared Variables and Named Color Variables

// Shared Variables
// --------------------------------------------------
// To customize the look and feel of this app, you can override
// the Sass variables found in Ionic's source scss files.
// To view all the possible Ionic variables, see:
// http://ionicframework.com/docs/v2/theming/overriding-ionic-variables/

$background-color: #ac6477;

// Named Color Variables
// --------------------------------------------------
// Named colors makes it easy to reuse colors on various components.
// It's highly recommended to change the default colors
// to match your app's branding. Ionic uses a Sass map of
// colors so you can add, rename and remove colors as needed.
// The "primary" color is the only required color in the map.

$colors: (
  primary:    #ac6477,
  secondary:  #985869,
  danger:     #f53d3d,
  light:      #f4f4f4,
  dark:       #222
);

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

.ios, .md {

    page-product {

        ion-item {
            background-color: #fff;
            width: 92%;
            margin: 20px auto;
            padding: 20px;
            position: relative;

            h3 {
                font-size: 1.3em;
            }

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

        }

    }

}

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

<ion-header no-border>

  <ion-navbar color="secondary">
    <ion-title>Product</ion-title>
  </ion-navbar>

</ion-header>

<ion-content>

    <ion-list no-lines>
        <ion-item *ngFor="let product of productsService.products">
            <h3>{{product.title}}</h3>
            <span>${{product.price}}</span>
            <p>{{product.description}}</p>
        </ion-item>
    </ion-list>

</ion-content>

Now the application should look something like this:

Updated UI

Still not the most “eCommerce-y” looking application in the world, but it doesn’t hurt so much to look at anymore. Just in case, let’s also run our tests with npm test to ensure that we haven’t broken anything unintentionally:

Original Passing Tests

All good! Let’s continue.

2. Creating our First Spy

We want to create a WishlistPage but we can’t write any code until we have a test, so we are going to write that first. We’re going to continue using the methodology of writing tests in the way that we want them to work (even if the things we are attempting to use don’t exist yet), and use the errors that the tests create to drive the development of the application.

We want to be able to navigate from our ProductPage to our WishlistPage (which does not exist yet), so let’s create a test for that in the test file for the product page.

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

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 { Products } from '../../providers/products';
import { ProductsMock, NavMock } 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
                }
            ],

            imports: [
                IonicModule.forRoot(MyApp)
            ]

        }).compileComponents();

    }));

    beforeEach(() => {

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

    });

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

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

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

    });

    it('displays products containing a title, description, and price in the list', () => {

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

        fixture.detectChanges();

        de = fixture.debugElement.query(By.css('ion-list ion-item'));
        el = de.nativeElement; 

        expect(el.textContent).toContain(firstProduct.title);
        expect(el.textContent).toContain(firstProduct.description);
        expect(el.textContent).toContain(firstProduct.price);

    });

    it('should be able to launch wishlist page', () => {

        let navCtrl = fixture.debugElement.injector.get(NavController);
        spyOn(navCtrl, 'push');

        de = fixture.debugElement.query(By.css('ion-buttons button'));

        de.triggerEventHandler('click', null);

        expect(navCtrl.push).toHaveBeenCalledWith(WishlistPage);

    });

});

We’ve done a few things here, so let’s talk through them. We have modified NavController to use a mock called NavMock now, which is available through ours mocks.ts file (so you will need to make sure you import it as well). We overwrite the existing NavController by using useClass – if you would like a more in-depth explanation about mocks then I would recommend reading the previous tutorial.

We’ve imported the WishlistPage and we are referencing it in this file, but it doesn’t exist yet. As I mentioned, we are just designing this test in the way we want it to work, we will worry about actually making it work later.

The important part here is that we have added a new test:

    it('should be able to launch wishlist page', () => {

        let navCtrl = fixture.debugElement.injector.get(NavController);
        spyOn(navCtrl, 'push');

        de = fixture.debugElement.query(By.css('ion-buttons button'));

        de.triggerEventHandler('click', null);

        expect(navCtrl.push).toHaveBeenCalledWith(WishlistPage);

    });

We are testing that we can launch the WishlistPage from our ProductPage. We get a reference to the NavController in the same way that we have referenced the Products service in previous tests, but we are doing a couple of things here that we haven’t before.

We set up a spy on the push method of the NavController. Spies are provided by the Jasmine framework (the framework that we are using to create these tests), and it allows you to track any calls that are made to a function. This allows us to use methods like toHaveBeenCalled and toHaveBeenCalledWith to check if a function was ever called throughout the duration of the test, or whether the function was called with the correct arguments. In this case, we expect that the push method of the NavController should have been called with the WishlistPage.

We also use the triggerEventHandler method on DebugElement to trigger the (click) handler for a button that we will use to launch the WishlistPage. This button also does not exist yet, but again, it’s how we want it to work.

Let’s see what’s happening with our tests now:

Can't resolve error

No surprises there. We are immediately getting an error because it can’t find the WishlistPage which doesn’t exist yet.

3. Create the Wishlist Page

Our test from the previous step is never going to pass if the page doesn’t even exist, so we are going to create that now and also set up a test file for the wishlist page. We will build this out step-by-step, responding to new errors as they arise.

Run the following command to generate the WishlistPage:

ionic g page Wishlist

Modify src/app/app.module.ts to reflect the following:

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 { 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]
})
export class AppModule {}

Now that we have our WishlistPage added, let’s see what is happening with our tests now:

Wishlist Page Error

We have some tests running successfully, but now it is getting stuck on the following test:

should be able to launch wishlist page

and the error we are receiving is:

TypeError: Cannot read property 'triggerEventHandler' of null

This makes sense because we are trying to get a reference to a button we haven’t added yet. Let’s do that now.

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 *ngFor="let product of productsService.products">
            <h3>{{product.title}}</h3>
            <span>${{product.price}}</span>
            <p>{{product.description}}</p>
        </ion-item>
    </ion-list>

</ion-content>

We’ve added a button to the header, which should now be able to be successfully referenced by using the CSS selector ion-buttons button (which we are doing in our test).

Another Wishlist Page Error

We are still stuck on the same test, but it’s a different error now (which is good). This is because we are trying to call a function that does not exist. Let’s add the launchWishlist function to the ProductPage now.

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

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

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

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

  launchWishlist() {

    this.navCtrl.push(WishlistPage);

  }

}

Passing Tests

and now it works! We will move on to creating some tests for the WishlistPage itself next, but that is something we will save for the next tutorial. For now, let’s just set up our basic testing template and verify that the WishlistPage is generated properly.

Create a file at src/pages/wishlist/wishlist.spec.ts and add 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 { NavMock } 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
                }
            ],

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

    });

});

If we test this now, we will actually run into an error:

NavParams Error

That’s because the generated page includes NavParams by default, but we don’t need that so we are going to remove it.

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

import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';

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

  constructor(public navCtrl: NavController) {}

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

}

Now everything should be working again:

All Tests Passing

Summary

We’ve covered a few new concepts in this tutorial, most notably spies and triggering event bindings on elements, and we have also taken some more steps towards developing our application using the Test Driven Development methodology. In the next tutorial, we will continue to develop the WishlistPage.

UPDATE: Part 4 is out now.

What to watch next...