• Stars
    star
    177
  • Rank 215,985 (Top 5 %)
  • Language
    TypeScript
  • Created over 8 years ago
  • Updated over 1 year ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

🦴 Bare Bones Angular and Angular CLI Tutorial

🦴 Bare Bones Angular and Angular CLI Tutorial

For a book of this tutorial, please check out The Angular Mini-Book. Its "Build an Angular App" chapter was inspired by this tutorial.

This tutorial shows you how to build a bare-bones search and edit application using Angular and Angular CLI version 15.

💡
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.
Source Code

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.

What you’ll build

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.

What you’ll need

If you don’t have Angular CLI installed, install it:

npm install -g @angular/cli@15
📎
IntelliJ IDEA Ultimate Edition has the best support for TypeScript. If you’d rather not pay for your IDE, checkout Visual Studio Code.

Create a new Angular project

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 version of Angular CLI you’re using with the ng version command.

$ ng version

     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/


Angular CLI: 15.1.6
Node: 18.14.0
Package Manager: npm 9.3.1
OS: darwin arm64

Angular:
...

Package                      Version
------------------------------------------------------
@angular-devkit/architect    0.1501.6 (cli-only)
@angular-devkit/core         15.1.6 (cli-only)
@angular-devkit/schematics   15.1.6 (cli-only)
@schematics/angular          15.1.6 (cli-only)

If you run this command from the ng-demo directory, you’ll see even more information.

....

Angular: 15.1.5
... animations, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, router

Package                         Version
---------------------------------------------------------
@angular-devkit/architect       0.1501.6
@angular-devkit/build-angular   15.1.6
@angular-devkit/core            15.1.6
@angular-devkit/schematics      15.1.6
@angular/cli                    15.1.6
@schematics/angular             15.1.6
rxjs                            7.8.0
typescript                      4.9.5

Run the application

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.

Default Homepage
Figure 1. Default homepage

You can make sure your new project’s tests pass, run ng test:

$ ng test
...
...: Executed 3 of 3 SUCCESS (0.061 secs / 0.055 secs)

Add a search feature

To add a search feature, open the project in an IDE or your favorite text editor.

The Basics

In a terminal window, cd into your project’s directory and run the following command to create a search component.

ng g component search

Open src/app/search/search.component.html and replace its default HTML with the following:

src/app/search/search.component.html
<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>

Add a query property to src/app/search/search.component.ts. While you’re there, add a searchResults property and an empty search() method.

src/app/search/search.component.ts
export class SearchComponent implements OnInit {
  query: string | undefined;
  searchResults: any;

  constructor() { }

  ngOnInit(): void { }

  search(): void { }

}

In src/app/app-routing.module.ts, modify the routes constant to add SearchComponent as the default:

src/app/app-routing.module.ts
import { SearchComponent } from './search/search.component';

const routes: Routes = [
  { path: 'search', component: SearchComponent },
  { path: '', redirectTo: '/search', pathMatch: 'full' }
];

Run ng serve again you will see a compilation error.

ERROR in src/app/search/search.component.html:3:37 - error NG8002:
 Can't bind to 'ngModel' since it isn't a known property of 'input'.

To solve this, open src/app/app.module.ts and add FormsModule as an import in @NgModule:

src/app/app.module.ts
import { FormsModule } from '@angular/forms';

@NgModule({
  ...
  imports: [
    ...
    FormsModule
  ]
  ...
})
export class AppModule { }

Now you should be able to see the search form.

Search component
Figure 2. Search component

If yours looks different, it’s because I trimmed my app.component.html to the bare minimum.

src/app/app.component.html
<h1>Welcome to {{ title }}!</h1>

<router-outlet></router-outlet>

If you want to add styling for this component, open src/app/search/search.component.css and add some CSS. For example:

src/app/search/search.component.css
:host {
  display: block;
  padding: 0 20px;
}
The :host allows you to target the container of the component. It’s the only way to target the host element. You can’t reach the host element from inside the component with other selectors because it’s not part of the component’s own template.

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.

The Backend

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/assets/data/people.json to hold your data.

mkdir -p src/assets/data
src/assets/data/people.json
[
  {
    "id": 1,
    "name": "Nikola Jokić",
    "phone": "(720) 555-1212",
    "address": {
      "street": "2000 16th Street",
      "city": "Denver",
      "state": "CO",
      "zip": "80202"
    }
  },
  {
    "id": 2,
    "name": "Jamal Murray",
    "phone": "(303) 321-8765",
    "address": {
      "street": "2654 Washington Street",
      "city": "Lakewood",
      "state": "CO",
      "zip": "80568"
    }
  },
  {
    "id": 3,
    "name": "Aaron Gordon",
    "phone": "(303) 323-1233",
    "address": {
      "street": "46 Creekside Way",
      "city": "Winter Park",
      "state": "CO",
      "zip": "80482"
    }
  }
]

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.

