The Guide to Unit Testing in Ionic 2

  • May 18th, 2017

This is Part 1 of 2 of this guide. In this part of the guide you will learn how to setup your test suite – Karma, Jasmine, TestBed, mocks, and more. Part 2 will focus on writing tests.

Introduction

One major aspect of Ionic 2 development that has dragged behind the framework is unit testing. This may come as a surprise, given the test positive philosophy (think dependency injection) of AngularJS since the early days.

But in the case of Ionic 2, there have been many breaking changes in its evolution from Beta to Release Candidate to stable. A few brave Ionic 2 pioneers have been kind enough to share basic unit testing solutions during the Release Candidate phase, only to see enough major changes that their advice needed to be scrapped. However, we are now past the Release Candidate stage and currently into version 3 of Ionic 2. Also, please note that this guide is updated for the new @angular/cli.

While some stable, basic solutions to unit testing are being provided out there in the wild (and even a basic testing repo hosted in the official Ionic GitHub), I felt it was time someone created a more in-depth guide to unit testing Ionic 2 applications.

In this guide, we will focus on the steps of getting the recommended unit testing frameworks in place, writing a few simple example tests, and leaving you some of the additional resources you can use to write the complete tests you need for your own applications.

Reasons for Unit Testing

It’s unlikely you randomly stumbled upon this guide, and you probably have some pretty good ideas of why you want to unit test your Ionic 2 application. However, I will go over several now.

Fighting Against Regression

One reason you might want to test your code to provide some protection against regressions. My own recent professional work, for example, has been on a project where components have often changed. Modifying components, especially ones you haven’t written yourself, can lead to all sorts of unexpected (and undesirable) behavior. Unexpected and undesirable behavior is the fancy way of saying, bugs.

Test Driven Development (TDD)

Kent Beck’s concept of test-driven development centers on two basic rules:

1. Never write a single line of code unless you have a failing automated test.
2. Eliminate duplication.

So, obviously, if we are doing Test Driven Development without a testing system in place, we cannot write any code! You cannot employed this useful methodology without the ability to write and run tests. Here’s some benefits of TDD.

Documentation

Any code of your own that you haven’t looked at for six or more months might as well have been written by someone else.

Eagleson’s Law

Unit tests are a good form of documentation. A new programmer should be able to jump on to your project, and into a component you have written, and be able to discern fairly easily what the requirements of that component are. Eagleson’s Law also applies here – see the above quote.

Setup: Sample Application

Okay, enough of the why – and more of the how.

I have provided a simple to do application example that you can use to follow along. This is your typical to-do app – a design example well known to JavaScript frameworks everywhere! On a sad side note, I’ll be using this as my own to do app as my old reliable Wunderlist is shutting down.

Clone the sample application here:

git clone -b no-unit-tests https://github.com/ErikAugust/ionic2-todos.git

Please note – by no means do you do not have to use this – and if you want to use your own Ionic 2 project, simply skip to the next section, “Setup: Installing Dependencies”.

Setup: Installing Dependencies

The setup of our Ionic 2 testing suite requires a bunch of steps. Luckily, we will go through them step-by-step:

Let’s start with command line interfaces – Karma CLI (globally) and Angular CLI (globally & locally):

npm install -g karma-cli
npm install -g @angular/cli
npm install --save-dev @angular/cli

We want code coverage reporting – so let’s add that to our project:

npm install codecov --save-dev

Now, let’s add Jasmine libraries to the project – which we will write our tests with:

npm install jasmine-core --save-dev
npm install jasmine-spec-reporter --save-dev

Next up, our test runner, Karma:

npm install karma --save-dev

We’ll use the Chrome launcher – but you may want to use something else:

npm install karma-chrome-launcher --save-dev
npm install karma-jasmine --save-dev
npm install karma-mocha-reporter --save-dev
npm install karma-remap-istanbul --save-dev

Lastly, we’ll add some TypeScript libraries:

npm install ts-node --save-dev
npm install tslint --save-dev
npm install tslint-eslint-rules --save-dev
npm install @types/jasmine --save-dev
npm install @types/node --save-dev

And one more random one, which you may or may not need but your test suite may complain about it, so just to be sure:

npm install @angular/router --save

Phew. That was quite a bit of npm installations, wasn’t it?

Setup: Karma

We can use the Karma CLI to generate a new init file:

karma init karma.conf.js

When prompted, hit return repeatedly to accept the defaults – we’re going to be swapping out these values anyway.

Let’s swap out the default for this:


// Karma configuration file, see link for more information
// https://karma-runner.github.io/0.13/config/configuration-file.html

