A Test-Driven Development Introduction to Angular 2 – Part 2
Updated for Angular 2 Release – I originally wrote this article/application when Angular 2 was still in beta. Now that it has released officially, I have updated the source code to reflect updates made to the Angular 2 framework for release. My approach to get this working was to start from the new Angular 2 quick-start project, port in the original application source code, and refactor as needed to make things work.
Below are my notes from this process:
- TestBed – The TestBed class is the primary Angular testing utility. Before I had used
beforeEachProvider
to set up dependency injection for tests, but this has been replaced byTestBed
.TestBed
is responsible for doing most of the heavy lifting for component test setup. You can pass all of the providers needed for dependency injection and any imports needed likeFormsModule
.TestBed
has in my opinion has cleaned up the set up code required and made it a little more straightforward. - FormsModule – This was another new class that wasn’t in my original code. I’ve found it’s necessary to import this module if you are using
ngModel
on any form input fields, otherwise you’ll see some strange message along the lines ofngModel not recognized as an attribute for input
. - Debugging tests can be tricky. Through this process I encountered quite a few error messages in writing my tests that gave me no clue as to what was going on. This can be frustrating and required a bit of patience.
The updated source code for the example project can be found here.
—
AngularJS is a popular framework used for building single-page applications. One great benefit of using Angular is that it is easy to incorporate automated testing.
I have been using Angular on various projects for a few years now, so naturally I was curious to learn what’s new in Angular 2. In this article, I will be walking through the process of creating a simple Angular 2 application with integrated unit testing.
What We Are Building
For this article we will be building a simple employee directory web application. This will be a straightforward CRUD app that will display a list of our employee data and allow users to add, edit, and remove employees. You can find the full source code here.
Below is our file structure:
app/ employees/ add/ employee-add.component.html employee-add.component.ts data/ employee-data.ts mock-data.ts edit/ employee-edit.component.html employee-edit.component.ts models/ employee.ts services/ employee.service.ts app.component.ts main.ts test/ employees/ add/ employee-add.spec.ts edit/ employee-edit.spec.ts services/ employee.service.spec.ts employees.spec.ts
Our test folder mirrors the structure of our app folder so that it is easy to see what a spec is testing.
Typescript
Most of the current Angular 2 documentation is written in TypeScript, so I will be using it in this project. TypeScript gives the added benefit of ECMAScript 2015 features and strong object types. TypeScript files have a .ts
extension and are compiled to .js
files.
The compiler will give you errors that I’ve found to be useful in debugging as I have put this project together. You can read more about TypeScript at its website.
Test-Driven Development
Jasmine and Karma test runner will serve as our tools for unit testing. I will be using the test-driven development approach to build this application.
First we write unit tests that will fail initially and then build the components out until we have passing tests and then refactor as necessary. I’ve found this to be a useful approach as it encourages you to write the minimum amount of code necessary to get tests passing and work in small units.
Let’s Get Started!
To get the project up and running, I’ve used the 5 Minute QuickStart from the Angular 2 documentation site.
I’ve slightly modified the original package.json
file to add Karma to the devDependencies
. Running the command npm install
will install our dependencies we need to test and run our application. I’ve also added the test command
to scripts. The command npm test
will run the TypeScript compiler and then run our unit tests. npm start
will start our development server for running our application locally.
package.json:
{ "name": "angular-quickstart", "version": "1.0.0", "description": "QuickStart package.json from the documentation, supplemented with testing support", "scripts": { "start": "tsc && concurrently \"tsc -w\" \"lite-server\" ", "e2e": "tsc && concurrently \"http-server -s\" \"protractor protractor.config.js\" --kill-others --success first", "lint": "tslint ./app/**/*.ts -t verbose", "lite": "lite-server", "pree2e": "webdriver-manager update", "test": "tsc && concurrently \"tsc -w\" \"karma start karma.conf.js\"", "test-once": "tsc && karma start karma.conf.js --single-run", "tsc": "tsc", "tsc:w": "tsc -w" }, "keywords": [], "author": "", "license": "MIT", "dependencies": { "@angular/common": "~2.4.0", "@angular/compiler": "~2.4.0", "@angular/core": "~2.4.0", "@angular/forms": "~2.4.0", "@angular/http": "~2.4.0", "@angular/platform-browser": "~2.4.0", "@angular/platform-browser-dynamic": "~2.4.0", "@angular/router": "~3.4.0", "angular-in-memory-web-api": "~0.2.4", "systemjs": "0.19.40", "core-js": "^2.4.1", "rxjs": "5.0.1", "zone.js": "^0.7.6" }, "devDependencies": { "concurrently": "^3.1.0", "lite-server": "^2.2.2", "typescript": "~2.0.10", "canonical-path": "0.0.2", "http-server": "^0.9.0", "tslint": "^3.15.1", "lodash": "^4.16.4", "jasmine-core": "~2.4.1", "karma": "^1.3.0", "karma-chrome-launcher": "^2.0.0", "karma-cli": "^1.0.1", "karma-jasmine": "^1.0.2", "karma-jasmine-html-reporter": "^0.2.2", "protractor": "~4.0.14", "rimraf": "^2.5.4", "@types/node": "^6.0.46", "@types/jasmine": "^2.5.36" }, "repository": {} }
Karma Configuration
There are two files in the source used to configure Karma to run our tests: karma.conf.js
and karma-test-shim.js
. The important part here is having the correct path to our test spec files and all of our app source files. These files can be found in the full source code.
Angular App and Routing Setup
Now that we have our testing configured, we will write the code to initialize our Angular 2 application along with the router for the app. Below is the code for our main app file. You’ll notice imports for the view components that correspond to what used to be controllers in version 1. I will talk about those changes later.
The first thing we do is set up our Router in app-routing.module.ts
. Each route is given a path, link name, and the view
component that will be used. Also notice we use the default ''
empty path to redirect to /employees
.
Next we set up the @Component
. This one is pretty simple. The selector
property is the DOM element where the component template will inserted. Our template for this component is also pretty simple. Notice backticks can be used to enclose multi-line templates. The router-outlet
element is important here. This is where our view
components from our router will go. Also we declare any directives and service providers here as well.
app.component.ts
import { Component } from '@angular/core'; @Component({ selector: 'directory-app', template: ` <h1>{{title}}</h1> <router-outlet></router-outlet> `, }) export class AppComponent { name = 'Employee Directory'; }
app-routing.module.ts
import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { EmployeesComponent } from './employees/employees.component'; import { EmployeeEditComponent } from './employees/edit/employee-edit.component'; import { EmployeeAddComponent } from './employees/add/employee-add.component'; const routes: Routes = [ { path: '', redirectTo: '/employees', pathMatch: 'full' }, { path: 'employees', component: EmployeesComponent }, { path: 'edit/:id', component: EmployeeEditComponent }, { path: 'add', component: EmployeeAddComponent } ]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] }) export class AppRoutingModule { }
app.module.ts
import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RouterModule } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { EmployeesComponent } from './employees/employees.component'; import { EmployeeEditComponent } from './employees/edit/employee-edit.component'; import { EmployeeAddComponent } from './employees/add/employee-add.component'; import { EmployeeService } from './employees/services/employee.service'; import { AppComponent } from './app.component'; import { AppRoutingModule } from './app-routing.module'; @NgModule({ imports: [ BrowserModule, AppRoutingModule, FormsModule ], declarations: [ AppComponent, EmployeesComponent, EmployeeEditComponent, EmployeeAddComponent ], providers: [ EmployeeService ], bootstrap: [AppComponent] }) export class AppModule { }
To kick off the application in our main.ts
file, all we need to do is import the AppComponent
we just built and then pass it to the bootstrap
function to get things started.
main.ts
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app.module'; platformBrowserDynamic().bootstrapModule(AppModule);
Employee Data Model
Below is the data model we will be using throughout the project. Notice that each property is assigned a data type. If we try to assign a data type that is not expected, the TypeScript compiler will throw an error.
app/employees/data/employee.ts
export class Employee { id: number; firstName: string; lastName: string; phone: string; email: string; createDate: string; }
Our First Test
Now that we have a foundation for the application in place, it is time to write our first test and build out the employee
service. This service will be responsible for interacting with the data source. We will need our service to be able to perform the basic CRUD operations.
Setting up the test for the service is straightforward as it has no dependencies that we need to inject for the tests. We simply create a new EmployeeService
and set the data source to some mock data.
test/employees/services/employees.service.spec.ts
import { Employee } from '../../../app/employees/models/employee'; import { EmployeeService } from '../../../app/employees/services/employee.service'; import { MOCK_DATA } from '../../../app/employees/data/mock-data'; describe('Employee Service Tests', () => { let employeeService = new EmployeeService(); employeeService.data = MOCK_DATA; });
This is the mock testing data:
app/employees/data/mock-data.ts
import {Employee} from '../../../app/employees/models/employee'; export var MOCK_DATA: Employee[] = [ { "id": 1, "firstName": "Test1", "lastName": "Employee1", "phone": "111-111-1111", "email": "test1@employee.com", "createDate": "1/1/1999" }, { "id": 2, "firstName": "Test2", "lastName": "Employee2", "phone": "111-111-1112", "email": "test2@employee.com", "createDate": "1/1/2002" }, { "id": 3, "firstName": "Test3", "lastName": "Employee3", "phone": "111-111-1113", "email": "test3@employee.com", "createDate": "1/1/2006" } ];
Let’s break down the first test case. We pass in a description of what is being tested. Take note of the .then()
used on the value returned from the service. Since our service will be returning a promise, the data will not be available immediately for executing our test.
First, we write a simple test for getting the list of employee data
objects.
it('returns a list of employees', () => { employeeService.getEmployees() .then(employees => { expect(employees.length).toBeDefined(); expect(employees.length).toBe(3); }); });
Time to finish out the rest of the service tests here. We would also expect the service to have the ability to get a single employee by ID, add a new employee, and remove an existing employee.
it('returns a single employee by id', () => { let testEmployee = (employee: Employee) => { expect(employee).toBeDefined(); expect(employee.firstName).toBe('Test2'); expect(employee.lastName).toBe('Employee2'); }; employeeService.getEmployee(2) .then(testEmployee); }); it('add a new employee', () => { let newEmployee: Employee = new Employee(); let testNewEmployee = (employee: Employee) => { expect(employee).toBeDefined(); expect(employee.firstName).toBe('John'); expect(employee.lastName).toBe('Doe'); }; newEmployee.id = 222; newEmployee.firstName = 'John'; newEmployee.lastName = 'Doe'; employeeService.addEmployee(newEmployee) .then(() => employeeService.getEmployee(222).then(testNewEmployee)); }); it('removes an employee', () => { let employeeCount = 0; let postRemoveCallback = () => employeeService.getEmployees() .then(postEmployees => expect(postEmployees.length).toBe(employeeCount - 1)); let getEmployeeCallback = (employee: Employee) => employeeService.removeEmployee(employee) .then(postRemoveCallback); let preRemoveCallback = (preEmployees: Employee[]) => { employeeCount = preEmployees.length; employeeService.getEmployee(1) .then(getEmployeeCallback); }; employeeService.getEmployees() .then(preRemoveCallback); });
Before we can run these service tests, we will need to scaffold out our service.
app/employees/services/employee.service.ts
import { Injectable } from '@angular/core'; import { Employee } from '../models/employee'; import { EMPLOYEES } from '../data/employee-data' @Injectable() export class EmployeeService { NEW_ID = 16; data = EMPLOYEES constructor() { } getEmployees(): Promise<Employee[]> { return Promise.resolve([new Employee()]); } getEmployee(id: number): Promise<Employee> { return Promise.resolve(new Employee()); } addEmployee(employee: Employee): Promise<void> { return Promise.resolve(); } removeEmployee(employee: Employee): Promise<void> { return Promise.resolve(); } }
In Angular 2, @Injectable
is used for creating services. We add this notation to the EmployeeService
class as shown above. We are using JSON data from a file in our project. Normally a service like this would be interacting with a server through AJAX calls for receiving and persisting data. But for the purposes of this article we are keeping things simple.
Our service test is written and the service is scaffolded out. If we run the npm test
command, we should see that our tests are being run, but none of them are passing, which is what would be expected at this point. Let’s flesh out our service
methods so we can get those tests passing.
getEmployees(): Promise<Employee[]> { return Promise.resolve(this.data); }
For the getEmployees
, we simply return our data. Run our test again and we will have our first passing test case.
No time to celebrate. Let’s get the rest passing.
getEmployee(id: number): Promise<Employee> { return Promise.resolve(this.data).then( employees => employees.filter(employee => employee.id === id)[0] ) } addEmployee(employee: Employee): Promise<void> { let today = new Date(); let month = today.getMonth() + 1; let date = today.getDate(); let year = today.getFullYear(); if (!employee.id) { employee.id = this.NEW_ID++; } if (!employee.createDate) { employee.createDate = month + '/' + date + '/' + year; } return Promise.resolve(this.data) .then(employees => employees.push(employee)); } removeEmployee(employee: Employee): Promise<void> { let index = this.data.indexOf(employee); return Promise.resolve(this.data) .then(employees => employees.splice(index, 1)); }
You’ll notice one of the nice features of using TypeScript is that we can use arrow =>
functions. This is simply a shorthand way of writing functions. We make use of these for our callbacks above. Now if we try running our tests again they should all pass this time. Now we have a fully functioning service and an automated test to let us now if any of our changes in the future break our base functionality. Time for us to move on to our view components next.
Make Components…Not Controllers
We are ready to build our views. In version 1, a view would consist of a controller and the $scope
object that would keep track of all the view data/functions. Version 2 does away with all of that. Instead of controllers we will be building components.
Let’s get started by building our test for our first component. This view
will be responsible for displaying the list of employee data and will be the page our users initially land on.
test/employees/employees.spec.ts
import { ComponentFixture, TestBed, inject, async } from '@angular/core/testing'; import { Router } from '@angular/router'; import { By } from '@angular/platform-browser'; import { DebugElement } from '@angular/core' import { EmployeeService } from '../../app/employees/services/employee.service'; import { EmployeesComponent } from '../../app/employees/employees.component'; describe('Employee Component Tests', () => { let comp: EmployeesComponent; let fixture: ComponentFixture<EmployeesComponent>; let routerMock: any; let employeeServiceMock: any; let serviceSpy: any; let routerSpy: any; let de: DebugElement; let el: HTMLElement; beforeEach(async(() => { routerMock = { navigate: jasmine.createSpy('navigate') }; employeeServiceMock = { getEmployees: jasmine.createSpy('getEmployees') .and.returnValue(Promise.resolve([{}, {}, {}])), removeEmployee: jasmine.createSpy('removeEmployee') }; TestBed .configureTestingModule({ declarations: [EmployeesComponent], providers: [ { provide: EmployeeService, useValue: employeeServiceMock }, { provide: Router, useValue: routerMock } ] }) .compileComponents() .then(() => { fixture = TestBed.createComponent(EmployeesComponent); comp = fixture.componentInstance; de = fixture.debugElement.query(By.css('table tbody')); el = de.nativeElement; serviceSpy = TestBed.get(EmployeeService); routerSpy = TestBed.get(Router); }); })); });
This is the set up for our employee
component test spec. As you can see, it is slightly more complicated than the set up for our previous test. This is due to our view
components having the Router
as a dependency that we will need to inject for testing purposes.
We set up our injector providers in Angular’s TestBed
which is used to declare the component we want to build for the test and dependency injection providers. Values are specified for what Angular should be injecting when we create our component in the test. The Location
object will be used for testing if our Router
is taking us to the proper paths.
Now we are ready to write test cases for our view. The main functions we want to test on this component are retrieving employee data on initialization, delete functionality, and allowing our users to navigate to the edit and add employee views.
it('should fetch the employee list on init', async(() => { comp.ngOnInit(); expect(serviceSpy.getEmployees).toHaveBeenCalled(); fixture.detectChanges(); fixture.whenStable() .then(() => { fixture.detectChanges(); expect(comp.employees.length).toBe(3); expect(el.getElementsByTagName('tr').length).toBe(3); }); })); it('should remove employees selected to be deleted', () => { comp.deleteEmployee(null); expect(employeeServiceMock.removeEmployee).toHaveBeenCalledTimes(1); }); it('should navigate to the edit page', () => { comp.goToEdit(55); fixture.whenStable() .then(() => expect(routerSpy.navigate).toHaveBeenCalledWith(['/edit/55'])); }); it('should navigate to the add a new employee page', () => { comp.goToAdd(); fixture.whenStable() .then(() => expect(routerSpy.navigate).toHaveBeenCalledWith(['/add'])); });
Now we will scaffold out our component. A couple of interesting things going on here. The EmployeesComponent
implements the OnInit
class. What this does is when the component is initialized, it will automatically call ngOnInit()
. This is where we will instruct our component to retrieve our data for display on the page.
Also, in the constructor, Angular’s dependency injection will provide our component with the EmployeeService
mock object that we build previously and the Router
spy object so we can navigate our users to other views.
app/employees/employees.component.ts
import { Component, OnInit } from '@angular/core'; import { Employee } from './models/employee'; import { EmployeeService } from './services/employee.service' import { Router } from '@angular/router'; @Component({ templateUrl: 'app/employees/employees.component.html' }) export class EmployeesComponent implements OnInit { title = 'Employee Directory'; employees: Employee[]; constructor( private _employeeService: EmployeeService, private _router: Router ) { } getEmployees() { } ngOnInit() { } deleteEmployee(employee: Employee) { } goToEdit(id: number) { } goToAdd() { } }
Let’s fix our failing unit tests fleshing out our component
methods:
getEmployees() { this._employeeService.getEmployees() .then(employees => this.employees = employees); } ngOnInit() { this.getEmployees(); } deleteEmployee(employee: Employee) { this._employeeService.removeEmployee(employee); } goToEdit(id: number) { this._router.navigate(['/edit/' + id ]); } goToAdd() { this._router.navigate(['/add']); }
Nothing too complicated going on here. We interact with our EmployeeService
to retrieve or remove Employees and use the Router to move our users to other views. Something else we should look at is the HTML template for this view:
app/employees/employees.component.html
<div> <h1>{{title}}</h1> <table class="table table-striped"> <thead> <tr> <td>Name</td> <td>Phone</td> <td>Email</td> <td>Created Date</td> <td class="text-right"> <button (click)="goToAdd()" class="btn btn-primary btn-sm"> <span class="glyphicon glyphicon-plus"></span> </button> </td> </tr> </thead> <tbody> <tr *ngFor="let employee of employees"> <td>{{employee.lastName}}, {{employee.firstName}}</td> <td>{{employee.phone}}</td> <td>{{employee.email}}</td> <td>{{employee.createDate}}</td> <td class="text-right"> <button (click)="goToEdit(employee.id)" class="btn btn-success btn-sm"> <span class="glyphicon glyphicon-pencil"></span> </button> <button (click)="deleteEmployee(employee)" class="btn btn-danger btn-sm"> <span class="glyphicon glyphicon-remove"></span> </button> </td> </tr> </tbody> </table> </div>
If you are familiar with version 1, you’ll notice the markup for event handlers and loops have changed. Rather than ng-click
, a click event is set up using (click)
. A loop is declared using *ngFor=" let item of list"
. The brackets {{ }}
for displaying data still work the same way.
Add and Edit Components
For the sake of brevity I will not go through all of the code for the add
and edit
view components, but the code can be found below. Instead I’ll go over a few specific pieces with parts that haven’t already been covered.
test/employees/add/employee-add.spec.ts
import { ComponentFixture, TestBed, inject, async } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { Location } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; import { Router } from '@angular/router'; import {Employee} from '../../../app/employees/models/employee'; import {EmployeeService} from '../../../app/employees/services/employee.service'; import {EmployeeAddComponent} from '../../../app/employees/add/employee-add.component'; describe('Employee Add Component Tests', () => { let comp: EmployeeAddComponent; let fixture: ComponentFixture<EmployeeAddComponent>; let employeeServiceMock: any; let routerMock: any; let locationMock: any; let locationSpy: any; beforeEach(async(() => { routerMock = { navigate: jasmine.createSpy('navigate') }; locationMock = { back: jasmine.createSpy('back') }; employeeServiceMock = { addEmployee: jasmine.createSpy('addEmployee') .and.returnValue(Promise.resolve()) }; TestBed .configureTestingModule({ imports: [ RouterTestingModule.withRoutes([]), FormsModule ], declarations: [EmployeeAddComponent], providers: [ { provide: EmployeeService, useValue: employeeServiceMock }, { provide: Location, useValue: locationMock }, { provide: Router, useValue: routerMock } ] }) .compileComponents() .then(() => { fixture = TestBed.createComponent(EmployeeAddComponent); comp = fixture.componentInstance; locationSpy = TestBed.get(Location); }); })); it('should create a new employee', () => { let employeeCount:number; let newEmployee: Employee; comp.ngOnInit(); comp.saveEmployee(null); expect(employeeServiceMock.addEmployee).toHaveBeenCalledTimes(1); fixture.detectChanges(); fixture.whenStable() .then(() => expect(locationSpy.back).toHaveBeenCalled()); }); it('should navigate to the employee list page on cancel', () => { comp.cancelAdd(null); fixture.whenStable() .then(() => expect(locationSpy.back).toHaveBeenCalled()); }); });
add/employees/add/employee-add.component.ts
import { Component, OnInit } from '@angular/core'; import { Location } from '@angular/common'; import { Employee } from '../models/employee'; import { Router } from '@angular/router'; import { EmployeeService } from '../services/employee.service'; @Component({ templateUrl: 'app/employees/add/employee-add.component.html' }) export class EmployeeAddComponent implements OnInit { title = 'Add New Employee' newEmployee: Employee; constructor( private _employeeService: EmployeeService, private _location: Location ) { } ngOnInit() { this.newEmployee = new Employee(); } saveEmployee(event: any) { let _this = this; this._employeeService.addEmployee(this.newEmployee) .then(function () { _this._location.back(); }); } cancelAdd(event: any) { this._location.back(); } }
add/employees/add/employee-add.component.html
<h2>{{title}}</h2> <form class="col-sm-5"> <div class="form-group"> <label for="first-name">First Name</label> <input [(ngModel)]="newEmployee.firstName" type="text" class="form-control" name="first-name" id="first-name" placeholder="First Name"> </div> <div class="form-group"> <label for="last-name">Last Name</label> <input [(ngModel)]="newEmployee.lastName" type="text" class="form-control" name="last-name" id="last-name" placeholder="Last Name"> </div> <div class="form-group"> <label for="email">Email</label> <input [(ngModel)]="newEmployee.email" type="text" class="form-control" name="email" id="email" placeholder="Email"> </div> <div class="form-group"> <label for="phone">Phone</label> <input [(ngModel)]="newEmployee.phone" type="text" class="form-control" name="phone" id="phone" placeholder="Phone"> </div> <button (click)="saveEmployee()" type="button" class="btn btn-default">Save</button> <button (click)="cancelAdd()" type="button" class="btn btn-danger">Cancel</button> </form>
test/employees/edit/employee-edit.spec.ts
import { ComponentFixture, TestBed, inject, async } from '@angular/core/testing'; import { Location } from '@angular/common'; import { Router, ActivatedRoute, Data } from '@angular/router'; import { Component } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; import { DebugElement } from '@angular/core' import { By } from '@angular/platform-browser'; import { EmployeeService } from '../../../app/employees/services/employee.service'; import { EmployeeEditComponent } from '../../../app/employees/edit/employee-edit.component'; import { Employee } from '../../../app/employees/models/employee'; describe('Employee Edit Component Tests', () => { let comp: EmployeeEditComponent; let fixture: ComponentFixture<EmployeeEditComponent>; let employeeServiceMock: any; let routerMock: any; let employeeMock: Employee; let locationMock: any; let locationSpy: any; beforeEach(async(() => { employeeMock = new Employee(); employeeMock.firstName = 'John'; employeeMock.lastName = 'Doe'; employeeMock.phone = '111-222-3344'; employeeMock.email = 'jdoe@test.com'; routerMock = { navigate: jasmine.createSpy('navigate') }; locationMock = { back: jasmine.createSpy('back') }; employeeServiceMock = { getEmployee: jasmine.createSpy('getEmployee') .and.returnValue(Promise.resolve(employeeMock)) }; TestBed .configureTestingModule({ imports: [ RouterTestingModule.withRoutes([]), FormsModule ], declarations: [EmployeeEditComponent], providers: [ { provide: EmployeeService, useValue: employeeServiceMock }, { provide: Location, useValue: locationMock }, { provide: Router, useValue: routerMock }, { provide: ActivatedRoute, useValue: { params: { subscribe: (fn: (value: Data) => void) => fn({ id: 1 }) } } } ] }) .compileComponents() .then(() => { fixture = TestBed.createComponent(EmployeeEditComponent); comp = fixture.componentInstance; locationSpy = TestBed.get(Location); }); })); it('should fetch an employee object on init', async(() => { comp.ngOnInit(); fixture.whenStable() .then(() => { expect(comp.employee).toBeDefined(); expect(comp.employee.firstName).toBe('John'); expect(comp.employee.lastName).toBe('Doe'); }); })); it('should navigate to the employee list page', () => { comp.backToDirectory({}); fixture.whenStable() .then(() => expect(locationSpy.back).toHaveBeenCalled()); }); });
app/employees/edit/employee-edit.component.ts
import { Component, OnInit } from '@angular/core'; import { Location } from '@angular/common'; import { Employee } from '../models/employee'; import { Router, ActivatedRoute, Params } from '@angular/router'; import { EmployeeService } from '../services/employee.service'; import { Observable } from 'rxjs/Observable'; @Component({ selector: 'employee-detail', templateUrl: 'app/employees/edit/employee-edit.component.html' }) export class EmployeeEditComponent implements OnInit { employee: Employee; constructor( private _employeeService: EmployeeService, private _route: ActivatedRoute, private _location: Location ) { } ngOnInit() { this._route.params.subscribe(params => { this._employeeService.getEmployee(+params['id']) .then(employee => this.employee = employee); }); } backToDirectory(event: any) { this._location.back(); } }
app/employees/edit/employee-edit.component.html
<div *ngIf="employee"> <h2>Edit Contact Information for {{employee.firstName}} {{employee.lastName}}</h2> <form class="col-sm-4"> <div class="form-group"> <label for="email">Email</label> <input type="text" [(ngModel)]="employee.email" name="email" class="form-control" id="email"> </div> <div class="form-group"> <label for="phone">Phone</label> <input type="text" [(ngModel)]="employee.phone" name="phone" class="form-control" id="phone"> </div> <button (click)="backToDirectory()" class="btn btn-default" type="button">Done</button> </form> </div>
Route Parameters in Components
The EmployeeEditComponent
makes use of a route parameter. If you recall in our Router setup, we are passing in an id to this view so it’s aware of what employee
object needs to be retrieved.
In the constructor there is an ActivatedRoute
object. To retrieve parameters from this object this._route.params.subscribe()
. This method returns an Observable
that we get a parameter map which we use params['id']
to retrieve the employee id that was passed in.
export class EmployeeEditComponent implements OnInit { employee: Employee; constructor( private _employeeService: EmployeeService, private _route: ActivatedRoute, private _location: Location ) { } ngOnInit() { this._route.params.subscribe(params => { this._employeeService.getEmployee(+params['id']) .then(employee => this.employee = employee); }); } }
Databinding: ngModel
Below is piece of the template for the add a new employee view. The ngModel
markup has changed from version 1. Previously you would bind input fields to models using ng-model
. Now the syntax has changed to [(ngModel)]
.
<div class="form-group"> <label for="first-name">First Name</label> <input [(ngModel)]="newEmployee.firstName" type="text" class="form-control" name="first-name" id="first-name" placeholder="First Name"> </div> <div class="form-group"> <label for="last-name">Last Name</label> <input [(ngModel)]="newEmployee.lastName" type="text" class="form-control" name="last-name" id="last-name" placeholder="Last Name"> </div> <div class="form-group"> <label for="email">Email</label> <input [(ngModel)]="newEmployee.email" type="text" class="form-control" name="email" id="email" placeholder="Email"> </div> <div class="form-group"> <label for="phone">Phone</label> <input [(ngModel)]="newEmployee.phone" type="text" class="form-control" name="phone" id="phone" placeholder="Phone"> </div>
Let’s Wrap This Up
We’ve built an employee directory using Angular 2 with unit tests, gone over some differences between Angular 2 and version 1, and introduced some of the features of TypeScript.
This is obviously just scratching the surface of what Angular 2 can do, but I’ve introduced some of the basics in this article that you can build on. As an developer who uses Angular I will be interested to see how quickly (or slowly) version 2 will be adopted.
Let me know if you have any thoughts, comments, or questions
Reference: | A Test-Driven Development Introduction to Angular 2 – Part 2 from our WCG partner Matthew Brown at the Keyhole Software blog. |