src/app/shared/search/search.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class SearchService {

  constructor(private http: HttpClient) { }

  getAll(): Observable<Person[]> {
    return this.http.get<Person[]>('assets/data/people.json');
  }
}

export class Address {
  street: string;
  city: string;
  state: string;
  zip: string;

  constructor(obj?: any) {
    this.street = obj?.street || null;
    this.city = obj?.city || null;
    this.state = obj?.state || null;
    this.zip = obj?.zip || null;
  }
}

export class Person {
  id: number;
  name: string;
  phone: string;
  address: Address;

  constructor(obj?: any) {
    this.id = obj?.id || null;
    this.name = obj?.name || null;
    this.phone = obj?.phone || null;
    this.address = obj?.address || null;
  }
}

To make these classes easier to consume by your components, create src/app/shared/index.ts and add the following:

src/app/shared/index.ts
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.

src/app/search/search.component.ts
import { Person, SearchService } from '../shared';

You can now add a proper type to the searchResults variable. While you’re there, modify the constructor to inject the SearchService.

src/app/search/search.component.ts
export class SearchComponent implements OnInit {
  query: string | undefined;
  searchResults: Person[] = [];

  constructor(private searchService: SearchService) { }

Then update the search() method to call the service’s getAll() method.

src/app/search/search.component.ts
search(): void {
  this.searchService.getAll().subscribe({
    next: (data: Person[]) => {
      this.searchResults = data;
    },
    error: error => console.log(error)
  });
}

At this point, if your app is running, you’ll 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.

src/app/app.module.ts
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.

src/app/search/search.component.html
<table *ngIf="searchResults?.length">
  <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.

src/app/search/search.component.css
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.

Search Results
Figure 3. Search results

But wait, you still don’t have search functionality! To add a search feature, add a search() method to SearchService.

src/app/shared/search/search.service.ts
import { map, Observable } from 'rxjs';
...

