Testing in Ionic 2

Test Driven Development in Ionic 2: Http and Mocks



·

In the previous tutorial in this series, we introduced the concept of Test Driven Development and how those concepts can be applied to Ionic 2 development. I won’t be introducing the concepts again here, but in short: Test Driven Development, or TDD, is a method of development that involves writing tests before writing your application code, and then writing code to satisfy those tests.

We have already covered the basic concepts, how to set up testing, and some basic tests which ensured our components were being created successfully and that views were being updated with the correct data. In this tutorial, we are going to build on the existing application to include a more complex scenario.

In the previous tutorial we had a Products service that would supply some product data, and then we would use that service to display some data in a ProductPage. We have written tests for these and all of those tests pass successfully:

Passing Unit Tests

But the Products service is only using static data set up through its constructor:

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

	}

This was done for the sake of making the first tutorial more simple, but this isn’t likely how you would want to set this service up in a real world scenario. You would likely load this data in from somewhere using the Http service, but if you try to modify this service to make an HTTP request, you will quickly find you run into trouble with your tests.

In this tutorial, we will be modifying the Products service to make an HTTP request to load the data. We will discuss why it is beneficial to “fake” the request to the backend, and why it is useful to use “mocks”.

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: An Introduction to TDD, if you want to follow along with the example you will need to have completed the previous tutorial first.

1. Modify the Products Service to make an HTTP Request

In the previous tutorial, we discussed that the general approach to Test Driven Development is the following:

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

We already have our tests and the code to satisfy those tests, and now we are looking at the refactor step. Refactoring code just means modifying it to make it better in some way. Since we have our tests, we know that if we make any breaking changes as we are attempting to refactor, our tests will let us know about it.

Let’s see what this process might look like as we refactor our Products service to make an HTTP request.

Create a file at src/assets/data/products.json and add the following:

{

    "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"}
    ]

}

This is the data that we will load with an HTTP request, but we are just going to store it locally. You could just as easily make this request to a server instead, either way, by loading through an HTTP request we are making the load process asynchronous (meaning the data is not instantly available to our application, and it will go on doing its own thing instead until the data is available).

Modify src/providers/products.ts to reflect the following:

import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
import 'rxjs/add/operator/map';

@Injectable()
export class Products {

    products: any;

    constructor(public http: Http) {

    }

    load(){

        this.http.get('assets/data/products.json').map(res => res.json()).subscribe(data => {
            this.products = data.products;
        });

    }

}

We’re importing the *Http** service now, and we have made a load method that will make a request to the JSON file we created. Once the data has been retrieved, it will be converted into a Javascript object by mapping the response using the json() method, and then we will assign that data to the products member variable.

Let’s see what happens if we run our tests now with npm test:

Failing Unit Tests

We have two failing tests now. Our ProductPage is not correctly adding data to the product page, and the Products provider does not have an array of product data. This is good, it’s exactly what the tests are designed to do. We made some change which broke our application, and our tests are letting us know about it.

Now let’s investigate why.

2. Modify the Products Service Test

This is what our test file looks like for the Products service right now:

import { Products } from './products';

let productsService;

describe('Provider: Products', () => {

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

    it('should have a non empty array called products', () => {

        let products = productsService.products;

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

    });

});

This is a nice and simple test. At the time, this provider did not have any dependencies so we could just instantiate a new object using:

productsService = new Products();

instead of configuring a test component with TestBed. But now that we are making an HTTP request, we are setting up the Http service through dependency injection, which means we are going to need to start using TestBed.

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

import { TestBed, inject, async } from '@angular/core/testing';
import { Http, HttpModule, BaseRequestOptions, Response, ResponseOptions } from '@angular/http';
import { MockBackend } from '@angular/http/testing';
import { Products } from './products';

describe('Provider: Products', () => {

    beforeEach(async(() => {

        TestBed.configureTestingModule({

            declarations: [

            ],

            providers: [
                Products,
                MockBackend,
                BaseRequestOptions,
                {
                    provide: Http, 
                    useFactory: (mockBackend, options) => {
                        return new Http(mockBackend, options);
                    },
                    deps: [MockBackend, BaseRequestOptions]
                }
            ],

            imports: [
                HttpModule
            ]

        }).compileComponents();

    }));

    beforeEach(() => {

    });

    it('should have a non empty array called products', inject([Products, MockBackend], (productsService, mockBackend) => {

        const mockResponse = '{"products": [{"title": "Cool shoes", "description": "Isnt 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"} ] }';

        mockBackend.connections.subscribe((connection) => {

            connection.mockRespond(new Response(new ResponseOptions({
                body: mockResponse
            })));

        });

        productsService.load();

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

    }));


});

