Test Driven Development in Ionic: An Introduction to TDD

Test Driven Development in Ionic: An Introduction to TDD

Follow Josh Morony on

The concept of creating automated tests for your application allows you to improve the quality of your code, protect against regressions when making updates, and speed up the testing and debugging process.

There are many different types of tests you can add to your applications, including:

  • Unit tests which test small isolated chunks of code
  • Integration tests which test integrations between various components/services/functions
  • End-to-end tests which test the flow of an application from start to finish with realistic scenarios

and there are many different approaches to creating these tests. One common approach is to add tests after the code has already been created, to ensure that it is doing what it is supposed to do. Another, and perhaps better (depending on who you ask), approach is to create tests before writing any code. The general process involves writing a test that describes what the code should do (which will fail because the code does not exist yet), then writing code to satisfy that test until it passes. This is referred to as Test Driven Development or Behaviour Driven Development (we will briefly discuss the difference later).

Some benefits to a Test Driven Development approach are:

  • Tests can’t be skipped
  • Test coverage is high since a test needs to be written before code is created
  • The tests document the requirements of the application
  • It forces you to plan what your code will do before writing it
  • Issues can be identified and resolved during the development of the application
  • It is easier to identify what tests need to be written

I like the idea of Test Driven Development, but I struggled for a while to come up with an approach that made sense to me… What should my first test be? What kind of tests do I write? How many tests do I write before I start building the app? Adam Wathan’s content helped me a lot with this, as did this video by Misko Hevery. The content I just linked is not specific to Ionic or even to Angular (in fact, Adam’s content is based on the Laravel framework for PHP), but the general testing concepts can be applied just about anywhere – they might just look a little different.

In this tutorial series, we will be walking through some examples of using this style of Test Driven Development.

Testing is one of those things where there isn’t really a “one true way” to do things, which makes it a bit more complicated to wrap your head around. There are certainly some guidelines and best practices around what to test and how to test it, but there are different schools of thought and not everybody agrees on what these best practices are. If you have an opinion on the way I am structuring the tests in these tutorials, I would encourage you to share it in the comments (constructively, of course).

If you are completely new to testing, my advice to you would be to just do it and don’t worry about whether your tests follow best practices or not – this will come in time. Your tests are separate to your application, so they aren’t going to break anything, and having any kind of testing is better than having none at all. But also keep in mind that just because you have passing tests, it doesn’t mean that your code is working. The better you get at testing, the more confident you can be that it is.

Before we get into building a real application in Ionic using Test Driven Development, we are going to cover a few basic concepts first.

An Introduction to Unit Testing

Before starting with this series, you should at least understand the basic concept of how to write unit tests for Angular or Ionic applications. We will be focusing on an Ionic/Angular application, but a lot of the same concepts can be applied to other types of Ionic applications (e.g. ones built with StencilJS).

If you need a bit a primer before continuing, you can read some of my previous tutorials:

Once you have read through both of those, and preferably give the examples a go yourself, you should be fine to continue on.

Test Driven Development vs Behaviour Driven Development

This series will not strictly be about Test Driven Development (TDD) over Behaviour Driven Development (BDD), we will use a mix of both. The difference between the two approaches is subtle and is also one of those things that does not seem to be fully agreed upon.

Both TDD and BDD are based on the idea that you write tests to drive the development of the application, the general process is:

  1. Write a test
  2. Run the test (it should fail)
  3. Write code to satisfy the test, and test again (it should pass)
  4. Refactor as necessary

I am not going to dive too deep into the difference between TDD and BDD. In general, tests created with TDD are more granular and are concerned with the inner workings of the code (i.e. the writer of the tests knows or assumes how the code will be implemented), tests created with a BDD approach take more of a black box approach and are only concerned with what should happen, not how it is achieved.

A test written with a TDD approach might be described as:

it('should remove the last element in the array')

whereas a test written with a BDD approach might be described as:

it('should remove the last grocery item added')

Both of these are testing the same thing, but the TDD approach has knowledge of the internals of the implementation, whereas the BDD approach is only testing for the desired behaviour.

