This tutorial shows you how to build a simple search and edit application using Angular and Angular CLI version 7.0.
If you’d rather use Angular 6, see this version (also available via DocGist). The Angular 5 version is available here.
💡
|
It appears you’re reading this document on GitHub. If you want a prettier view, install Asciidoctor.js Live Preview for Chrome, then view the raw document. Another option is to use the DocGist view. |
If you’d like to get right to it, the source is on GitHub. To run the app, use ng serve
. To test it, run ng test
. To run its integration tests, run ng e2e
.
You’ll build a simple web application with Angular CLI, a tool for Angular development. You’ll create an application with search and edit features.
-
About 30-40 minutes.
-
A favorite text editor or IDE. I recommend IntelliJ IDEA and its Angular TypeScript Live Templates plugin.
-
Angular CLI installed. If you don’t have Angular CLI installed, install it using
npm install -g @angular/[email protected]
.
💡
|
Angular Augury is a Google Chrome Dev Tools extension for debugging Angular applications. I haven’t needed it much myself, but I can see how it might come in handy. |
Create a new project using the ng new
command:
ng new ng-demo
When prompted to install Angular routing, type "Y". For the stylesheet format, choose "CSS" (the default).
This will create a ng-demo
project and run npm install
in it. It takes about a minute to complete,
but will vary based on your internet connection speed.
You can see the what version of Angular CLI you’re using with ng --version
.
$ ng --version _ _ ____ _ ___ / \ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _| / △ \ | '_ \ / _` | | | | |/ _` | '__| | | | | | | / ___ \| | | | (_| | |_| | | (_| | | | |___| |___ | | /_/ \_\_| |_|\__, |\__,_|_|\__,_|_| \____|_____|___| |___/ Angular CLI: 7.0.6 Node: 11.1.0 OS: darwin x64 Angular: ... Package Version ------------------------------------------------------ @angular-devkit/architect 0.10.6 @angular-devkit/core 7.0.6 @angular-devkit/schematics 7.0.6 @schematics/angular 7.0.6 @schematics/update 0.10.6 rxjs 6.3.3 typescript 3.1.6
The project is configured with a simple web server for development. To start it, run:
ng serve
You should see a screen like the one below at http://localhost:4200.
You can make sure your new project’s tests pass, run ng test
:
$ ng test ... Chrome 70.0.3538 (Mac OS X 10.13.6): Executed 3 of 3 SUCCESS (0.162 secs / 0.149 secs)
To add a search feature, open the project in an IDE or your favorite text editor. For IntelliJ IDEA, use File > New Project > Static Web and point to the ng-demo
directory.
In a terminal window, cd into your project’s directory and run the following command. This will create a search component.
$ ng g component search
CREATE src/app/search/search.component.css (0 bytes)
CREATE src/app/search/search.component.html (25 bytes)
CREATE src/app/search/search.component.spec.ts (628 bytes)
CREATE src/app/search/search.component.ts (269 bytes)
UPDATE src/app/app.module.ts (475 bytes)
Open src/app/search/search.component.html
and replace its default HTML with the following:
<h2>Search</h2>
<form>
<input type="search" name="query" [(ngModel)]="query" (keyup.enter)="search()">
<button type="button" (click)="search()">Search</button>
</form>
<pre>{{searchResults | json}}</pre>
The Router & Navigation documentation for Angular provides the information you need to setup a route to the SearchComponent
you just generated. Here’s a quick summary:
In src/app/app-routing.module.ts
, modify the routes
constant to add SearchComponent
as the default:
import { SearchComponent } from './search/search.component';
const routes: Routes = [
{ path: 'search', component: SearchComponent },
{ path: '', redirectTo: '/search', pathMatch: 'full' }
];
If you still have ng serve
running, your browser should refresh automatically. If not, navigate to http://localhost:4200. You will likely see a blank screen. Open your JavaScript console and you’ll see the problem.
To solve this, open src/app/app.module.ts
and add FormsModule
as an import in @NgModule
:
import { FormsModule } from '@angular/forms';
@NgModule({
...
imports: [
...
FormsModule
]
...
})
export class AppModule { }
Now you should see the search form.
If yours looks different, it’s because I trimmed my app.component.html
to the bare minimum.
<h1>Welcome to {{ title }}!</h1>
<router-outlet></router-outlet>
If you want to add CSS for this components, open src/app/search/search.component.css
and add some CSS. For example:
:host {
display: block;
padding: 0 20px;
}
This section has shown you how to generate a new component and add it to a basic Angular application with Angular CLI.
The next section shows you how to create and use a JSON file and localStorage
to create a fake API.
To get search results, create a SearchService
that makes HTTP requests to a JSON file. Start by generating a new service.
$ ng g service shared/search/search CREATE src/app/shared/search/search.service.spec.ts (333 bytes) CREATE src/app/shared/search/search.service.ts (135 bytes)
Create src/assets/data/people.json
to hold your data.
[
{
"id": 1,
"name": "Peyton Manning",
"phone": "(303) 567-8910",
"address": {
"street": "1234 Main Street",
"city": "Greenwood Village",
"state": "CO",
"zip": "80111"
}
},
{
"id": 2,
"name": "Demaryius Thomas",
"phone": "(720) 213-9876",
"address": {
"street": "5555 Marion Street",
"city": "Denver",
"state": "CO",
"zip": "80202"
}
},
{
"id": 3,
"name": "Von Miller",
"phone": "(917) 323-2333",
"address": {
"street": "14 Mountain Way",
"city": "Vail",
"state": "CO",
"zip": "81657"
}
}
]
Modify src/app/shared/search/search.service.ts
and provide HttpClient
as a dependency in its constructor.
In this same file, create a getAll()
method to gather all the people. Also, define the Address
and Person
classes
that JSON will be marshalled to.
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({
providedIn: 'root'
})
export class SearchService {
constructor(private http: HttpClient) { }
getAll() {
return this.http.get('assets/data/people.json');
}
}
export class Address {
street: string;
city: string;
state: string;
zip: string;
constructor(obj?: any) {
this.street = obj && obj.street || null;
this.city = obj && obj.city || null;
this.state = obj && obj.state || null;
this.zip = obj && obj.zip || null;
}
}
export class Person {
id: number;
name: string;
phone: string;
address: Address;
constructor(obj?: any) {
this.id = obj && Number(obj.id) || null;
this.name = obj && obj.name || null;
this.phone = obj && obj.phone || null;
this.address = obj && obj.address || null;
}
}
To make these classes easier to consume by your components, create src/app/shared/index.ts
and add the following:
export * from './search/search.service';
The reason for creating this file is so you can import multiple classes on a single line rather than having to import each individual class on separate lines.
In search.component.ts
, add imports for these classes.
import { Person, SearchService } from '../shared';
You can now add query
and searchResults
variables. While you’re there, modify the constructor to inject the SearchService
.
export class SearchComponent implements OnInit {
query: string;
searchResults: Array<Person>;
constructor(private searchService: SearchService) { }
Then implement a search()
method to call the service’s getAll()
method.
search(): void {
this.searchService.getAll().subscribe(
(data: any) => { this.searchResults = data; },
error => console.log(error)
);
}
At this point, you’ll likely see the following message in your browser’s console.
NullInjectorError: No provider for HttpClient!
To fix the "No provider" error from above, update app.module.ts
to import HttpClientModule
.
import { HttpClientModule } from '@angular/common/http';
@NgModule({
...
imports: [
...
HttpClientModule
],
providers: [],
bootstrap: [AppComponent]
})
Now clicking the search button should work. To make the results look better, remove the <pre>
tag and replace it with a <table>
in search.component.html
.
<table *ngIf="searchResults">
<thead>
<tr>
<th>Name</th>
<th>Phone</th>
<th>Address</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let person of searchResults; let i=index">
<td>{{person.name}}</td>
<td>{{person.phone}}</td>
<td>{{person.address.street}}<br/>
{{person.address.city}}, {{person.address.state}} {{person.address.zip}}
</td>
</tr>
</tbody>
</table>
Then add some additional CSS to search.component.css
to improve its table layout.
table {
margin-top: 10px;
border-collapse: collapse;
}
th {
text-align: left;
border-bottom: 2px solid #ddd;
padding: 8px;
}
td {
border-top: 1px solid #ddd;
padding: 8px;
}
Now the search results look better.
But wait, you still don’t have search functionality! To add a search feature, add a search()
method to SearchService
.
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
...
search(q: string): Observable<any> {
if (!q || q === '*') {
q = '';
} else {
q = q.toLowerCase();
}
return this.getAll().pipe(
map((data: any) => data
.filter(item => JSON.stringify(item).toLowerCase().includes(q)))
);
}
Then refactor SearchComponent
to call this method with its query
variable.
search(): void {
this.searchService.search(this.query).subscribe(
(data: any) => { this.searchResults = data; },
error => console.log(error)
);
}
Now search results will be filtered by the query value you type in.
This section showed you how to fetch and display search results. The next section builds on this and shows how to edit and save a record.
Modify search.component.html
to add a link for editing a person.
<td><a [routerLink]="['/edit', person.id]">{{person.name}}</a></td>
Run the following command to generate an EditComponent
.
$ ng g component edit
CREATE src/app/edit/edit.component.css (0 bytes)
CREATE src/app/edit/edit.component.html (23 bytes)
CREATE src/app/edit/edit.component.spec.ts (614 bytes)
CREATE src/app/edit/edit.component.ts (261 bytes)
UPDATE src/app/app.module.ts (691 bytes)
Add a route for this component in app-routing.module.ts
:
import { EditComponent } from './edit/edit.component';
const routes: Routes = [
{ path: 'search', component: SearchComponent },
{ path: 'edit/:id', component: EditComponent },
{ path: '', redirectTo: '/search', pathMatch: 'full' }
];
Update src/app/edit/edit.component.html
to display an editable form. You might notice I’ve added id
attributes to most elements. This is to make things easier when writing integration tests with Protractor.
<div *ngIf="person">
<h3>{{editName}}</h3>
<div>
<label>Id:</label>
{{person.id}}
</div>
<div>
<label>Name:</label>
<input [(ngModel)]="editName" name="name" id="name" placeholder="name"/>
</div>
<div>
<label>Phone:</label>
<input [(ngModel)]="editPhone" name="phone" id="phone" placeholder="Phone"/>
</div>
<fieldset>
<legend>Address:</legend>
<address>
<input [(ngModel)]="editAddress.street" id="street"><br/>
<input [(ngModel)]="editAddress.city" id="city">,
<input [(ngModel)]="editAddress.state" id="state" size="2">
<input [(ngModel)]="editAddress.zip" id="zip" size="5">
</address>
</fieldset>
<button (click)="save()" id="save">Save</button>
<button (click)="cancel()" id="cancel">Cancel</button>
</div>
Modify EditComponent
to import model and service classes and to use the SearchService
to get data.
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Address, Person, SearchService } from '../shared';
import { Subscription } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'app-edit',
templateUrl: './edit.component.html',
styleUrls: ['./edit.component.css']
})
export class EditComponent implements OnInit, OnDestroy {
person: Person;
editName: string;
editPhone: string;
editAddress: Address;
sub: Subscription;
constructor(private route: ActivatedRoute,
private router: Router,
private service: SearchService) {
}
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
const id = + params['id']; // (+) converts string 'id' to a number
this.service.get(id).subscribe(person => {
if (person) {
this.editName = person.name;
this.editPhone = person.phone;
this.editAddress = person.address;
this.person = person;
} else {
this.gotoList();
}
});
});
}
ngOnDestroy() {
this.sub.unsubscribe();
}
cancel() {
this.router.navigate(['/search']);
}
save() {
this.person.name = this.editName;
this.person.phone = this.editPhone;
this.person.address = this.editAddress;
this.service.save(this.person);
this.gotoList();
}
gotoList() {
if (this.person) {
this.router.navigate(['/search', {term: this.person.name} ]);
} else {
this.router.navigate(['/search']);
}
}
}
Modify SearchService
to contain functions for finding a person by their id, and saving them. While you’re in there, modify the search()
method to be aware of updated objects in localStorage
.
search(q: string): Observable<any> {
if (!q || q === '*') {
q = '';
} else {
q = q.toLowerCase();
}
return this.getAll().pipe(
map((data: any) => data
.map(item => !!localStorage['person' + item.id] ?
JSON.parse(localStorage['person' + item.id]) : item)
.filter(item => JSON.stringify(item).toLowerCase().includes(q))
));
}
get(id: number) {
return this.getAll().pipe(map((all: any) => {
if (localStorage['person' + id]) {
return JSON.parse(localStorage['person' + id]);
}
return all.find(e => e.id === id);
}));
}
save(person: Person) {
localStorage['person' + person.id] = JSON.stringify(person);
}
You can add CSS to src/app/edit/edit.component.css
if you want to make the form look a bit better.
:host {
display: block;
padding: 0 20px;
}
button {
margin-top: 10px;
}
At this point, you should be able to search for a person and update their information.
The <form> in src/app/edit/edit.component.html
calls a save()
function to update a person’s data. You already implemented this above.
The function calls a gotoList()
function that appends the person’s name to the URL when sending the user back to the search screen.
gotoList() {
if (this.person) {
this.router.navigate(['/search', {term: this.person.name} ]);
} else {
this.router.navigate(['/search']);
}
}
Since the SearchComponent
doesn’t execute a search automatically when you execute this URL, add the following logic to do so in its ngOnInit
method.
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
...
sub: Subscription;
constructor(private searchService: SearchService, private route: ActivatedRoute) { }
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
if (params['term']) {
this.query = decodeURIComponent(params['term']);
this.search();
}
});
}
You’ll want to implement OnDestroy
and define the ngOnDestroy
method to clean up this subscription.
import { Component, OnDestroy, OnInit } from '@angular/core';
export class SearchComponent implements OnInit, OnDestroy {
...
ngOnDestroy() {
if (this.sub) {
this.sub.unsubscribe();
}
}
}
After making all these changes, you should be able to search/edit/update a person’s information. If it works - nice job!
One thing you might notice is you can clear any input element in the form and save it. At the very least, the name
field should be required. Otherwise, there’s nothing to click on in the search results.
To make name required, modify edit.component.html
to add a required
attribute to the name <input>
.
<input [(ngModel)]="editName" name="name" id="name" placeholder="name" required/>
You’ll also need to wrap everything in a <form>
element. Add <form>
after the <h3>
tag and close it before the last </div>
. You’ll also need to add an (ngSubmit)
handler to the form and change the save button to be a regular submit button.
<h3>{{editName}}</h3>
<form (ngSubmit)="save()" ngNativeValidate>
...
<button type="submit" id="save">Save</button>
<button (click)="cancel()" id="cancel">Cancel</button>
</form>
After making these changes, any field with a required
attribute will be required.
In this screenshot, you might notice the address fields are blank. This is explained by the error in your console.
If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions. Example 1: <input [(ngModel)]="person.firstName" name="first"> Example 2: <input [(ngModel)]="person.firstName" [ngModelOptions]="{standalone: true}">
To fix, add a name
attribute to all the address fields. For example:
<address>
<input [(ngModel)]="editAddress.street" name="street" id="street"><br/>
<input [(ngModel)]="editAddress.city" name="city" id="city">,
<input [(ngModel)]="editAddress.state" name="state" id="state" size="2">
<input [(ngModel)]="editAddress.zip" name="zip" id="zip" size="5">
</address>
Now values should display in all fields and name
should be required.
If you want to provide your own validation messages instead of relying on the browser’s, complete the following steps:
-
Remove
ngNativeValidate
and add#editForm="ngForm"
to the<form>
element. -
Add
#name="ngModel"
to the<input id="name">
element. -
Add
[disabled]="!editForm.form.valid"
to the Save button. -
Add the following under the
name
field to display a validation error.
<div [hidden]="name.valid || name.pristine" style="color: red">
Name is required
</div>
This validation mechanism will render as follows:
To learn more about forms and validation, see Angular forms documentation.
Now that you’ve built an application, it’s important to test it to ensure it works. The best reason for writing tests is to automate your testing. Without tests, you’ll likely be testing manually. This manual testing will take longer and longer as your application grows.
💡
|
If you didn’t complete the previous section, you can clone the ng-demo repository and checkout the git clone -b test-start https://github.com/mraible/ng-demo.git cd ng-demo && npm i |
In this section, you’ll learn to use Jasmine for unit testing controllers and Protractor for integration testing. Angular’s testing documentation lists good reasons to test, but doesn’t currently have many examples.
If you run ng test
, you’ll likely get failures for the components and service you created. These failures will be solved as you complete the section below.
💡
|
You can use x and f prefixes Jasmine’s describe and it functions to exclude only run only a particular test.
|
Modify src/app/shared/search/search.service.spec.ts
and setup the test’s infrastructure (a.k.a. TestBed
) using HttpClientTestingModule
and HttpTestingController
.
💡
|
I learned how to test services that use HttpClient from Ciro Nunes' Testing with the Angular HttpClient API.
|
import { getTestBed, TestBed } from '@angular/core/testing';
import { SearchService } from './search.service';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
describe('SearchService', () => {
let injector: TestBed;
let service: SearchService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [SearchService]
});
injector = getTestBed();
service = injector.get(SearchService);
httpMock = injector.get(HttpTestingController);
});
...
If you run ng test
, you will likely see some errors about the test stubs that Angular CLI created for you. You can ignore these for now.
Chrome 70.0.3538 (Mac OS X 10.13.6) EditComponent should create FAILED There is no directive with "exportAs" set to "ngForm" ("<div *ngIf="person"> Chrome 70.0.3538 (Mac OS X 10.13.6) SearchComponent should create FAILED Can't bind to 'ngModel' since it isn't a known property of 'input'. ("<h2>Search</h2>
HttpTestingController
allows you to mock requests and use its flush
method to provide response values. Since the HTTP request methods return an Observable, you can subscribe to it and create expectations in the callback methods. Add the first test of getAll()
to search.service.spec.ts
.
The test below should be on the same level as beforeEach
.
it('should retrieve all search results', () => {
const dummyData = [
{name: 'John Elway'},
{name: 'Gary Kubiak'}
];
service.getAll().subscribe((people: any) => {
expect(people.length).toBe(2);
expect(people[0].name).toBe('John Elway');
expect(people).toEqual(dummyData);
});
const req = httpMock.expectOne('assets/data/people.json');
expect(req.request.method).toBe('GET');
req.flush(dummyData);
});
While you’re there, fix the 'should be created' test and add an afterEach
to verify requests.
afterEach(() => {
httpMock.verify();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
Add a couple more tests for filtering by search term and fetching by id.
it('should filter by search term', () => {
const dummyData = [
{name: 'John Elway'}
];
service.search('john').subscribe((people: any) => {
expect(people.length).toBe(1);
expect(people[0].name).toBe('John Elway');
});
const req = httpMock.expectOne('assets/data/people.json');
expect(req.request.method).toBe('GET');
req.flush(dummyData);
});
it('should fetch by id', () => {
const dummyData = [
{id: 1, name: 'John Elway'},
{id: 2, name: 'Gary Kubiak'}
];
service.get(2).subscribe((person: any) => {
expect(person.name).toBe('Gary Kubiak');
});
const req = httpMock.expectOne('assets/data/people.json');
expect(req.request.method).toBe('GET');
req.flush(dummyData);
});
To unit test the SearchComponent
, create a MockSearchProvider
that has spies. These allow you to spy on functions to check if they were called.
Create src/app/shared/search/mocks/search.service.ts
and populate it with spies for each method, as well as methods to set the response and subscribe to results.
import { SpyObject } from './helper';
import { SearchService } from '../search.service';
import Spy = jasmine.Spy;
export class MockSearchService extends SpyObject {
getAllSpy: Spy;
getByIdSpy: Spy;
searchSpy: Spy;
saveSpy: Spy;
fakeResponse: any;
constructor() {
super( SearchService );
this.fakeResponse = null;
this.getAllSpy = this.spy('getAll').andReturn(this);
this.getByIdSpy = this.spy('get').andReturn(this);
this.searchSpy = this.spy('search').andReturn(this);
this.saveSpy = this.spy('save').andReturn(this);
}
subscribe(callback: any) {
callback(this.fakeResponse);
}
setResponse(json: any): void {
this.fakeResponse = json;
}
}
In this same directory, create a helper.ts
class to implement the SpyObject
that MockSearchService
extends.
/// <reference path="../../../../../node_modules/@types/jasmine/index.d.ts"/>
export interface GuinessCompatibleSpy extends jasmine.Spy {
/** By chaining the spy with and.returnValue, all calls to the function will return a specific
* value. */
andReturn(val: any): void;
/** By chaining the spy with and.callFake, all calls to the spy will delegate to the supplied
* function. */
andCallFake(fn: Function): GuinessCompatibleSpy;
/** removes all recorded calls */
reset();
}
export class SpyObject {
static stub(object = null, config = null, overrides = null) {
if (!(object instanceof SpyObject)) {
overrides = config;
config = object;
object = new SpyObject();
}
const m = {};
Object.keys(config).forEach((key) => m[key] = config[key]);
Object.keys(overrides).forEach((key) => m[key] = overrides[key]);
for (const key in m) {
object.spy(key).andReturn(m[key]);
}
return object;
}
constructor(type = null) {
if (type) {
for (const prop in type.prototype) {
let m = null;
try {
m = type.prototype[prop];
} catch (e) {
// As we are creating spys for abstract classes,
// these classes might have getters that throw when they are accessed.
// As we are only auto creating spys for methods, this
// should not matter.
}
if (typeof m === 'function') {
this.spy(prop);
}
}
}
}
spy(name) {
if (!this[name]) {
this[name] = this._createGuinnessCompatibleSpy(name);
}
return this[name];
}
prop(name, value) { this[name] = value; }
/** @internal */
_createGuinnessCompatibleSpy(name): GuinessCompatibleSpy {
const newSpy: GuinessCompatibleSpy = <any>jasmine.createSpy(name);
newSpy.andCallFake = <any>newSpy.and.callFake;
newSpy.andReturn = <any>newSpy.and.returnValue;
newSpy.reset = <any>newSpy.calls.reset;
// revisit return null here (previously needed for rtts_assert).
newSpy.and.returnValue(null);
return newSpy;
}
}
Alongside, create routes.ts
to mock Angular’s Router
and ActivatedRoute
.
import { ActivatedRoute, Params } from '@angular/router';
import { Observable, of } from 'rxjs';
export class MockActivatedRoute extends ActivatedRoute {
params: Observable<Params>;
constructor(parameters?: { [key: string]: any; }) {
super();
this.params = of(parameters);
}
}
export class MockRouter {
navigate = jasmine.createSpy('navigate');
}
With mocks in place, you can TestBed.configureTestingModule()
to setup SearchComponent
to use these as providers.
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchComponent } from './search.component';
import { MockSearchService } from '../shared/search/mocks/search.service';
import { MockActivatedRoute, MockRouter } from '../shared/search/mocks/routes';
import { SearchService } from '../shared';
import { ActivatedRoute, Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { FormsModule } from '@angular/forms';
describe('SearchComponent', () => {
let component: SearchComponent;
let fixture: ComponentFixture<SearchComponent>;
let mockSearchService: MockSearchService;
let mockActivatedRoute: MockActivatedRoute;
beforeEach(async(() => {
mockSearchService = new MockSearchService();
mockActivatedRoute = new MockActivatedRoute({'term': 'peyton'});
TestBed.configureTestingModule({
declarations: [ SearchComponent ],
providers: [
{provide: SearchService, useValue: mockSearchService},
{provide: ActivatedRoute, useValue: mockActivatedRoute}
],
imports: [FormsModule, RouterTestingModule]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
Add two tests, one to verify a search term is used when it’s set on the component, and a second to verify search is called when a term is passed in as a route parameter.
it('should search when a term is set and search() is called', () => {
component = fixture.debugElement.componentInstance;
component.query = 'M';
component.search();
expect(mockSearchService.searchSpy).toHaveBeenCalledWith('M');
});
it('should search automatically when a term is on the URL', () => {
fixture.detectChanges();
expect(mockSearchService.searchSpy).toHaveBeenCalledWith('peyton');
});
Update the test for EditComponent
, verifying fetching a single record works. Notice how you can access the component directly with
fixture.debugElement.componentInstance
, or its rendered version with fixture.debugElement.nativeElement
.
import { MockSearchService } from '../shared/search/mocks/search.service';
import { EditComponent } from './edit.component';
import { TestBed } from '@angular/core/testing';
import { SearchService } from '../shared';
import { MockRouter, MockActivatedRoute } from '../shared/search/mocks/routes';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
describe('EditComponent', () => {
let mockSearchService: MockSearchService;
let mockActivatedRoute: MockActivatedRoute;
let mockRouter: MockRouter;
beforeEach(() => {
mockSearchService = new MockSearchService();
mockActivatedRoute = new MockActivatedRoute({'id': 1});
mockRouter = new MockRouter();
TestBed.configureTestingModule({
declarations: [EditComponent],
providers: [
{provide: SearchService, useValue: mockSearchService},
{provide: ActivatedRoute, useValue: mockActivatedRoute},
{provide: Router, useValue: mockRouter}
],
imports: [FormsModule]
}).compileComponents();
});
it('should fetch a single record', () => {
const fixture = TestBed.createComponent(EditComponent);
const person = {name: 'Emmanuel Sanders', address: {city: 'Denver'}};
mockSearchService.setResponse(person);
fixture.detectChanges();
// verify service was called
expect(mockSearchService.getByIdSpy).toHaveBeenCalledWith(1);
// verify data was set on component when initialized
const editComponent = fixture.debugElement.componentInstance;
expect(editComponent.editAddress.city).toBe('Denver');
// verify HTML renders as expected
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h3').innerHTML).toBe('Emmanuel Sanders');
});
});
You should see "Executed 11 of 11 SUCCESS (0.411 secs / 0.399 secs)" in the shell window that’s running ng test
. If you don’t, try cancelling the command and restarting.
To test if the application works end-to-end, you can write tests with Protractor. These are also known as integration tests, since they test the integration between all layers of your application.
To verify end-to-end tests work in the project before you begin, run the following command in a terminal window.
ng e2e
All tests should pass.
$ ng e2e ** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ ** Date: 2018-11-18T16:31:59.824Z Hash: ba8063b94d89f79646a4 Time: 6650ms chunk {main} main.js, main.js.map (main) 29.8 kB [initial] [rendered] chunk {polyfills} polyfills.js, polyfills.js.map (polyfills) 223 kB [initial] [rendered] chunk {runtime} runtime.js, runtime.js.map (runtime) 6.08 kB [entry] [rendered] chunk {styles} styles.js, styles.js.map (styles) 16.2 kB [initial] [rendered] chunk {vendor} vendor.js, vendor.js.map (vendor) 3.89 MB [initial] [rendered] ℹ 「wdm」: Compiled successfully. [09:32:00] I/update - chromedriver: file exists /Users/mraible/dev/ng-demo/node_modules/protractor/node_modules/webdriver-manager/selenium/chromedriver_2.43.zip [09:32:00] I/update - chromedriver: unzipping chromedriver_2.43.zip [09:32:00] I/update - chromedriver: setting permissions to 0755 for /Users/mraible/dev/ng-demo/node_modules/protractor/node_modules/webdriver-manager/selenium/chromedriver_2.43 [09:32:00] I/update - chromedriver: chromedriver_2.43 up to date [09:32:00] I/launcher - Running 1 instances of WebDriver [09:32:00] I/direct - Using ChromeDriver directly... Jasmine started workspace-project App ✓ should display welcome message Executed 1 of 1 spec SUCCESS in 0.83 sec. [09:32:02] I/launcher - 0 instance(s) of WebDriver still running [09:32:02] I/launcher - chrome #01 passed
I was recently made aware of a better way to configure Protractor. To integrate these improvements into your project, add the following to e2e/protractor.conf.js
.
SELENIUM_PROMISE_MANAGER: false,
Then change e2e/src/app.e2e-spec.ts
so its test uses async
and await
.
it('should display welcome message', async () => { await page.navigateTo(); expect(await page.getTitleText()).toEqual('Welcome to ng-demo!'); });
Run ng e2e
again to make sure everything still works.
Create end-to-end tests in e2e/src/search.e2e-spec.ts
to verify the search feature works. Populate it with the following code:
import { browser, by, element } from 'protractor';
describe('Search', () => {
beforeEach(async () => {
await browser.get('/search');
});
it('should have an input and search button', () => {
expect(element(by.css('app-root app-search form input')).isPresent()).toEqual(true);
expect(element(by.css('app-root app-search form button')).isPresent()).toEqual(true);
});
it('should allow searching', async () => {
const searchButton = element(by.css('button'));
const searchBox = element(by.css('input'));
await searchBox.sendKeys('M');
await searchButton.click();
const list = element.all(by.css('app-search table tbody tr'));
expect(list.count()).toBe(3);
});
});
Create a e2e/src/edit.e2e-spec.ts
test to verify the EditComponent
renders a person’s information and that their information can be updated.
import { browser, by, element } from 'protractor';
describe('Edit', () => {
beforeEach(async () => {
await browser.get('/edit/1');
});
const name = element(by.id('name'));
const street = element(by.id('street'));
const city = element(by.id('city'));
it('should allow viewing a person', async () => {
expect(await element(by.css('h3')).getText()).toEqual('Peyton Manning');
expect(await name.getAttribute('value')).toEqual('Peyton Manning');
expect(await street.getAttribute('value')).toEqual('1234 Main Street');
expect(await city.getAttribute('value')).toEqual('Greenwood Village');
});
it('should allow updating a name', async () => {
const save = element(by.id('save'));
name.sendKeys(' Won!');
await save.click();
// verify one element matched this change
const list = element.all(by.css('app-search table tbody tr'));
expect(list.count()).toBe(1);
});
});
Run ng e2e
to verify all your end-to-end tests pass. You should see a success message similar to the one below in
your terminal window.
If you made it this far and have all your specs passing - congratulations! You’re well on your way to writing quality code with Angular and verifying it works.
You can see the test coverage of your project by running ng test --codeCoverage=true
and then opening coverage/index.html
in your browser.
You might notice that the new components and service could use some additional coverage. If you feel the need to improve this coverage, please create a pull request!
At the time of this writing, Angular CLI did not have any continuous integration support. This section shows you how to setup continuous integration with Travis CI and Jenkins.
If you’ve checked in your project to GitHub, you can use Travis CI.
-
Login to Travis CI and enable builds for the GitHub repo you published the project to.
-
Add the following
.travis.yml
in your root directory andgit commit/push
it. This will trigger the first build.
os:
- linux
services:
- docker
language: node_js
node_js:
- "11.0.0"
addons:
apt:
sources:
- google-chrome
packages:
- google-chrome-stable
cache:
directories:
- node_modules
branches:
only:
- master
before_install:
- export CHROME_BIN=/usr/bin/google-chrome
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start
- npm install -g @angular/[email protected]
install:
- npm ci
script:
- ng test --watch=false
- ng e2e
notifications:
webhooks:
on_success: change
on_failure: always
on_start: false
Here is a build showing all unit and integration tests passing.
If you’ve checked your project into source control, you can use Jenkins to automate testing.
-
Create a
Jenkinsfile
in the root directory and commit to master.
node { def nodeHome = tool name: 'node-11.0.0', type: 'jenkins.plugins.nodejs.tools.NodeJSInstallation' env.PATH = "${nodeHome}/bin:${env.PATH}" stage('check tools') { sh "node -v" sh "npm -v" } stage('checkout') { checkout scm } stage('npm install') { sh "npm install" } stage('unit tests') { sh "ng test" } stage('protractor tests') { sh "ng e2e" } }
-
Download Jenkins 2 and install it on your local hard drive. Start it using
java -jar jenkins.war
. -
Login to Jenkins and create a new project with an SCM Pipeline. Point it at your project’s repository. Run a build.
This section shows you how to deploy an Angular app to Cloud Foundry and Heroku.
Create a Pivotal account and install the cf CLI. Then run the following commands to build and deploy your application.
ng build --prod=true
cd dist && touch Staticfile
# enable pushstate so no 404s on refresh
echo 'root: ng-demo\npushstate: enabled' > Staticfile
cf push ng-demo
📎
|
You might need to use an app name other than ng-demo .
|
Create a Heroku account and install the heroku CLI. Then run the following commands to build and deploy your application.
-
Run
heroku create
-
Change
package.json
to have a differentstart
script."start": "http-server-spa dist/ng-demo index.html $PORT",
-
Add
preinstall
andpostinstall
scripts topackage.json
:"preinstall": "npm i -g http-server-spa", "postinstall": "ng build --prod=true"
-
Run
git push heroku master
-
View the application in your browser with
heroku open
A completed project with this code in it is available on GitHub at https://github.com/mraible/ng-demo.
I hope you’ve enjoyed this in-depth tutorial on how to get started with Angular and Angular CLI. Angular CLI takes much of the pain out of setting up an Angular project and using Typescript. I expect great things from Angular CLI, mostly because the Angular setup process can be tedious and CLI greatly simplifies things.
If you’d like to see how to integrate Angular Material, Bootstrap 4, or authentication with Okta, this section is for you!
I’ve created branches to show how to integrate each of these libraries. Click on the links below to see each branch’s documentation.