  search(q: string): Observable<Person[]> {
    if (!q || q === '*') {
      q = '';
    } else {
      q = q.toLowerCase();
    }
    return this.getAll().pipe(
      map((data: Person[]) => data
        .filter((item: Person) => JSON.stringify(item).toLowerCase().includes(q)))
    );
  }

Then refactor SearchComponent to call this method with its query variable.

src/app/search/search.component.ts
search(): void {
  this.searchService.search(this.query).subscribe({
    next: (data: Person[]) => {
      this.searchResults = data;
    },
    error: error => console.log(error)
  });
}

This won’t compile right away.

Error: src/app/search/search.component.ts:19:31 - error TS2345:
 Argument of type 'string | undefined' is not assignable to parameter of type 'string'.

Since query will always be assigned (even if it’s empty), change its variable declaration to:

query!: string; // query: string = ''; will also work

This is called a definite assignment assertion. It’s a way to tell TypeScript “I know what I’m doing, the variable will be assigned.”

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.

Add an edit feature

Modify search.component.html to wrap the person’s name with a link.

src/app/search/search.component.html
<td><a [routerLink]="['/edit', person.id]">{{person.name}}</a></td>

Run the following command to generate an EditComponent.

ng g component edit

Add a route for this component in app-routing.module.ts:

src/app/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 it easier to locate elements when writing integration tests.

src/app/edit/edit.component.html
<div *ngIf="person">
  <h3>{{person.name}}</h3>
  <div>
    <label>Id:</label>
    {{person.id}}
  </div>
  <div>
    <label>Name:</label>
    <input [(ngModel)]="person.name" name="name" id="name" placeholder="Name"/>
  </div>
  <div>
    <label>Phone:</label>
    <input [(ngModel)]="person.phone" name="phone" id="phone" placeholder="Phone"/>
  </div>
  <fieldset>
    <legend>Address:</legend>
    <address>
      <input [(ngModel)]="person.address.street" id="street"><br/>
      <input [(ngModel)]="person.address.city" id="city">,
      <input [(ngModel)]="person.address.state" id="state" size="2">
      <input [(ngModel)]="person.address.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.

src/app/edit/edit.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { 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;
  sub!: Subscription;

  constructor(private route: ActivatedRoute,
              private router: Router,
              private service: SearchService) {
  }

  async ngOnInit(): Promise<void> {
    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.person = person;
        } else {
          this.gotoList();
        }
      });
    });
  }

  ngOnDestroy(): void {
    if (this.sub) {
      this.sub.unsubscribe();
    }
  }

  async cancel() {
    await this.router.navigate(['/search']);
  }

  async save() {
    this.service.save(this.person);
    await this.gotoList();
  }

  async gotoList() {
    if (this.person) {
      await this.router.navigate(['/search', {term: this.person.name} ]);
    } else {
      await 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.

src/app/shared/search/search.service.ts
search(q: string): Observable<Person[]> {
  if (!q || q === '*') {
    q = '';
  } else {
    q = q.toLowerCase();
  }
  return this.getAll().pipe(
    map((data: Person[]) => data
      .map((item: Person) => !!localStorage['person' + item.id] ?
        JSON.parse(localStorage['person' + item.id]) : item)
      .filter((item: Person) => JSON.stringify(item).toLowerCase().includes(q))
    ));
}

get(id: number): Observable<Person> {
  return this.getAll().pipe(map((all: Person[]) => {
    if (localStorage['person' + id]) {
      return JSON.parse(localStorage['person' + id]);
    }
    return all.find((e: Person) => 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.

src/app/edit/edit.component.css
: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.

Edit form
Figure 4. Edit component

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.

src/app/edit/edit.component.ts
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.

src/app/search/search.component.ts
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
...

  sub!: Subscription;

  constructor(private searchService: SearchService, private route: ActivatedRoute) { }

  ngOnInit(): void {
    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.

src/app/search/search.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';

export class SearchComponent implements OnInit, OnDestroy {
  ...

  ngOnDestroy(): void {
    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!

Form Validation

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> and bind it to Angular’s validation with #name="ngModel". Add a <div> next to the field to display an error message when validation fails.

src/app/edit/edit.component.html
<input [(ngModel)]="person.name" name="name" id="name" placeholder="Name" required #name="ngModel"/>
<div [hidden]="name.valid || name.pristine" style="color: red">
  Name is required
</div>

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, give it the name of editForm, and change the save button to be a regular submit button that’s disabled when the form is invalid.

src/app/edit/edit.component.html
<h3>{{person.name}}</h3>
<form (ngSubmit)="save()" #editForm="ngForm">
  ...
  <button type="submit" id="save" [disabled]="!editForm.form.valid">Save</button>
  <button (click)="cancel()" id="cancel">Cancel</button>
</form>

After making these changes, the name field will be required.

Edit form with validation
Figure 5. Edit form with validation

In this screenshot, you might notice the address fields are blank and the save button is enabled. 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 this, add a name attribute to all the address fields. For example:

src/app/edit/edit.component.html
<address>
  <input [(ngModel)]="person.address.street" name="street" id="street"><br/>
  <input [(ngModel)]="person.address.city" name="city" id="city">,
  <input [(ngModel)]="person.address.state" name="state" id="state" size="2">
  <input [(ngModel)]="person.address.zip" name="zip" id="zip" size="5">
</address>

Now values display in all fields, name is required, and save is disabled when the form is invalid.

Edit form with names and validation
Figure 6. Edit form with names and validation

To learn more about forms and validation, see Angular’s Validating form input documentation.

Unit and End-to-End Testing

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.

In this section, you’ll learn to use Jasmine for unit testing controllers and Cypress for integration testing.

Fix the Tests

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. The ng test command will start a process that listens for changes so all you need to do is edit/save files and tests will be automatically run again.

💡
You can use x and f prefixes in front of describe and it functions to exclude or only run a particular test.

Fix the AppComponent test

If you changed the app.component.html template as I did, you’ll need to modify app.component.spec.ts to account for the change in HTML. Change its last test to look for an <h1> element and the welcome message inside it.

src/app/app.component.spec.ts
it('should render title', () => {
  const fixture = TestBed.createComponent(AppComponent);
  fixture.detectChanges();
  const compiled = fixture.nativeElement as HTMLElement;
  expect(compiled.querySelector('h1')?.textContent).toContain('Welcome to ng-demo!');
});

Now this test should pass.

Unit test the SearchService

Modify src/app/shared/search/search.service.spec.ts and set up the test’s infrastructure (a.k.a. TestBed) using HttpClientTestingModule and HttpTestingController.

src/app/shared/search/search.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { SearchService } from './search.service';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe('SearchService', () => {
  let service: SearchService;
  let httpMock: HttpTestingController;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [SearchService]
    });

    service = TestBed.inject(SearchService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });
});

Now, you will likely see some errors about the test stubs that Angular CLI created for you. You can ignore these for now.

NullInjectorError: R3InjectorError(DynamicTestModule)[SearchService -> HttpClient -> HttpClient]:
  NullInjectorError: No provider for HttpClient!

NullInjectorError: R3InjectorError(DynamicTestModule)[ActivatedRoute -> ActivatedRoute]:
  NullInjectorError: No provider for ActivatedRoute!

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.

src/app/shared/search/search.service.spec.ts
it('should retrieve all search results', () => {
  const mockResponse = [
    {name: 'Nikola Jokić'},
    {name: 'Mike Malone'}
  ];

  service.getAll().subscribe((people: any) => {
    expect(people.length).toBe(2);
    expect(people[0].name).toBe('Nikola Jokić');
    expect(people).toEqual(mockResponse);
  });

  const req = httpMock.expectOne('assets/data/people.json');
  expect(req.request.method).toBe('GET');
  req.flush(mockResponse);
});

While you’re there, add an afterEach() to verify requests.

src/app/shared/search/search.service.spec.ts
afterEach(() => {
  httpMock.verify();
});

Add a couple more tests for filtering by search term and fetching by id.

src/app/shared/search/search.service.spec.ts
it('should filter by search term', () => {
  const mockResponse = [{name: 'Nikola Jokić'}];

  service.search('nik').subscribe((people: any) => {
    expect(people.length).toBe(1);
    expect(people[0].name).toBe('Nikola Jokić');
  });

  const req = httpMock.expectOne('assets/data/people.json');
  expect(req.request.method).toBe('GET');
  req.flush(mockResponse);
});

it('should fetch by id', () => {
  const mockResponse = [
    {id: 1, name: 'Nikola Jokić'},
    {id: 2, name: 'Mike Malone'}
  ];

  service.get(2).subscribe((person: any) => {
    expect(person.name).toBe('Mike Malone');
  });

  const req = httpMock.expectOne('assets/data/people.json');
  expect(req.request.method).toBe('GET');
  req.flush(mockResponse);
});

Unit test the SearchComponent

To unit test the SearchComponent, you can mock the methods in SearchService with spies. These allow you to spy on functions to check if they were called.

Create src/app/shared/search/mocks/routes.ts to mock Angular’s Router and ActivatedRoute.

src/app/shared/search/mocks/routes.ts
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';

export class MockActivatedRoute extends ActivatedRoute {

  constructor(parameters?: { [key: string]: any; }) {
    super();
    // @ts-ignore
    this.params = of(parameters);
  }
}

export class MockRouter {
  navigate = jasmine.createSpy('navigate');
}

With this mock in place, you can TestBed.configureTestingModule() to set up SearchComponent to use it as a provider. In the second beforeEach(), you can see that the search() method is spied on and its results are mocked. The response isn’t important in this case because you’re just unit testing the SearchComponent.

src/app/search/search.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchComponent } from './search.component';
import { SearchService } from '../shared';
import { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { FormsModule } from '@angular/forms';
import { MockActivatedRoute } from '../shared/search/mocks/routes';
import { of } from 'rxjs';
import { HttpClientTestingModule } from '@angular/common/http/testing';

describe('SearchComponent', () => {
  let component: SearchComponent;
  let fixture: ComponentFixture<SearchComponent>;
  let mockSearchService: SearchService;
  let mockActivatedRoute: MockActivatedRoute;

  beforeEach(async () => {
    mockActivatedRoute = new MockActivatedRoute({term: 'nikola'});

    await TestBed.configureTestingModule({
      declarations: [SearchComponent],
      providers: [
        {provide: ActivatedRoute, useValue: mockActivatedRoute}
      ],
      imports: [FormsModule, RouterTestingModule, HttpClientTestingModule]
    }).compileComponents();
  });

  beforeEach(() => {
    // mock response
    mockSearchService = TestBed.inject(SearchService);
    mockSearchService.search = jasmine.createSpy().and.returnValue(of([]));

    // initialize component
    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.

src/app/search/search.component.spec.ts
it('should search when a term is set and search() is called', () => {
  component = fixture.componentInstance;
  component.query = 'J';
  component.search();
  expect(mockSearchService.search).toHaveBeenCalledWith('J');
});

it('should search automatically when a term is on the URL', () => {
  fixture.detectChanges();
  expect(mockSearchService.search).toHaveBeenCalledWith('nikola');
});

Update the test for EditComponent, verifying fetching a single record works. Notice how you can access the component directly with fixture.componentInstance, or its rendered version with fixture.nativeElement.

src/app/edit/edit.component.spec.ts
import { EditComponent } from './edit.component';
import { TestBed } from '@angular/core/testing';
import { Address, Person, SearchService } from '../shared';
import { MockActivatedRoute, MockRouter } from '../shared/search/mocks/routes';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { of } from 'rxjs';
import { HttpClientTestingModule } from '@angular/common/http/testing';

describe('EditComponent', () => {
  let mockSearchService: SearchService;
  let mockActivatedRoute: MockActivatedRoute;
  let mockRouter: MockRouter;

  beforeEach(async () => {
    mockActivatedRoute = new MockActivatedRoute({id: 1});
    mockRouter = new MockRouter();

    await TestBed.configureTestingModule({
      declarations: [EditComponent],
      providers: [
        {provide: ActivatedRoute, useValue: mockActivatedRoute},
        {provide: Router, useValue: mockRouter}
      ],
      imports: [FormsModule, HttpClientTestingModule]
    }).compileComponents();

    mockSearchService = TestBed.inject(SearchService);
  });

  it('should fetch a single record', () => {
    const fixture = TestBed.createComponent(EditComponent);

    const person = new Person({id: 1, name: 'Michael Porter Jr.'});
    person.address = new Address({city: 'Denver'});

    // mock response
    spyOn(mockSearchService, 'get').and.returnValue(of(person));

    // initialize component
    fixture.detectChanges();

    // verify service was called
    expect(mockSearchService.get).toHaveBeenCalledWith(1);

    // verify data was set on component when initialized
    const editComponent = fixture.componentInstance;
    expect(editComponent.person.address.city).toBe('Denver');

    // verify HTML renders as expected
    const compiled = fixture.nativeElement;
    expect(compiled.querySelector('h3').innerHTML).toBe('Michael Porter Jr.');
  });
});

You should see "`Executed 11 of 11 SUCCESS" in the shell window that’s running ng test. If you don’t, try canceling the command and restarting.

Integration test the search UI

To test if the application works end-to-end, you can write tests with Cypress. These are also known as integration tests since they test the integration between all layers of your application.

You can use the official Cypress Angular Schematic to add Cypress to your Angular project.

ng add @cypress/schematic

When prompted to proceed and use Cypress for ng e2e, answer “Yes”.

This will add Cypress as a dependency and create configuration files to work with Angular and TypeScript. Rename cypress/e2e/spec.cy.ts to home.cy.ts and change it to look for the title of your app.

cypress/e2e/home.spec.ts
describe('Home', () => {
  it('Visits the initial project page', () => {
    cy.visit('/')
    cy.contains('Welcome to ng-demo!')
    cy.contains('Search')
  })
})

Then, run ng e2e. This will compile your app, start it on http://localhost:4200, and launch the Cypress Electron app.

Cypress Electron App
Figure 7. Cypress Electron App

If you click on the file name, it’ll launch a browser and run the test. You can use this feature to step through your tests, find selectors for elements, and much more. You can learn more about Cypress' features at Setting up Cypress for an Angular Project.

Personally, I prefer the Protractor experience where you could just run the command, it’d run all the tests, and the user doesn’t need to interact. You can do this with Cypress too!

The Cypress Angular Schematic added a few scripts to your package.json:

"scripts": {
  ...
  "e2e": "ng e2e",
  "cypress:open": "cypress open",
  "cypress:run": "cypress run"
}

To use the no-interaction approach, you’ll need to start your app:

npm start

Then, run the Cypress tests for it in another window:

npm run cypress:run
💡

You might notice Cypress creates a video. You can disable this by adding video: false to your cypress.config.ts file.

export default defineConfig({
  e2e: { ... },
  video: false,
  component: { ... }
})

The npm run cypress:run command will run a headless browser, so you won’t see anything happening on your screen.

If you want to see the tests run, append -- --browser chrome --headed to the command. Add this to your package.json if you want to make it the default. See Cypress' launching browsers documentation to see a list of supported browsers.

You can also install concurrently so you can run multiple tasks with one command.

npm install -D concurrently

Then, add a cy:run script to your package.json:

"scripts": {
  ...
  "cy:run": "concurrently \"ng serve\" \"cypress run\""
}

Then, you can run npm run cy:run to start your app and continuously run end-to-end tests on it when you change files.

Testing the search feature

Create another end-to-end test in cypress/e2e/search.cy.ts to verify the search feature works. Populate it with the following code:

cypress/e2e/search.cy.ts
describe('Search', () => {

  beforeEach(() => {
    cy.visit('/search')
  });

  it('should have an input and search button', () => {
    cy.get('app-root app-search form input').should('exist');
    cy.get('app-root app-search form button').should('exist');
  });

  it('should allow searching', () => {
    cy.get('input').type('A');
    cy.get('button').click();
    const list = cy.get('app-search table tbody tr');
    list.should('have.length', 3);
  });
});

Testing the edit feature

Create a cypress/e2e/edit.cy.ts test to verify the EditComponent renders a person’s information and that their information can be updated.

cypress/e2e/edit.cy.ts
describe('Edit', () => {

  beforeEach(() => {
    cy.visit('/edit/1')
  });

  it('should allow viewing a person',  () => {
    cy.get('h3').should('have.text', 'Nikola Jokić');
    cy.get('#name').should('have.value', 'Nikola Jokić');
    cy.get('#street').should('have.value', '2000 16th Street');
    cy.get('#city').should('have.value', 'Denver');
  });

  it('should allow updating a name', () => {
    cy.get('#name').type(' Rocks!');
    cy.get('#save').click();
    // verify one element matched this change
    const list = cy.get('app-search table tbody tr');
    list.should('have.length', 1);
  });
});

With your app running, execute npm run cypress:run to verify all your end-to-end tests pass. You should see a success message similar to the one below in your terminal window.

Cypress success
Figure 8. Cypress success

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 --no-watch --code-coverage.

You’ll see a print out of code coverage in your terminal window.

=============================== Coverage summary ===============================
Statements   : 79.41% ( 54/68 )
Branches     : 76.31% ( 29/38 )
Functions    : 83.33% ( 25/30 )
Lines        : 78.46% ( 51/65 )
================================================================================

You can also open coverage/ng-demo/index.html in your browser.

You might notice that the EditComponent could use some additional coverage. If you feel the need to improve this coverage, please create a pull request!

Test coverage
Figure 9. Test coverage

Continuous Integration

At the time of this writing, Angular CLI did not have any continuous integration support. This section shows you how to set up continuous integration with GitHub Actions and Jenkins.

GitHub Actions

If you’ve checked your project into GitHub, you can use GitHub Actions.

Create a .github/workflows/main.yml file. Add the following YAML to it. This will run both unit tests and integration tests with Cypress.

name: Angular

on: [push, pull_request]

jobs:
  build:
    name: Build and Test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Use Node 18
        uses: actions/setup-node@v3
        with:
          node-version: 18
      - name: Install latest Chrome
        run: |
          sudo apt update
          sudo apt --only-upgrade install google-chrome-stable
          google-chrome --version
      - name: Install dependencies
        run: npm ci
      - name: Run unit tests
        run: xvfb-run npm test -- --watch=false
      - name: Run integration tests
        uses: cypress-io/github-action@v5
        with:
          browser: chrome
          start: npm start
          install: false
          wait-on: http://[::1]:4200
💡
See issue #634 for more information on the strange syntax for wait-on.

Check it in on a branch, create a pull request for that branch, and you should see your tests running.

Jenkins

If you’ve checked your project into source control, you can use Jenkins to automate testing.

  1. Create a Jenkinsfile in the root directory and commit/push it.

    node {
        def nodeHome = tool name: 'node-18', 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 "npm test -- --watch=false"
        }
    
        stage('cypress tests') {
            sh "npm start &"
            sh "npm run cypress:run"
        }
    }
  1. Install Jenkins on your hard drive and start it.

  2. Login to Jenkins at http://localhost:8080 and install the Node.js plugin.

  3. Go to Manage Jenkins > Global Tool Configuration > NodeJS. Install and configure the name of your Node.js installation to match your build script.

  4. Create a new project with Dashboard > New Item > Pipeline > Pipeline script from SCM (near the bottom). Point it at your project’s repository and specify the main branch.

  5. Click Save, then Build Now on the following screen.

Deployment to Heroku

This section shows you how to deploy an Angular app to Heroku.

Run heroku create to create an app on Heroku.

Create a config/nginx.conf.erb file with the configuration for secure headers and redirect all HTTP requests to HTTPS.

daemon off;
# Heroku dynos have at least 4 cores.
worker_processes <%= ENV['NGINX_WORKERS'] || 4 %>;

events {
	use epoll;
	accept_mutex on;
	worker_connections <%= ENV['NGINX_WORKER_CONNECTIONS'] || 1024 %>;
}

http {
	gzip on;
	gzip_comp_level 2;
	gzip_min_length 512;
	gzip_proxied any; # Heroku router sends Via header

	server_tokens off;

	log_format l2met 'measure#nginx.service=$request_time request_id=$http_x_request_id';
	access_log <%= ENV['NGINX_ACCESS_LOG_PATH'] || 'logs/nginx/access.log' %> l2met;
	error_log <%= ENV['NGINX_ERROR_LOG_PATH'] || 'logs/nginx/error.log' %>;

	include mime.types;
	default_type application/octet-stream;
	sendfile on;

	# Must read the body in 5 seconds.
	client_body_timeout <%= ENV['NGINX_CLIENT_BODY_TIMEOUT'] || 5 %>;

	server {
		listen <%= ENV["PORT"] %>;
		server_name _;
		keepalive_timeout 5;
		client_max_body_size <%= ENV['NGINX_CLIENT_MAX_BODY_SIZE'] || 1 %>M;

		root dist/ng-demo;
		index index.html;

		location / {
			try_files $uri /index.html;
		}

		add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; frame-ancestors 'none'; connect-src 'self' https://*.auth0.com https://*.herokuapp.com";
		add_header Referrer-Policy "no-referrer, strict-origin-when-cross-origin";
		add_header Strict-Transport-Security "max-age=63072000; includeSubDomains";
		add_header X-Content-Type-Options nosniff;
		add_header X-Frame-Options DENY;
		add_header X-XSS-Protection "1; mode=block";
		add_header Permissions-Policy "geolocation=(self), microphone=(), accelerometer=(), camera=()";
	}
}
📎
In this code, you might notice that some https URLs are allowed in the content security policy. Those are there so this app can make XHR requests to those domains when that functionality is added.

For config/nginx.conf.erb to be read, you have to use the Heroku NGINX buildpack.

Add a Procfile to the root of your project.

web: bin/start-nginx-solo

Commit your changes to Git, add the Node.js + NGINX buildpack, and redeploy your Angular app using git push.

git add .
git commit -m "Configure secure headers and nginx buildpack"
heroku buildpacks:add heroku/nodejs
heroku buildpacks:add heroku-community/nginx
git push heroku main

View the application in your browser with heroku open. Try your app’s URL on https://securityheaders.com to be pleasantly surprised.

💡
You can watch your app’s logs using heroku logs --tail.

Source code

A completed project with this code in it is available on GitHub at https://github.com/mraible/ng-demo.

Summary

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.

Bonus: Angular Material, Bootstrap, Auth0, and Electron

If you’d like to see how to integrate Angular Material, Bootstrap, authentication with Auth0, or Electron 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.

More Repositories

1

history-of-web-frameworks-timeline

The history of web frameworks as described by a timeline of releases.
369
star
2

21-points

❤️ 21-Points Health is an app you can use to monitor your health.
TypeScript
282
star
3

idea-live-templates

My IntelliJ Live Templates
277
star
4

jhipster4-demo

Blog demo app with JHipster 4
Java
179
star
5

infoq-mini-book

Template project for creating an InfoQ Mini-Book with Asciidoctor
CSS
172
star
6

jhipster6-demo

JHipster 6 Demo! 🎉
Java
153
star
7

jhipster7-demo

JHipster 7 Demo! 🔥
TypeScript
81
star
8

jhipster5-demo

Get Started with JHipster 5 Tutorial and Example
Java
80
star
9

ajax-login

Ajax Login
JavaScript
46
star
10

angular2-tutorial

Getting Started with Angular 2
TypeScript
46
star
11

boot-ionic

An example mobile app written with Ionic Framework
JavaScript
36
star
12

microservices-for-the-masses

Microservices for the Masses with Spring Boot, JHipster, and JWT
CSS
36
star
13

java-webapp-security-examples

Example projects showing how to configure security with Java EE, Spring Security and Apache Shiro.
Java
36
star
14

jhipster8-demo

JHipster 8 Demo! 🌟
TypeScript
34
star
15

cloud-native-pwas

Cloud Native Progressive Web Apps with Spring Boot and Angular
Shell
31
star
16

spring-native-examples

JHipster Works with Spring Native!
Java
24
star
17

boot-makeover

A Webapp Makeover with Spring 4 and Spring Boot
Java
23
star
18

jhipster-book

The JHipster Mini-Book
CSS
21
star
19

camel-rest-swagger

Camel with Spring JavaConfig and Camel's REST + Swagger support with no web.xml
JavaScript
20
star
20

play-more

HTML5 Fitness Tracking with Play!
JavaScript
17
star
21

mobile-jhipster

Mobile Development with Ionic, React Native, and JHipster
TypeScript
17
star
22

jhipster-demo

JHipster 2 Demo
Java
16
star
23

java-rest-api-examples

Java REST API Examples
HTML
15
star
24

ionic-jhipster-example

Example of integrating Ionic with JHipster
Java
13
star
25

webflux-oauth2

Spring Webflux with OAuth
Java
11
star
26

devoxxus-jhipster-microservices-demo

JHipster Microservices Demo Code from Devoxx US 2017
Java
10
star
27

angular-tutorial

Getting Started with AngularJS
JavaScript
10
star
28

nuxt-spring-boot

Vue
7
star
29

angular-book

The Angular Mini-Book
TypeScript
7
star
30

spring-kickstart

JavaScript
7
star
31

jhipster-reactive-microservices-oauth2

Java
6
star
32

appfuse-noxml

A work-in-progress version of AppFuse with no XML
Java
6
star
33

okta-spring-boot-angular-example

TypeScript
6
star
34

jhipster-oidc-example

Example of doing OIDC Login with Keycloak and Okta
Java
5
star
35

okta-scim-spring-boot-example

A SCIM Example with Apache SCIMple and Spring Boot
Java
5
star
36

ionic-4-oidc-demo

Angular/Cordova demo of Ionic App Auth
TypeScript
4
star
37

spring-boot-oauth

Java
4
star
38

auth0-react-example

JavaScript
4
star
39

jhipster-stormpath-example

Example app showing how to integrate Stormpath into JHipster 3.x
Java
4
star
40

javaone2017-jhipster-demo

Blog application created during my JHipster talk at JavaOne 2017
Java
3
star
41

spring-boot-postgresql

Java
3
star
42

webflux-mongodb

Java
3
star
43

okta-native-hints

Java
3
star
44

jhipster-reactive-monolith-oauth2

Java
3
star
45

infoq-mini-book-presentation

Writing an InfoQ Mini-Book with Asciidoctor
JavaScript
3
star
46

play-scalate

Play Plugin for Scalate
Scala
3
star
47

okta-react-quickstart

JavaScript
3
star
48

okta-spring-boot-saml-example

Java
3
star
49

jhipster-micro-frontends

JHipster Microservices Architecture with Micro Frontends
Java
3
star
50

appauth-react-electron

JavaScript
2
star
51

react-native-logout-issue

JavaScript
2
star
52

angular-app

HTML
2
star
53

jx-demo

Java
2
star
54

jhipster-microfrontends-react

Java
2
star
55

speaking-tour

How to plan a speaking tour
2
star
56

vue-gateway

Java
2
star
57

bjug-microservices

Microservices with JHipster example from Boulder JUG - February 2020
Java
2
star
58

jhipster-sb3-csrf

Java
1
star
59

21-points-v7

TypeScript
1
star
60

environment-marespring-production

Makefile
1
star
61

mraible

1
star
62

blog-oauth2-native

TypeScript
1
star
63

blazor-test

C#
1
star
64

okta-react-auth-js

JavaScript
1
star
65

jwt-client

TypeScript
1
star
66

history-of-online-video-timeline

1
star
67

webflux-couchbase

Java
1
star
68

auth0-expenses-api

JavaScript
1
star
69

ionic-jhipster

TypeScript
1
star
70

ionic-4-oauth2

TypeScript
1
star
71

jhipster-ionic-images

Example repo with image upload/view/delete working
TypeScript
1
star
72

jhipster-blog-oauth2

Java
1
star
73

jhipster-flickr2

GitHub Actions for GraalVM images example
Java
1
star
74

spinnaker-store

Java
1
star
75

my-cool-app

JavaScript
1
star
76

jhipster4-stormpath-example

JHipster 4 + Stormpath + JWT Example
TypeScript
1
star
77

grails-oauth-example

Grails application demonstrating OAuth with LinkedIn's API
1
star
78

jhipster-oauth-example

This repo is temporary. It will be deleted soon. I'm creating it for community code review.
Java
1
star
79

okta-react-signin-widget

JavaScript
1
star
80

jhipster-microfrontends-angular

Java
1
star
81

html5-denver

TypeScript
1
star