module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular/cli'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-remap-istanbul'),
      require('karma-mocha-reporter'),
      require('@angular/cli/plugins/karma')
    ],
    files: [
      { pattern: './src/test.ts', watched: false }
    ],
    preprocessors: {
      './src/test.ts': ['@angular/cli']
    },
    mime: {
      'text/x-typescript': ['ts','tsx']
    },
    remapIstanbulReporter: {
      reports: {
        html: 'coverage',
        lcovonly: './coverage/coverage.lcov'
      }
    },
    angularCli: {
      config: './angular-cli.json',
      environment: 'dev'
    },
    reporters: config.angularCli && config.angularCli.codeCoverage
      ? ['mocha', 'karma-remap-istanbul']
      : ['mocha'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome'],
    singleRun: false
  });
};

Setup: Angular CLI

Create a file in the root folder of the project named angular-cli.json and add the following:


{
  "project": {
    "version": "0.0.1",
    "name": "ionic2-todos"
  },
  "apps": [
    {
      "root": "src",
      "outDir": "dist",
      "assets": [
        "assets"
      ],
      "index": "index.html",
      "main": "app/main.ts",
      "test": "test.ts",
      "tsconfig": "tsconfig.test.json",
      "prefix": "app",
      "mobile": false,
      "styles": [
        "styles.css"
      ],
      "scripts": [],
      "environmentSource": "environments/environment.ts",
      "environments": {
        "dev": "environments/environment.ts",
        "prod": "environments/environment.prod.ts"
      }
    }
  ],
  "addons": [],
  "packages": [],
  "test": {
    "karma": {
      "config": "./karma.conf.js"
    }
  },
  "defaults": {
    "styleExt": "css",
    "prefixInterfaces": false,
    "inline": {
      "style": false,
      "template": false
    },
    "spec": {
      "class": false,
      "component": true,
      "directive": true,
      "module": false,
      "pipe": true,
      "service": true
    }
  }
}

Setup: Environments

Next, let’s setup our environments – development and production:

First production – create a new file at src/environments/environment.prod.ts and add the following code:


export const environment: any = {
  production: true,
}

Now let’s add our dev environment code at src/environment/environment.ts:


// The file contents for the current environment will overwrite these during build.
// The build system defaults to the dev environment which uses `environment.ts`, but if you do
// `ng build --env=prod` then `environment.prod.ts` will be used instead.
// The list of which env maps to which file can be found in `angular-cli.json`.
 
export const environment: any = {
  production: false,
};

Setup: Polyfills

There are a bunch of polyfills that Angular 2 needs to run – let’s add them in their own file – src/polyfills.ts:


// This file includes polyfills needed by Angular 2 and is loaded before
// the app. You can add your own extra polyfills to this file.
import 'core-js/es6/symbol';
import 'core-js/es6/object';
import 'core-js/es6/function';
import 'core-js/es6/parse-int';
import 'core-js/es6/parse-float';
import 'core-js/es6/number';
import 'core-js/es6/math';
import 'core-js/es6/string';
import 'core-js/es6/date';
import 'core-js/es6/array';
import 'core-js/es6/regexp';
import 'core-js/es6/map';
import 'core-js/es6/set';
import 'core-js/es6/reflect';
 
import 'core-js/es7/reflect';
import 'zone.js/dist/zone';

Setup: Mocks

A mock is a method or object that simulates the behavior of a real method or object in controlled ways.

In the case of unit testing, we’re focused on testing one unit at a time, so we are not overly concerned with a module’s dependencies – and want to swap them out for mocks which will only simulate their real behaviors. The nice thing about dependency injection is our ability to easily do this – swap out any dependency for a mock version.

Let’s add a nifty npm module that includes many mock Ionic 2 components:

npm install --save-dev ionic-mocks

This module currently covers:

ActionSheet
ActionSheetController
Alert
AlertController
App
Content
Events
Haptic
InifiniteScroll
Keyboard
Loading
LoadingController
Menu
MenuController
Modal
ModalController
Platform
Popover
PopoverController
NavController
NavParams
Tab
Tabs

That’s pretty awesome – and saves us some time mocking Ionic stuff. However, we will add our own additional mocks at src/mocks.ts:


import { Observable, BehaviorSubject } from 'rxjs/Rx';

/**
 * @class NavParamsMock
 */
export class NavParamsMock {
  data: Object = { Example: 1234 };
}

/**
 * @class ConfigMock
 */
export class ConfigMock {
  public get(): any {
    return '';
  }

  public getBoolean(): boolean {
    return true;
  }

  public getNumber(): number {
    return 1;
  }
}

/**
 * @class FormMock
 */
export class FormMock {
  public register(): any {
    return true;
  }
}

/**
 * @class NavMock
 */
export class NavMock {

  public pop(): any {
    return new Promise(function(resolve: Function): void {
      resolve();
    });
  }

  public push(): any {
    return new Promise(function(resolve: Function): void {
      resolve();
    });
  }

  public getActive(): any {
    return {
      'instance': {
        'model': 'something',
      },
    };
  }

  public setRoot(): any {
    return true;
  }
}

/**
 * @class PlatformMock
 */ new BehaviorSubject(this.HOME_VIEW);