There are benefits to both approaches. With TDD it may be easier to spot why tests are failing because they are more granular, but if the implementation of certain functions change then the tests will need to be updated to reflect the new implementation (which isn’t as much of an issue for BDD).

Getting into the specifics of this is far beyond the scope of this series, I want to keep it simple, but I think it is worth touching on the difference between the two approaches.

1. Generate a New Ionic/Angular Application

First, you will need to create a new Ionic application by running the following command:

ionic start ionic-tdd-part1 blank --type=angular

We will be creating some pages and providers for this application, but we will do that later.

2. The Application

Before we get into building the application, we should discuss what it is that we are going to attempt to build. We’re going to keep it reasonably simple for now, but we will likely expand on the application throughout the series.

For now, we are going to develop an application that displays a list of products to the user. Later, we will expand this to include other functions like adding products to a favourites list, going through a checkout process, loading data dynamically and so on.

3. The First Test

We have everything set up now, and since we are using a Test Driven Development approach our first thought should be about writing our first test. Figuring out what that first test might be can be difficult.

As I mentioned before, Adam Wathan’s content helped a lot with deciding on a testing approach. He talks about writing your first test for a feature of your application that gets to the point of your application, without relying on any complicated dependencies. Starting with authentication wouldn’t be the best idea because it is rather complicated to test, and it doesn’t really have much to do with what your application actually does.

A good starting test for our application might be to test that a user can view a list of products. This is a reasonably simple test, and it goes straight to the core of what the application is about.

It is also a good idea to write tests in a way that you want them to work in an ideal scenario – don’t just write code to create a passing test as soon as possible. If you are writing a test and you think it would be a good idea to use some provider to handle something that does not yet exist, you should just pretend it does. This will cause your test to fail, which will force you to then create that provider to satisfy the test.

In effect, you will keep facing error after error in your tests until they eventually all pass. This is what TDD is all about.

We are going to start creating our tests now and you will see this in action.

4. Testing the Product Page

As I just mentioned, the first thing we are going to test is that a user is able to view a list of products on the product page. First, we will create that page, and then start creating our test.

Run the following command to generate the product page:

ionic g page Product

Now we are going to write a test that will test that products are actually displayed to the user on that page.

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

import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { async, ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { DebugElement } from "@angular/core";
import { ProductsService } from "../services/products.service";

import { ProductPage } from "./product.page";

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

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ProductPage],
      providers: [ProductsService],
      schemas: [CUSTOM_ELEMENTS_SCHEMA]
    }).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(ProductsService);
    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);
  });
});

In this test, we are grabbing a reference to the first ion-item element in our template (which does not exist yet) to check if it contains the appropriate data from the first product in the Products provider (which also does not exist yet).

Remember before how I mentioned designing your tests in the way you want them to work? This is an example of that. I don’t have a Products service, but it makes sense to have a service that handles the product data, so I am pretending that it does exist. When an error forces me to make that service, I will.

So, let’s run the tests with npm test and see what happens:

ERROR in src/app/product/product.page.spec.ts:5:26 - error TS2307: Cannot find module '../services/products.service'.

5 import { ProductsService } from "../services/products.service";

Chrome 76.0.3809 (Mac OS X 10.14.6): Executed 0 of 0 ERROR (0.001 secs / 0 secs)

We immediately run into issues because we are attempting to use a provider that doesn’t exist. This is blocking us from continuing, so we are going to switch gears and start building the product service instead, and then once we are done with that we will continue where we left off here.

6. Testing the Products Service

We need to build the Products service to satisfy our Product page test, but we can’t implement functionality in a new service unless we have a failing test for it first. That’s what we are going to do now.

It isn’t necessary to include the entire TestBed set up to test a simple service like this. Instead, we will be using a more basic set up as we did in the How to Unit Test an Ionic/Angular Application tutorial.

Create the provider with the following command:

ionic g service services/Products

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

import { ProductsService } from "./products.service";

