Test Driven Development in Ionic: Services and Templates

Throughout this tutorial series, we have been learning various methods for unit testing Ionic 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 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
  • How to write tests for HTTP requests
  • How to test navigation
  • How to create test doubles or mocks 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

IMPORTANT: This tutorial follows on from Test Driven Development in Ionic: Navigation and Spies, if you want to follow along with the example you will need to have completed the previous tutorial first.

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:

8 specs, 0 failures, randomized with seed 96663
WishlistPage
- should create
Provider: Products
- should have a non empty array called products
Page: Product Page
- is created
- displays products containing a title, description, and price in the list
- should be able to launch wishlist page
AppComponent
- should initialize the app
- should create the app
HomePage
- should create

We will keep these tests running 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/app/product/product.page.spec.ts:

// ...snip

import { WishlistService } from "../services/wishlist.service";
import { WishlistMock } from "../../mocks";

// ...snip

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ProductPage],
      imports: [HttpClientTestingModule],
      providers: [
        {
          provide: ProductsService,
          useClass: ProductsMock
        },
        {
          provide: WishlistService,
          useClass: WishlistMock
        },
        {
          provide: NavController,
          useClass: NavMock
        }
      ],
      schemas: [CUSTOM_ELEMENTS_SCHEMA]
    }).compileComponents();
  }));