export class PlatformMock {
  public ready(): Promise<{String}> {
    return new Promise((resolve) => {
      resolve('READY');
    });
  }

  public registerBackButtonAction(fn: Function, priority?: number): Function {
    return (() => true);
  }

  public hasFocus(ele: HTMLElement): boolean {
    return true;
  }

  public doc(): HTMLDocument {
    return document;
  }

  public is(): boolean {
    return true;
  }

  public getElementComputedStyle(container: any): any {
    return {
      paddingLeft: '10',
      paddingTop: '10',
      paddingRight: '10',
      paddingBottom: '10',
    };
  }

  public onResize(callback: any) {
    return callback;
  }

  public registerListener(ele: any, eventName: string, callback: any): Function {
    return (() => true);
  }

  public win(): Window {
    return window;
  }

  public raf(callback: any): number {
    return 1;
  }
}


/**
 * @class MenuMock
 */
export class MenuMock {
  public close(): any {
    return new Promise((resolve: Function) => {
      resolve();
    });
  }
}

Setup: TypeScript Test Configuration

Next, add src/tsconfig.test.json:


{
  "compilerOptions": {
    "baseUrl": "",
    "declaration": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "lib": ["es6", "dom"],
    "mapRoot": "./",
    "module": "es6",
    "moduleResolution": "node",
    "outDir": "../dist/out-tsc",
    "sourceMap": true,
    "target": "es5",
    "typeRoots": [
      "../node_modules/@types"
    ]
  }
}

Setup: Test Configuration

Finally, let’s setup our test configuration by creating a file – src/test.ts:


import './polyfills.ts';

import 'zone.js/dist/long-stack-trace-zone';
import 'zone.js/dist/proxy.js';
import 'zone.js/dist/sync-test';
import 'zone.js/dist/jasmine-patch';
import 'zone.js/dist/async-test';
import 'zone.js/dist/fake-async-test';

import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { getTestBed, TestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';

// Ionic / Angular Providers:
import {
  App,
  Config,
  DomController,
  Form,
  GestureController,
  Haptic,
  IonicModule,
  Keyboard,
  LoadingController,
  MenuController,
  ModalController,
  NavController,
  NavParams,
  Platform
} from 'ionic-angular';

// Mocks:
import { ConfigMock, NavParamsMock, PlatformMock } from './mocks';
import { HapticMock, LoadingControllerMock, ModalControllerMock } from 'ionic-mocks';

// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
declare var __karma__: any;
declare var require: any;

// Prevent Karma from running prematurely.
__karma__.loaded = function (): void {
  // noop
};

// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting(),
);
// Then we find all the tests.
const context: any = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);
// Finally, start Karma to run the tests.
__karma__.start();

/**
 * This class will be imported into your individual tests - and provide dependencies and mocked dependencies
 * @class TestUtils
 */
export class TestUtils {

  public static beforeEachCompiler(components: Array): Promise<{fixture: any, instance: any}> {
    return TestUtils.configureIonicTestingModule(components)
      .compileComponents().then(() => {
        let fixture: any = TestBed.createComponent(components[0]);
        return {
          fixture: fixture,
          instance: fixture.debugElement.componentInstance,
        };
      });
  }

  public static configureIonicTestingModule(components: Array): typeof TestBed {

    return TestBed.configureTestingModule({
      declarations: [
        ...components,
      ],
      providers: [
        App,
        DomController,
        Form,
        GestureController,
        Keyboard,
        MenuController,
        NavController,
        {provide: Config, useClass: ConfigMock},
        {provide: Haptic, useClass: HapticMock},
        {provide: LoadingController, useClass: LoadingControllerMock},
        {provide: ModalController, useClass: ModalControllerMock},
        {provide: NavParams, useClass: NavParamsMock},
        {provide: Platform, useClass: PlatformMock}
      ],
      imports: [
        FormsModule,
        IonicModule,
        ReactiveFormsModule,
      ],
    });
  }

  // http://stackoverflow.com/questions/2705583/how-to-simulate-a-click-with-javascript
  public static eventFire(el: any, etype: string): void {
    if (el.fireEvent) {
      el.fireEvent('on' + etype);
    } else {
      let evObj: any = document.createEvent('Events');
      evObj.initEvent(etype, true, false);
      el.dispatchEvent(evObj);
    }
  }
}

Alright, now, let’s add the ng test command to our package.json scripts object:

"test": "ng test"

And the moment you have been waiting for – let’s run our tests:

npm test

If all goes well, you should see something like this:

Angular 2 Unit Testing

Sweet, sweet success

Did you see it? Congratulations! You’ve done it. The hard part, the setup, is over. In the upcoming section – we will write our first tests… stay tuned!

See The Finished Version

If you’d like to see the finished version without doing any of the work – clone this branch:

git clone -b unit-tests-setup https://github.com/ErikAugust/ionic2-todos.git
Tags: , , , , , , , ,

Leave a Reply

Your email address will not be published. Required fields are marked *