describe("Provider: Products", () => {
  let productsService;

  beforeEach(() => {
    productsService = new ProductsService();
  });

  afterEach(() => {
    productsService = null;
  });

  it("should have a non empty array called products", () => {
    let products = productsService.products;

    expect(Array.isArray(products)).toBeTruthy();
    expect(products.length).toBeGreaterThan(0);
  });
});

This will test if our Products service has an array called products as a member variable, and that the array contains some data. Let’s run the test and see what happens:

ERROR in src/app/product/product.page.spec.ts:42:40 - error TS2339: Property 'products' does not exist on type 'ProductsService'.

42     let firstProduct = productsService.products[0];

Our products service test is now giving us an error. It is trying to access the products property of a member variable that does not exist yet. Now that we have our failing test, let’s implement the service such that it would satisfy this test:

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

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

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

  constructor() {
    this.products = [
      { title: "Cool shoes", description: "Isn't it obvious?", price: "39.99" },
      {
        title: "Broken shoes",
        description: "You should probably get the other ones",
        price: "89.99"
      },
      { title: "Socks", description: "The essential footwear companion", price: "2.99" }
    ];
  }
}

If you run the tests now you will still see a failing test, but that is for the Product page. Our Products service should now be passing the tests:

TOTAL: 1 FAILED, 5 SUCCESS

7. Testing the Product Page (again)

Since we have finished implementing the Products service, we can go back to testing the Product page again. Let’s take a look at why it is failing:

Chrome 76.0.3809 (Mac OS X 10.14.6) Page: Product Page displays products containing a title, description, and price in the list FAILED
	TypeError: Cannot read property 'nativeElement' of null

It is saying that it can’t read the nativeElement property. This is because of the following section in the test:

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

We are attempting to grab a reference to an item in a list, but we haven’t even implemented our template yet so it can’t find it.

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

<ion-header>
  <ion-toolbar>
    <ion-title>Product</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-list>
    <ion-item *ngFor="let product of productsService.products">
      <h3>{{ product.title }}</h3>
      <span>{{ product.price }}</span>
    </ion-item>
  </ion-list>
</ion-content>

and let’s run the tests again (this should happen automatically, by the way):

Chrome 76.0.3809 (Mac OS X 10.14.6) Page: Product Page displays products containing a title, description, and price in the list FAILED
	TypeError: Cannot read property 'products' of undefined

This time, we are getting an error about the products property being undefined. This is because we are referencing the Products service in our template, but we haven’t set it up in our TypeScript file yet.

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

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

@Component({
  selector: "app-product",
  templateUrl: "./product.page.html",
  styleUrls: ["./product.page.scss"]
})
export class ProductPage implements OnInit {
  constructor(public productsService: ProductsService) {}

  ngOnInit() {}
}

and, once again, let’s run the tests!

Chrome 76.0.3809 (Mac OS X 10.14.6) Page: Product Page displays products containing a title, description, and price in the list FAILED
	Error: Expected 'Cool shoes39.99' to contain 'Isn't it obvious?'.

This is an interesting error. In our test, we are checking for the existence of the title, price, and description of the first product in the products service in the list on the Product page. But we’ve forgotten to include the description in the template! Obviously, I did this on purpose, but I think it highlights how testing like this can help you pick up on issues along the way. Let’s fix it.

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

<ion-header>
  <ion-toolbar>
    <ion-title>Product</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-list>
    <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>

…and let’s run the tests again:

TOTAL: 6 SUCCESS

Hooray! Everything is all green and all of our tests have passed. This means that we can be reasonably sure that our code is working as intended, and as we add more features to the application (writing tests for them first, of course) we can be reasonably confident that we aren’t making breaking changes to the existing code.

If we later decide that we could refactor this code to improve it, we can do so and have our tests making sure that we aren’t breaking anything unintentionally.

Summary

It may seem a bit tedious, and it is a bit of extra work, but TDD is very much an example of “slow and steady wins the race”. You will code slower, but you will make fewer mistakes along the way, and when you finish you will have a robust set of automated tests.

If you would like a much more in-depth introduction to testing Ionic/Angular applications, check out Elite Ionic.

UPDATE: Part 2 is out now.

Check out my latest videos: