Behind the Scenes - Testing in Angular 2 with 51zero

At 51zero, we love Angular for creating web-apps for our clients. With Angular 2.0 now firmly in Beta and stabilised, its the perfect time to show you how we're testing in Angular 2.0.

The obvious tool of choice here is Karma with Jasmine (enforced by the angular2/testing module, and also angular2 internal tests).

The key concept which we apply in all tests is to stub / spy on all external dependencies and the methods called within the unit itself.  A small exception would be static methods from generic libraries e.g. Array.forEach, underscorejs etc, which do not hold state.

All of the private methods (even they are exposed as public when transpiled to js, and not truly private) should be tested strictly via the public methods/ events only.  In such cases they could be either stubbed e.g. to simplify the result or mock the behaviour of them.

Angular provides a great way to do so easily with the dependency injection at the core of its framework.

Angular2 has got three types of concepts, which require slightly different setup of tests and logic of testing - Components, Pipes and Services.

The easiest of all is Pipes, as they behave as/are essentially static methods.

First of all lets define the important bits in karma.conf.js file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
config.set({
    frameworks: ['jasmine', 'es6-shim'],
    files: [
        'src/vendors.ts',
        'node_modules/zone.js/dist/jasmine-patch.js',
        {pattern: 'test/**/*-spec.ts', watched: false}
    ],
    preprocessors: {
        'src/vendors.ts': ['webpack', 'sourcemap'],
        'test/**/*-spec.ts': ['webpack', 'sourcemap']
    },
    webpack: {
        resolve: {
        root: __dirname,
        extensions: ['', '.ts', '.js', '.json']
    },
    devtool: 'inline-source-map',
    module: {
        loaders: [
            {
                test: /\.tsx?$/, loader: 'ts-loader', exclude: [/node_modules/]
            }
        ]
    }
}

here src/vendors.ts contains the import statements for all vendor dependencies e.g.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import "es6-promise";
import "es6-shim";
import "reflect-metadata";
import "zone.js/dist/zone-microtask";
import "zone.js/dist/long-stack-trace-zone";
import "angular2/platform/browser";
import "angular2/platform/common_dom";
import "angular2/core";
import "angular2/router";
import "angular2/http";
import "rxjs";

Pipe testing

The pipe under test

1
2
3
4
5
6
7
import {Pipe} from "angular2/core";
@Pipe({ name: "upperCase" })
export class UpperCasePipe {
    transform(value: string) {
        return value.toUpperCase();
    }
}

The test here can be simply done without involving the angular injector - which makes the code framework agnostic. 
The import statements can also be excluded, as they are just wrapper for the global.it, global.describe, etc.  Injected by jasmine into the global object.

Not much of an advantage here except perhaps some of the jasmine.d.ts definitions come for free.

So the following works (which can be ported to any framework as it is ECMA6 compliant after stripping types from typescript if any):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import {
it,
describe,
expect
} from "angular2/testing";
import {UpperCasePipe} from "../../src/home/upper-case-pipe";
 
describe("UpperCasePipe", () => {
    it("should convert the string passed to it to uppercase", () => {
        let upperCasePipe = new UpperCasePipe();
        expect(upperCasePipe.transform("angular2 ")).toEqual("ANGULAR2 ");
    });
});

or alternatively (not my preferred approach) the pipe can be tested within the context of a template - e.g. testing it's selector

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import {BrowserDomAdapter} from "angular2/src/platform/browser/browser_adapter";
import {Component} from "angular2/core";
BrowserDomAdapter.makeCurrent();
import {
it,
injectAsync,
fdescribe,
expect,
TestComponentBuilder
} from "angular2/testing";
import {UpperCasePipe} from "../../src/home/upper-case-pipe";
 
@Component({
    selector: "home",
    pipes: <any>[UpperCasePipe],
    template: "<h3>{{message|upperCase}}</h3>"
})
class TestComponent {
    message = "Hello";
}
fdescribe("UpperCasePipe - test the injector selector", () => {
    it("should wrap content", injectAsync([TestComponentBuilder], tcb => {
        return tcb.createAsync(TestComponent).then(fixture => {
            fixture.detectChanges();
                let compiled = fixture.debugElement.nativeElement;
            expect(compiled.innerText).toContain("HELLO");
        });
    }));
});

Service Testing

Lets define the service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import dispatcher from "../dispatcher";
import {Http} from "angular2/http";
import {Inject} from "angular2/core";
 
export const FETCHED_DATA = "FETCHED_DATA";
 
export class HomePageActions {
    http:Http;
 
    constructor(@Inject(Http)http:Http) {
        this.http = http;
    }
 
    initializeData() {
        this.http.get("api-mock/colors.json").subscribe(data => {
            dispatcher.dispatch({
                type: FETCHED_DATA,
                data: data.json()
            });
 
            setTimeout(
                () => {
                    dispatcher.dispatch({
                        type: FETCHED_DATA,
                        data: data.json()
                    });
                },
                3000
            );
        });
    }
}

and the simplest test with stubbing Http with another class to match the same name and methods used in the class under test

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import {BrowserDomAdapter} from "angular2/src/platform/browser/browser_adapter";
BrowserDomAdapter.makeCurrent();
 
import {
    beforeEach,
    it,
    fdescribe,
    expect
} from "angular2/testing";
 
import dispatcher from "./../../src/dispatcher";
import {HomePageActions} from "../../src/home/home-page-actions";
 
 
import {Observable} from "rxjs/Rx";
import {ResponseOptions} from "angular2/src/http/base_response_options";
import {Response} from "angular2/src/http/static_response";
class Http {
    get() {
        return Observable.from([
            new Response(new ResponseOptions({body: {colors: "red"}}))
        ]);
    }
}
 
let actions:HomePageActions;
let http:Http = new Http();
 
fdescribe("HomePageActions", () => {
    beforeEach(() => {
        spyOn(dispatcher, "dispatch");
 
        actions = new HomePageActions(http);
    });
 
    describe("initializeDatar()", () => {
        it("should call dispatcher.dispatch()", () => {
            actions.initializeData();
 
            expect((<any>dispatcher.dispatch).calls.argsFor(0)).toEqual([
                Object({
                    type: "FETCHED_DATA",
                    data: Object({colors: "red"})
                })
            ]);
        });
    });
});

An alternative way of doing it - by using the angular2 injector instead and spy on the methods used within the class under test. 

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import {BrowserDomAdapter} from "angular2/src/platform/browser/browser_adapter";
BrowserDomAdapter.makeCurrent();
 
import {
    beforeEachProviders,
    beforeEach,
    inject,
    it,
    describe,
    expect
} from "angular2/testing";
 
import dispatcher from "./../../src/dispatcher";
import {HomePageActions} from "../../src/home/home-page-actions";
import {Http, HTTP_PROVIDERS, Response, ResponseOptions} from "angular2/http";
 
import {Observable} from "rxjs/Rx";
 
let actions:HomePageActions;
let http:Http;
 
describe("HomePageActions", () => {
    beforeEachProviders(() => [Http, HTTP_PROVIDERS]);
 
    beforeEach(inject([Http], _ => {
        http = _;
 
        spyOn(dispatcher, "dispatch");
 
        spyOn(http, "get").and.returnValue(Observable.from([
            new Response(new ResponseOptions({body: {colors: "red"}}))
        ]));
 
        actions = new HomePageActions(http);
    }));
 
    describe("initializeData()", () => {
        it("should call dispatcher.dispatch()", () => {
            actions.initializeData();
 
            expect((<any>dispatcher.dispatch).calls.argsFor(0)).toEqual([
                Object({
                    type: "FETCHED_DATA",
                    data: Object({colors: "red"})
                })
            ]);
        });
    });
});

Component testing

Component can be tested in a few ways

  1. As a normal typescript / ECMA6 class, without involving angular injector (which may not suit all components - e.g. stubbing all the dependencies may become a more tedious task, compared to leaving the injector to do the wiring).
  2. As a normal typescript / ECMA6 class, but with the addition of the injector and/or provider - then getting an instance of the component, created by angular. It is my preferred approach. It would usually involve stubbing the template to avoid http request calls.
  3. Testing the actual dom generated by the template from the component, events, expected behavior etc.
     

Lets define the component

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import {Component, Inject, OnInit, OnDestroy} from "angular2/core";
import {CounterActions} from "./counter-actions";
 
import {CounterStore} from "./counter-store";
 
@Component({
    selector: "counter",
    providers: [CounterActions, CounterStore],
    templateUrl: "./src/counter/counter.html"
})
export class CounterPageComponent implements OnInit, OnDestroy {
    counter:number = 0;
 
    private counterActions;
    private counterStore;
 
    constructor(@Inject(CounterActions)counterActions:CounterActions,
                @Inject(CounterStore)counterStore:CounterStore) {
        this.counterActions = counterActions;
        this.counterStore = counterStore;
    }
 
    ngOnInit() {
        this.counter = this.counterStore.getCounter();
        this.counterStore.subscribe(() => this.counter = this.counterStore.getCounter());
    }
 
    ngOnDestroy() {
        this.counterStore.unsubscribe();
    }
 
    increment() {
        this.counterActions.increment();
    }
 
    decrement() {
        this.counterActions.decrement();
    }
 
    reset() {
        this.counterActions.reset();
    }
}

Testing without angular2 injector involved

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import {BrowserDomAdapter} from "angular2/src/platform/browser/browser_adapter";
BrowserDomAdapter.makeCurrent();
 
import {
    beforeEach,
    it,
    fdescribe,
    expect,
} from "angular2/testing";
import {CounterPageComponent} from "../../src/counter/counter-page-component";
import {CounterStore} from "../../src/counter/counter-store";
import {CounterActions} from "../../src/counter/counter-actions";
 
fdescribe("CounterPageComponent", () => {
    let component:any;
    let actions:any;
    let store:any;
 
    beforeEach(() => {
        store = new CounterStore(); //or entirely overwritten stub if necessary
        actions = new CounterActions(); //same here
 
        spyOn(store, "subscribe");
        spyOn(store, "getCounter").and.returnValue(33);
        spyOn(actions, "increment");
        spyOn(actions, "decrement");
        spyOn(actions, "reset");
 
        component = new CounterPageComponent(actions, store);
    });
 
    describe("ngOnInit()", () => {
        beforeEach(() => {
            component.ngOnInit();
        });
 
        it("should call getCounter() to get initial value", ()  => {
            expect(store.getCounter.calls.count()).toEqual(1);
        });
 
        it("should subscribe to the counterStore", ()  => {
            let subscribeCallback = store.subscribe.calls.argsFor(0)[0];
            subscribeCallback();
 
            expect(store.getCounter.calls.count()).toEqual(2);
            expect(component.counter).toEqual(33);
        });
    });
 
    describe("increment()", () => {
        it("should proxy to counterActions.increment()", () => {
            component.increment();
 
            expect(actions.increment.calls.count()).toEqual(1);
        });
    });
 
    describe("decrement()", () => {
        it("should proxy to counterActions.decrement()", () => {
            component.decrement();
 
            expect(actions.decrement.calls.count()).toEqual(1);
        });
    });
 
    describe("reset()", () => {
        it("should proxy to counterActions.reset()", () => {
            component.reset();
 
            expect(actions.reset.calls.count()).toEqual(1);
        });
    });
});

Testing with the injector but without involving the template

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import {BrowserDomAdapter} from "angular2/src/platform/browser/browser_adapter";
BrowserDomAdapter.makeCurrent();
 
import {
    beforeEachProviders,
    beforeEach,
    inject,
    it,
    describe,
    expect,
    TestComponentBuilder
} from "angular2/testing";
import {provide} from "angular2/core";
import {CounterPageComponent} from "../../src/counter/counter-page-component";
import {CounterStore} from "../../src/counter/counter-store";
import {CounterActions} from "../../src/counter/counter-actions";
 
describe("CounterPageComponent", () => {
    let component:any;
    let actions:any;
    let store:any;
 
    beforeEachProviders(() => [CounterActions, CounterStore]);
 
    beforeEach(inject([TestComponentBuilder], tcb => {
        store = new CounterStore();
        actions = new CounterActions();
 
        spyOn(store, "subscribe");
        spyOn(store, "getCounter").and.returnValue(33);
        spyOn(actions, "increment");
        spyOn(actions, "decrement");
        spyOn(actions, "reset");
 
        tcb.overrideTemplate(CounterPageComponent, "<sec></sec>")
            .overrideProviders(CounterPageComponent, [
                provide(CounterActions, {useValue: actions}),
                provide(CounterStore, {useValue: store}) // or useFactory: () => {let counterStore = new CounterStore() //spy and return counterStore}
            ])
            .createAsync(CounterPageComponent)
            .then(f => component = f.componentInstance);
    }));
 
    describe("ngOnInit()", () => {
        beforeEach(() => {
            component.ngOnInit();
        });
 
        it("should call getCounter() to get initial value", ()  => {
            expect(store.getCounter.calls.count()).toEqual(1);
        });
 
        it("should subscribe to the counterStore", ()  => {
            let subscribeCallback = store.subscribe.calls.argsFor(0)[0];
            subscribeCallback();
 
            expect(store.getCounter.calls.count()).toEqual(2);
            expect(component.counter).toEqual(33);
        });
    });
 
    describe("increment()", () => {
        it("should proxy to counterActions.increment()", () => {
            component.increment();
 
            expect(actions.increment.calls.count()).toEqual(1);
        });
    });
 
    describe("decrement()", () => {
        it("should proxy to counterActions.decrement()", () => {
            component.decrement();
 
            expect(actions.decrement.calls.count()).toEqual(1);
        });
    });
 
    describe("reset()", () => {
        it("should proxy to counterActions.reset()", () => {
            component.reset();
 
            expect(actions.reset.calls.count()).toEqual(1);
        });
    });
});

Testing with compiling the template

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import {BrowserDomAdapter} from "angular2/src/platform/browser/browser_adapter";
BrowserDomAdapter.makeCurrent();
 
import {
    beforeEachProviders,
    beforeEach,
    injectAsync,
    it,
    fdescribe,
    TestComponentBuilder
} from "angular2/testing";
import {CounterPageComponent} from "../../src/counter/counter-page-component";
import {CounterStore} from "../../src/counter/counter-store";
import {CounterActions} from "../../src/counter/counter-actions";
import {provide} from "angular2/core";
import {EventEmitter} from "angular2/core";
 
 
fdescribe("CounterPageComponent", () => {
    let component:any;
    let actions:any;
    let store:any;
    let eventEmitter = new EventEmitter();
 
    beforeEachProviders(() => [CounterActions, CounterStore]);
 
    beforeEach(injectAsync([TestComponentBuilder], tcb => {
        store = new CounterStore();
        actions = new CounterActions();
        let getCounterValue = 33;
 
        spyOn(store, "subscribe").and.callFake(callback => {
            eventEmitter.subscribe(() => callback());
        });
        spyOn(store, "getCounter").and.returnValue(getCounterValue++);
        spyOn(actions, "increment");
        spyOn(actions, "decrement");
        spyOn(actions, "reset");
 
        //or simply leave the template to get loaded, but having it here will save us an http call
        return tcb.overrideTemplate(
            CounterPageComponent,
            `<p>Counter Value: {{counter}}<button (click)="increment()">+</button>
                <button (click)="decrement()">-</button>
                <button (click)="reset()">Reset</button>
            </p>`
            )
            .overrideProviders(CounterPageComponent, [
                provide(CounterActions, {useValue: actions}),
                provide(CounterStore, {useValue: store}) // or useFactory: () => {let counterStore = new CounterStore() //spy and return counterStore}
            ])
            .createAsync(CounterPageComponent)
            .then(f => {
                f.detectChanges();
 
                component = f.debugElement.nativeElement;
            });
    }));
 
    describe("component initialized", () => {
        it("should have the initial value displayed", ()  => {
            expect(component.innerText).toContain("Counter Value: " + 33);
        });
    });
 
  // etc with the remaining tests
});

There are also more scenarios which are not covered here (e.g. testing zone.js / http provider with using mocking provider etc)

There are various way to organise your tests and you need to be mindful when choosing an appropriate strategy for it.  One strategy we're tending towards is keeping the tests decoupled from Angular2 where possible, this makes the code easier to migrate to alternative framework, move to plain javascript or use the injector to reduce the dependencies creation.

Let us know in the comments below your thoughts on Angular2 and its implementation.

Further reading: Angular Testing