Blog

Resta aggiornato

Uno sguardo al testing partendo dai file .spec.ts e creazione dei nostri primi test
Angular Testing non mi fai paura
mercoledì 23 Dicembre 2020

La prima volta che ho aperto un file di test, di quelli che troviamo dopo aver creato un nuovo progetto mediante la CLI Angular, mi sono un po’ perso.

Avendo molta più familiarità con framework di test come XUnit o NUnit, ho cercato di fare il match tra quelle che sono le parti di un file di test in NUnit e quelle presenti in un file di test di Jasmine.

Di seguito, vediamo il codice del file app.component.spec.ts con l’obiettivo di analizzarlo, così da poter iniziare a creare i nostri file di test.

import { TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
 
describe('AppComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule
      ],
      declarations: [
        AppComponent
      ],
    }).compileComponents();
  }));
 
  it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  });
 
  it(`should have as title 'AngularTesting'`, () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app.title).toEqual('AngularTesting');
  });
 
  it('should render title in a h1 tag', () => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    expect(compiled.querySelector('h1').textContent).toContain('Welcome to AngularTesting!');
  });
});

Come primo passaggio ho cercato qualcosa del tipo [TestFixture] che mi identifichi che è una classe di test.

Nel codice troviamo describe(‘AppComponent’ () => {});che rappresenta il nostro [TestFixture].

Subito dopo c’è beforeEach(() => {}); che in NUnit viene identificato con l’annotazione [SetUp], con la quale stiamo istruendo NUnit ad eseguire prima di ogni test il metodo decorato, molto utile quando i test necessitano di una fase di setup comune. Allo stesso modo, le istruzioni contenute nel blocco beforeEach verranno eseguite prima di ogni test.

A questo punto ci manca [Test] che troviamo in genere sui metodi, ma in questo caso direi che è abbastanza semplice individuarlo, anche per come è formato il file.

it(‘should create the app’, () => {});

Ora che siamo riusciti a dividere il file di test possiamo analizzare le aree in modo più specifico.

Angular TestBed

In beforeEach troviamo la seguente istruzione:

TestBed.configureTestingModule({});

Innanzitutto, occorre capire chi è TestBed.

Angular Test Bed (ATB) è il framework di testing Angular a più alto livello che ci permette di testare tutto ciò che dipende dal Framework Angular stesso.

Tornando all’istruzione, ecco una delle cose che ci permette di fare TestBed: creare dinamicamente un modulo per il nostro test che simula un NgModule di Angular. La struttura è praticamente la stessa, con le import, le declarations e tutto quello che possiamo dichiarare in un NgModule.

Analizzando uno dei test, ad esempio:

it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  });

incontriamo un’altra istruzione in cui è presente ATB,

TestBed.createComponent(AppComponent);

La prima cosa che mi sono chiesto guardando questa istruzione è stata perché non è stato utilizzata una semplice new.

Un component non è solo una classe, con la new ottengo solo l’istanza della classe del component. Certo, posso ancora testare il suo funzionamento, ma non posso testare se gli elementi della DOM vengono correttamente creati e se interagiscono come mi aspetto.

Va quindi utilizzato il createComponent del ATB che crea un’istanza di AppComponent, alla quale è associato il template HTML e ritorna un oggetto di tipo ComponentFixture.

ComponentFixture

Un ComponentFixture è uno strumento che ci permette di debuggare e testare un componente.

it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  });

Possiamo intuire cosa stia testando questo metodo: si assicura che sia stata creata correttamente l’istanza di AppComponent.

Cos’è invece debugElement non è così immediato e va quindi analizzato.

Come abbiamo detto un component non è solo una classe ma è anche il suo template. ComponentFixture racchiude queste due cose in un unico oggetto, ed espone alcune proprietà come debugElement e nativeElement.

In nativeElement, Angular non è in grado di sapere a tempo di compilazione cosa andremo a trovare, dipende dall’ambiente di test che stiamo utilizzando.