// ...snip

  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(ProductsService);
    let firstProduct = productsService.products[0];

    fixture.detectChanges();

    de = fixture.debugElement.query(By.css("ion-card ion-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 button inside of the ion-card yet.

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

ERROR in src/app/product/product.page.spec.ts:12:33 - error TS2307: Cannot find module '../services/wishlist.service'.

12 import { WishlistService } from "../services/wishlist.service";

src/app/product/product.page.spec.ts:13:10 - error TS2305: Module '"../../mocks"' has no exported member 'WishlistMock'.

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

Run the following command to create the WishlistService

ionic g service services/Wishlist

Remember that we want to keep our tests isolated, 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 WishlistMock {
  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: Function = (product: Object) => {};

  public deleteProduct: Function = (product: Object) => {};
}

This will provide a basic implementation of the WishlistService that we can use as a test double. Let’s also set up an outline of our real WishlistService now (but we won’t add any functionality).

Modify src/app/services/wishlist.service.ts to reflect the following:

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

@Injectable({
  providedIn: "root"
})
export class WishlistService {
  public products;

  constructor() {}

  addProduct(product) {}

  deleteProduct(product) {}
}

Now that we have all of our files set up, let’s try our tests again:

TOTAL: 1 FAILED, 9 SUCCESS

The test that is failing is causing the following error:

TypeError: Cannot read property 'triggerEventHandler' of null

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

Modify src/app/product/product.page.html to reflect the following:

<ion-header>
  <ion-toolbar>
    <ion-title>Product</ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="launchWishlist()"
        ><ion-icon slot="icon-only" name="heart"></ion-icon
      ></ion-button>
    </ion-buttons>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-card *ngFor="let product of productsService.products">
    <ion-card-header>
      <ion-card-title>{{ product.title }}</ion-card-title>
      <ion-card-subtitle>{{ product.price }}</ion-card-subtitle>
    </ion-card-header>
    <ion-card-content>{{ product.description }}</ion-card-content>
    <ion-button (click)="addToWishlist(product)" color="light">
      <ion-icon slot="icon-only" name="heart"></ion-icon>
    </ion-button>
  </ion-card>
</ion-content>

Now we have added the button to add the product to the wishlist, let’s see how our tests are going:

Page: Product Page should add product to wishlist when add to wishlist button clicked FAILED
	Error: Expected spy addProduct to have been called with [ Object({ title: 'Cool shoes', description: 'Isnt it obvious?', price: '39.99' }) ] but it was never called.

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/app/product/product.page.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.

Executed 10 of 10 SUCCESS (0.506 secs / 0.472 secs)

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 some new tests.

Modify src/app/services/wishlist.service.spec.ts to reflect the following:

import { TestBed, inject } from "@angular/core/testing";

import { WishlistService } from "./wishlist.service";

describe("WishlistService", () => {
  let testProduct;

  beforeEach(() => TestBed.configureTestingModule({}));

  beforeEach(() => {
    testProduct = {
      title: "Test Product",
      description: "Test Description",
      price: "39.99"
    };
  });

  it("should be created", () => {
    const service: WishlistService = TestBed.get(WishlistService);
    expect(service).toBeTruthy();
  });

  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!

WishlistService should be able to add a single product to product array FAILED
	TypeError: Cannot read property 'length' of undefined

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

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

@Injectable({
  providedIn: "root"
})
export class WishlistService {
  public products: any = [];

  constructor() {}

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

  deleteProduct(product) {}
}

and then check our tests:

Executed 11 of 11 SUCCESS (0.482 secs / 0.451 secs)

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/app/services/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.

	Error: Expected [ Object({ title: 'Test Product', description: 'Test Description', price: '39.99' }) ] not to contain Object({ title: 'Test Product', description: 'Test Description', price: '39.99' }).

    Error: Expected 2 to equal 1.

Let’s take a look at the second error first:

Expected 2 to equal 1

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

Modify src/app/services/wishlist.service.ts to reflect the following:

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

@Injectable({
  providedIn: "root"
})
export class WishlistService {
  public products: any = [];

  constructor() {}

  addProduct(product): void {
    if (!this.products.includes(product)) {
      this.products.push(product);
    }
  }

  deleteProduct(product) {}
}

Now we first check if a product exists in the array before adding it, which fixes the error:

TOTAL: 1 FAILED, 12 SUCCESS

Now we need to deal with the error for the deleting products test, which is failing because we aren’t currently doing anything with that method.

Modify src/app/services/wishlist.service.ts to reflect the following:

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

@Injectable({
  providedIn: "root"
})
export class WishlistService {
  public products: any = [];

  constructor() {}

  addProduct(product): void {
    if (!this.products.includes(product)) {
      this.products.push(product);
    }
  }

  deleteProduct(product): 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.

Executed 13 of 13 SUCCESS (0.527 secs / 0.501 secs)

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/app/wishlist/wishlist.page.spec.ts to reflect the following:

import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { async, ComponentFixture, TestBed, inject } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { DebugElement } from "@angular/core";
import { WishlistPage } from "./wishlist.page";
import { WishlistService } from "../services/wishlist.service";
import { WishlistMock } from "../../mocks";

describe("WishlistPage", () => {
  let comp: WishlistPage;
  let fixture: ComponentFixture<WishlistPage>;
  let de: DebugElement;
  let el: HTMLElement;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [WishlistPage],
      providers: [
        {
          provide: WishlistService,
          useClass: WishlistMock
        }
      ],
      schemas: [CUSTOM_ELEMENTS_SCHEMA]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(WishlistPage);
    comp = fixture.componentInstance;
  });

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

  it("should create", () => {
    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 ion-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 WishlistMock 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.

WishlistPage should display all products contained in wishlist FAILED
	TypeError: Cannot read property 'innerHTML' of undefined

 WishlistPage should make a call to remove a prouct from wishlist when delete button clicked FAILED
	TypeError: Cannot read property 'triggerEventHandler' of null    

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/app/wishlist/wishlist.page.ts to reflect the following:

import { Component, OnInit } from "@angular/core";
import { WishlistService } from "../services/wishlist.service";

@Component({
  selector: "app-wishlist",
  templateUrl: "./wishlist.page.html",
  styleUrls: ["./wishlist.page.scss"]
})
export class WishlistPage implements OnInit {
  constructor(public wishlistService: WishlistService) {}

  ngOnInit() {}
}

Modify src/app/wishlist/wishlist.page.html to reflect the following:

<ion-header>
  <ion-toolbar>
    <ion-title>Wishlist</ion-title>
  </ion-toolbar>
</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>
        <ion-button (click)="deleteFromWishlist(product)" color="light">
          <ion-icon slot="icon-only" name="trash"></ion-icon>
        </ion-button>
      </ion-item-options>
    </ion-item-sliding>
  </ion-list>
</ion-content>
WishlistPage should make a call to remove a prouct from wishlist when delete button clicked FAILED

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 has not been implemented.

Modify src/app/wishlist/wishlist.page.ts to reflect the following:

import { Component, OnInit } from "@angular/core";
import { WishlistService } from "../services/wishlist.service";

@Component({
  selector: "app-wishlist",
  templateUrl: "./wishlist.page.html",
  styleUrls: ["./wishlist.page.scss"]
})
export class WishlistPage implements OnInit {
  constructor(public wishlistService: WishlistService) {}

  ngOnInit() {}

  deleteFromWishlist(product) {
    this.wishlistService.deleteProduct(product);
  }
}
Executed 15 of 15 SUCCESS (0.623 secs / 0.573 secs)

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?

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.

Advanced Animations & Interactions with Ionic

NEW Create next level Ionic applications by harnessing the power of the Ionic Animations API and Gestures.

Utilise these powerful APIs to design and build your own custom animations and interactions. No external libraries required, everything is built with just Ionic. Learn more.

Follow me on Twitter or subscribe to me on YouTube for more web development content.

Check out my latest videos: