Topic
The developer should test the code. In this example, I will create a simple form with an HTTP request after submission and test.
Project
I used Angular CLI to create the project (default CLI answers):
ng new notification-example
I used Material Angular to provide propper styling by typing (default answers):
ng add @angular/material
Main module
To be able to use required Material modules I added them in imports in AppModule
:
imports: [
BrowserModule,
BrowserAnimationsModule,
ReactiveFormsModule,
HttpClientModule,
MatInputModule,
MatFormFieldModule,
MatButtonModule,
MatSnackBarModule,
],
I also added HttpClientModule
to be able to make HTTP calls. ReactiveFormsModule
is for making Reactive forms.
Full Module code:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { HttpClientModule } from '@angular/common/http';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
BrowserAnimationsModule,
ReactiveFormsModule,
HttpClientModule,
MatInputModule,
MatFormFieldModule,
MatButtonModule,
MatSnackBarModule,
],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
Component
In AppComponent
I defined simple form with one field which I set as required.
form = this.formBuilder.group({
text: [null, Validators.required],
});
In the constructor, I used two injected classes:
-
FormBuilder
for making Reactie Form -
ApiService
for sending data via an HTTP request (Service description is placed lower). On form submission, I am checking if form is valid and if it is then I am passing field value to the service. Full component code:
import { Component } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { ApiService } from './api.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
export class AppComponent {
form = this.formBuilder.group({
text: [null, Validators.required],
});
constructor(
private readonly formBuilder: FormBuilder,
private readonly apiService: ApiService
) {}
onSubmit(): void {
if (this.form.invalid) {
return;
}
this.apiService.create(this.form.get('text').value);
}
}
HTLM part is really simple, It has form with one field and the submit button.
Full HTML code:
<form [formGroup]="form" (submit)="onSubmit()">
<mat-form-field appearance="fill">
<mat-label>Text</mat-label>
<input matInput formControlName="text">
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="form.invalid">Send</button>
</form>
To place form in center of the window I added some flexbox styling:
:host {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
form {
display: flex;
flex-direction: column;
width: 400px;
}
:host
applies styling to the component root element, so angular will apply styling to the <app-root>
element.
Service
At the beginning of the service, I defined two variables:
-
url
- URL address where service will send data -
subject
- RxJS class which is used to pass data to HTTP call. We can use thenext
method to pass that data.
Constructor has two injected classes:
-
HttpClient
to be able to make HTTP calls, -
MatSnackBar
for displaying snack bar from Angular Material. Subject is used to pass data:
this.subject
.pipe(
debounceTime(500),
switchMap((text) => this.http.post(`${this.url}posts`, { text }))
)
.subscribe(
() => this.snackBar.open('Post saved!', null, { duration: 3000 }),
() =>
this.snackBar.open('Something went wrong.', null, { duration: 3000 })
);
I am using Subject as an observable by calling the pipe
method to work on stream:
-
debounceTime
RxJS operator will wait with emission in a given time and ignores data emitted in a shorter period. -
switchMap
RxJS operator takes data from the outer observable and passes it to the inner observable. Angular Service from default is a singleton, so We don't have to unsubscribe the subject inside the constructor. If no error occurs during emission snack bar is opened with aPost saved!
message. If an error occurs, thenSomething went wrong
is displayed.
To pass data to subject I am using next
method:
create(text: string): void {
this.subject.next(text);
}
Full service code:
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Subject } from 'rxjs';
import { debounceTime, switchMap } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class ApiService {
private readonly url = 'https://jsonplaceholder.typicode.com/';
private readonly subject = new Subject<string>();
constructor(
private readonly http: HttpClient,
private readonly snackBar: MatSnackBar
) {
this.subject
.pipe(
debounceTime(500),
switchMap((text) => this.http.post(`${this.url}posts`, { text }))
)
.subscribe(
() => this.snackBar.open('Post saved!', null, { duration: 3000 }),
() =>
this.snackBar.open('Something went wrong.', null, { duration: 3000 })
);
}
create(text: string): void {
this.subject.next(text);
}
}
Service tests
To check code coverage of our project, I typed in the command line:
ng test --code-coverage
It uses a karma reporter to generate test coverage, which I can check in the coverage
directory. My Service test is missing some checks, so that I will add them.
I generated service with:
ng g service api
so I have a service file and *.spec.ts
file, which contains tests.
describe
block is for wrapping tests in group. beforeEach
method is triggered before each test. In this method in imports, I have:
describe('Service: Api', () => {
let service: ApiService;
let http: HttpClient;
let snackBar: MatSnackBar;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ApiService],
imports: [HttpClientTestingModule, MatSnackBarModule, NoopAnimationsModule],
});
service = TestBed.inject(ApiService);
http = TestBed.inject(HttpClient);
snackBar = TestBed.inject(MatSnackBar);
});
-
HttpClientTestingModule
- for faking HTTP request (I don't want to make real calls) -
MatSnackBarModule
- component needs it to construct -
NoopAnimationsModule
- component needs it to construct, faking animations next, I am taking required instances in tests: -
service
- my service instance allows me to use service methods -
http
- HTTP service, for mocking responses -
snackBar
for listening to method calls
Test: should send http call
it('should send http call', fakeAsync(() => {
const spy = spyOn(http, 'post').and.callThrough();
service.create('test');
service.create('test1');
tick(500);
expect(spy).toHaveBeenCalledOnceWith('https://jsonplaceholder.typicode.com/posts', { text: 'test1' });
}));
it
wraps a single unit test. fakeAsync
allows me to wait for some time in the test.
const spy = spyOn(http, 'post').and.callThrough();
I want to check if post
method will be called. I am passing http
instance to check that and .and.callThrough();
to execute code normally like inside service.
service.create('test');
service.create('test1');
tick(500);
I am passing value to the create
method like the component is doing. tick
waits for the time in given milliseconds (reason to wrap test with fakeAsync
).
expect(spy).toHaveBeenCalledOnceWith('https://jsonplaceholder.typicode.com/posts', { text: 'test1' });
}));
In the end, I am checking if my spy
(post
method from HTTP
service instance) is called only once with the same values as in service.
Test: should call open on snack bar positive
it('should call open on snack bar positive', fakeAsync(() => {
spyOn(http, 'post').and.returnValue(of(true));
const openSpy = spyOn(snackBar, 'open');
service.create('test');
tick(500);
expect(openSpy).toHaveBeenCalledOnceWith('Post saved!', null, { duration: 3000 });
}));
Main difference from first test is:
spyOn(http, 'post').and.returnValue(of(true));
I used .and.returnValue(of(true));
to fake response from HTTP service and I am returning new observable by using of
operator with value true
. The rest of the test is similar to the first one. In the end, I am checking if a "positive" snack bar was called.
Test: should call open on snack bar negative
it('should call open on snack bar negative', fakeAsync(() => {
spyOn(http, 'post').and.returnValue(throwError('err'));
const openSpy = spyOn(snackBar, 'open');
service.create('test');
tick(500);
expect(openSpy).toHaveBeenCalledOnceWith('Something went wrong.', null, { duration: 3000 });
}));
Like the second one, but I am checking if the "negative" snack bar was called.
Now, after checking code coverage, I have 100% code covered in my service, and all tests passed:
Link to repo.
Top comments (0)