Nel caso sia un ambiente browser, come quello che vedremo in questi test, ci sarà un HTMLElement e l’oggetto sarà lo stesso che troveremo in debugElement.nativeElement. Altrimenti, potrebbe esserci un oggetto che avrà delle API ridotte rispetto ad HTMLElement o non averne affatto.

Angular quindi si affida all’astrazione di debugElement per restare safe su ogni piattaforma supportata.

A partire dal HTMLElement possiamo utilizzare il querySelector per prendere gli elementi del DOM che ci interessa testare come accade nella seguente istruzione dell’ultimo test:

const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h1').textContent).toContain('Welcome to AngularTesting!');

Spy e TestBed.inject

A questo punto, siamo quasi pronti per scrivere una nostra classe di test, ma ancora non abbiamo modo di testare componenti o servizi che hanno dipendenze.

Ci sono diversi modi per poter fornire alla classe che stiamo testando le sue dipendenze.
Possiamo, ad esempio, passarle con una new, possiamo creare una classe Test Double, oppure, ed è la scelta consigliata, utilizzare uno spy.

Jasmine ci permette di creare uno spy mediante la seguente istruzione:

var fooSpy = jasmine.createSpyObj(foo, [bar]);

e, con l’istruzione seguente, possiamo fare il setup del metodo bar:

fooSpy.bar.and.returnValue('stub value');

ora non ci resta che creare la nostra classe passando lo spy

service = new Service(fooSpy);

Con questa tecnica, però, non stiamo utilizzando la dependency injection di Angular: nelle nostre applicazioni Angular non ci sogneremo mai di fare una new di una classe per passarla al nostro service. Generalmente, infatti, un approccio di questo tipo comporterebbe avere delle dipendenze che non facilitano il testing e richiederebbero la creazione manuale dell’istanza. Per superare il problema, registriamo nel modulo opportuno tutte le classi che ci servono nell’array Provider.

Vorremmo replicare lo stesso comportamento anche nei nostri test. Possiamo farlo combinando spy e ATB:

var fooSpy = jasmine.createSpyObj(Foo, [bar]);
TestBed.configureTestingModule({
    …
        providers: [
     Service,
      { provide: Foo, useValue: fooSpy }
    ]
    ..
});

Infine, nei metodi di test utilizzeremo le seguenti istruzioni per recuperare le istanze che ci servono

service = TestBed.inject(Service);
fooSpy = TestBed.inject(Foo) as jasmine.SpyObj<Foo>;

TestBed.inject è possibile utilizzarlo dalla versione 9 di Angular, per quelle precedenti va utilizzato TestBed.get.

Demo

Non ci resta che passare al codice e provare ad aggiungere una dipendenza all’AppComponent creato dalla CLI, e testare il tutto mediante uno spy.

Voglio creare un service che espone un metodo che restituisce una lista di stringhe: se la chiamata va a buon fine verrà mostrata la lista, altrimenti un messaggio di errore.

Vediamo come possiamo configurare il test. Innanzitutto creiamo il service:

import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AppService {
  constructor() { }
  getList(): Observable<string[]> {
    return of(['item1', 'item2', 'item3']);
  }
}

Aggiungiamolo ad app.module.ts inserendolo tra i provider

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule
  ],
  providers: [AppService],
  bootstrap: [AppComponent]
})
export class AppModule { }

modifichiamo app.component.ts per poter fare la chiamata a getList()

import { Component, OnInit } from '@angular/core';
import { AppService } from './app.service';
 
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'AngularTesting';
  items = [];
  constructor(service: AppService) {
    service.getList().subscribe(
      data => this.items = data
    );
  }
}

app.component.html per visualizzare la lista.

<div style="text-align:center">
  <h1>
    Welcome to {{ title }}!
  </h1>
</div>
<ul>
  <li *ngFor="let item of items">{{item}}</li>
</ul>

Ora possiamo passare ad aggiungere i nostri test, modificando la classe di test che abbiamo analizzato prima app.component.spec.ts

Eseguendo ora i test presenti, saranno tutti rossi. Nell’AppComponent abbiamo aggiunto una dipendenza con un metodo che ritorna un observable, quindi dobbiamo istruire la classe di test affinché gestisca la modifica.

Modifichiamo il beforeEach aggiungendo lo spy per il servizio creato, e procediamo con il setup del metodo getList

const items = ['item1', 'item2', 'item3'];
beforeEach(async(() => {
    const spy = jasmine.createSpyObj('AppService', ['getList']);
    spy.getList.and.returnValue(of(items));
 
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule
      ],
      declarations: [
        AppComponent
      ],
      providers: [
        { provide: AppService, useValue: spy }
      ]
    }).compileComponents();
  }));

Con i test di nuovo verdi, possiamo inserirne uno nuovo.

it('should render list when getList() work properly', () => {
  const fixture = TestBed.createComponent(AppComponent);
 
  fixture.detectChanges();
  const compiled = fixture.debugElement.nativeElement;
  const elements = compiled.querySelector('ul')
  .querySelectorAll('li');
 
  items.forEach((item, index) => {
    expect(elements[index].textContent === item);
  });
});

Con questo test ci assicuriamo che gli elementi della lista mostrati all’utente, nel caso in cui getList vada a buon fine, siano esattamente quelli che abbiamo fornito nel setup.

Manca il test in cui la chiamata genera un errore, e vogliamo mostrare all’utente un messaggio di errore.

Nel componente, al momento non abbiamo nulla che gestisca tale errore lato codice né elementi in grado di visualizzarlo, dovremo quindi modificarlo per poter gestire questa casistica.

Proviamo, però, a partire con un approccio TDD, partendo quindi dal test, e poi modifichiamo il componente.

Come primo passo andiamo a recuperare lo spy di AppService e cambiamo il comportamento del metodo getList, restituendo un errore.

Ora possiamo creare il nostro expect: mediante querySelector recuperiamo l’elemento html che dovrebbe contenere il messaggio di errore e verifichiamo che contenga esattamente “Error!”.

Il test completo dovrebbe essere così

it('should show error message when getList() return an error', () => {
   const errorMessage = 'Fake error';
   const spy = TestBed.get(AppService) as jasmine.SpyObj<AppService>;
   spy.getList.and.callFake(() => {
     return throwError(new Error(errorMessage));
   });
   const fixture = TestBed.createComponent(AppComponent);
 
   fixture.detectChanges();
   const compiled = fixture.debugElement.nativeElement;
   const element = compiled.querySelector('h2.error');
 
   expect(element.textContent.trim()).toEqual(errorMessage);
 });

Lanciando i test, avremo un test rosso, e la motivazione è che non può essere letta la proprietà textContent di un oggetto nullo. In parole povere, non è stato trovato nessun elemento h2 con classe error nel componente. Mi sarei sorpreso del contrario.

Andiamo a modificare ora il componente per catturare l’errore lanciato da getList:

app.component.ts
import { Component } from '@angular/core';
import { AppService } from './app.service';
 
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'AngularTesting';
  items = [];
  errorMessage = '';
 
  constructor(service: AppService) {
    this.errorMessage = '';
 
    service.getList().subscribe(
      data => this.items = data,
      error => this.errorMessage = error.message
    );
  }
}

e mostriamolo mediante tag h2 con classe error come stabilito nel test:

app.component.html
<h1>
    Welcome to {{ title }}!
  </h1>
</div>
<h2 class="error" *ngIf="errorMessage">
  {{errorMessage}}
</h2>
<ul>
  <li *ngFor="let item of items">{{item}}</li>
</ul>

Infine rilanciamo i test:

In un solo articolo non è stato possibile affrontare tutti gli scenari che si presentano nei test. Ma questa può essere di sicuro una buona base per poter iniziare a comprendere i test esistenti in un progetto Angular e per poter iniziare a scrivere i nostri test che coprono le richieste delle attività che ci hanno assegnato.

Spero che l’articolo vi sia piaciuto.
Al prossimo!

Scritto da

Adolfo Arnold