DEV Community

Tomasz Flis
Tomasz Flis

Posted on

Angular simple form with async testing

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
Enter fullscreen mode Exit fullscreen mode

I used Material Angular to provide propper styling by typing (default answers):

ng add @angular/material 
Enter fullscreen mode Exit fullscreen mode

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,
  ],
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

Component

In AppComponent I defined simple form with one field which I set as required.

  form = this.formBuilder.group({
    text: [null, Validators.required],
  });
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

: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 the next 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 })
      );
Enter fullscreen mode Exit fullscreen mode

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 a Post saved! message. If an error occurs, then Something went wrong is displayed.

To pass data to subject I am using next method:

  create(text: string): void {
    this.subject.next(text);
  }
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Service tests

To check code coverage of our project, I typed in the command line:

ng test --code-coverage
Enter fullscreen mode Exit fullscreen mode

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.
Code coverage 1
I generated service with:

ng g service api
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode
  • 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' });
  }));
Enter fullscreen mode Exit fullscreen mode

it wraps a single unit test. fakeAsync allows me to wait for some time in the test.

const spy = spyOn(http, 'post').and.callThrough();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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' });
  }));
Enter fullscreen mode Exit fullscreen mode

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 });
  }));
Enter fullscreen mode Exit fullscreen mode

Main difference from first test is:

spyOn(http, 'post').and.returnValue(of(true));
Enter fullscreen mode Exit fullscreen mode

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 });
  }));
Enter fullscreen mode Exit fullscreen mode

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:
Code coverage 2

Link to repo.

Discussion (0)