We’ve set up TestBed now, and we have discussed the purpose for that previously, but we also have a bunch of other shenanigans going on now… MockBackend? BaseRequestOptions? useFactory?

We need to make use of the Http service, and so we would want to provide that service to this test configuration. However, when creating unit tests we generally want to create an isolated test case. We want to test this service and only this service, so we don’t want to have to rely on an external server to provide us with the correct data (we will just assume that it gives us the correct data). This is something you may cover in different types of tests, but not in a unit test.

So, instead of providing Http in its normal form, we modify its implementation by using useFactory so that it uses MockBackend. This is a service supplied by Angular for the purpose of testing, and it allows you to create a “fake” backend that sends fake responses. The important part is that these fake responses are still performed asynchronously in the same way that a normal HTTP request would be.

We can then use any response we like for testing this service, as you will see we are hard coding a JSON string to use as the response, and then we set it up as a mock response with the following code:

        const mockResponse = '{"products": [{"title": "Cool shoes", "description": "Isnt 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"} ] }';

        mockBackend.connections.subscribe((connection) => {

            connection.mockRespond(new Response(new ResponseOptions({
                body: mockResponse
            })));

        });

This has two main benefits: it allows us to test this service in isolation, and it will allow the tests to run faster since we don’t need to wait for a response from a server.

Let’s take a look at where we stand with our tests now:

Failing Unit Test

Great! Our Products service test is passing again now, but our ProductPage test is still failing.

3. Modify the Products Page and Test

We’ve changed the implementation of the Products service now, rather than loading in its data automatically it won’t load until the load function is called, so of course our ProductPage will need to be updated to reflect this change.

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'

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

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

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

}

This will cause the Products service to load in its data as soon as the view has loaded. Let’s see if this sorts out our problem with the test:

Failing Unit Test

Nope! Still getting an issue there. Let’s consider the test itself:

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

    });

Right now we just grab a reference to the Products service and check its data immediately. The data isn’t available immediately, though, because we are loading it asynchronously, so our test is going to fail.

This isn’t even really the correct approach anyway. As I mentioned in the last section, we want to isolate our unit tests as much as possible, but in this test, we are relying on the Products service to supply the data. Instead, we are going to “mock” the Products service. A mock is a “fake” implementation of a real class for the purpose of testing. It means we can take the approach of assuming that our dependencies work, and focus on only testing this one specific component.

Add the following to the end of src/mocks.ts:

export class ProductsMock {
  public products: any = [
    {title: "Cool shoes", description: "Isnt it obvious?", price: "39.99"}
  ];
}

This sets up a simple mock with a products member variable. Now we just need to use that mock in our ProductPage.

Modify src/pages/product/product.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 { Products } from '../../providers/products';
import { ProductsMock } 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: [
                NavController, 
                { 
                    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);

    });


});

Not a whole lot has changed here, the important part is this:

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

Instead of just providing the Products service, as usual, we take a similar approach to using the fake HTTP backend and swap out the real Products service with a fake ProductsMock by using useClass. Everything else remains the same, we can still access the Products service throughout the tests as we were before, except that it will have been replaced by its fake clone.

Let’s try the tests again..

Passing Unit Test

Hooray! All green text again, all of our tests are passing.

Summary

We haven’t written a single new test in this tutorial, but we have continued developing our application using a Test Driven Development approach. As you can see, sometimes our tests will need to be modified as we go, and there are different ways that you can test the same functionality. The changes we have made in this tutorial to use mocks instead of the dependencies themselves conforms to a more best practice approach of testing components in isolation.

UPDATE: The next part is available now.

What to watch next...

  • Chanoch

    Hi Josh,

    Great article again and a very valuable resource. Have you heard of Ruby Tapas? It might a useful business model for you. Consider this a pre-order from me if you go that way but prefer written to video..

    Chanoch

  • Chris Perkins

    I think that you might have erroneously referred to modifying ‘src/pages/product/product.ts’ rather than ‘src/pages/product/product.specs.ts’ when adding the ProductsMock in the example above.

    • Jeffrey Sun

      Yes, this confused me. Please update it. In general, though, your tutorials have been enormously helpful. Thank you so much!

  • Romain Fallet

    Hi, nice article,

    I have one question though. How do you unit test a service that use a global variable ? For example, I have an ENV variable provided by my webpack settings for my APIs credentials. I tried to declare it in src/test.ts but it does not work.

    Thanks for help.