Il mio foglio riassuntivo dei test unitari in Angular

Per chi di voi ha avuto l'opportunità di dare un'occhiata al framework Angular (V2 e altro) ha visto come la documentazione sul sito ufficiale potrebbe essere voluminosa, soprattutto su la parte Test unitari. Lo scopo di questo articolo è fornire un foglio riepilogativo delle migliori pratiche di unit test in Angular.
L'articolo presuppone che tu abbia già utilizzato Angular e i suoi unit test e che tu conosca il campo lessicale degli Unit Test.
I consigli e le soluzioni fornite qui provengono da un'esperienza di sei mesi di sviluppo Angular con un team di cinque persone. Naturalmente sono soggetti a miglioramenti/discussioni.

Preambolo: come "deridere" correttamente con Angular?

Un piccolo preambolo è necessario per chiarire un punto che per me è troppo spesso trascurato nella letteratura su questo argomento, e che sarà vitale per il resto della comprensione:
Mock, Spy e altri Stub.
Troppo spesso, negli esempi di test unitari angolari viene offerto un approccio alquanto ingenuo. Prendiamo il caso dal test di un componente nella documentazione ufficiale:

class MockUserService {
  isLoggedIn = true;
  user = { name: "Test User" };
}
/* ... */
beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      WelcomeComponent,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.get(WelcomeComponent);
  userService = TestBed.get(UserService);
});

Questo codice lo spiega per deridere l'implementazione di un servizio (qui UserService) crea semplicemente una classe falsa che sostituisce questo servizio (qui MockUserService).
Questo approccio fondamentalmente mi infastidisce perché perdiamo tutto il controllo del tipo. In realtà, non vi è alcuna garanzia che MockUserService rispetta l'interfaccia della classe UserService. A ciò si aggiunge il costo di scrivere una lezione falsa...
[bctt tweet="In Angular, creare un mock a mano non è né un approccio sicuro né efficiente."] Un altro modo per farlo, che può essere visto qua e là, è estendere la classe che vogliamo prendere in giro, allora, sovraccaricare i comportamenti desiderati. Se torniamo al nostro esempio precedente, sarebbe simile a questo:

class MockUserService extends UserService {
  user = { name: "Another user" };
}
/* ... */
beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      WelcomeComponent,
      FirstLevelNeededService,
      SecondLevelNeededService,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.get(WelcomeComponent);
  userService = TestBed.get(UserService);
});

Questo metodo è anche peggiore del precedente perché anche se hai risolto il problema di digitazione e verbosità, hai rotto l'isolamento necessario per uno unit test.
Come la vera implementazione di UserService è importato, TestBed richiederà che lo forniamo anche con tutte le classi su cui UserService. Immagina lo schema delle dipendenze di seguito:

+---------------+ +--------------------------+ +---- - ---------------------+ | | | | | | | UserService +<---+ FirstLevelNeededService +<----+ SecondLevelNeededService | | | | | | | +---------------+ +------------+ +---- -------------- --------+

Seguendo questo schema, dovremmo fornire il TestBed, FirstLevelNeededService et SecondLevelNeededService. Questo non ha senso se ci pensi perché lo scopo di un mock è simulare il comportamento di una classe. Allora perché fornire dipendenze fittizie?
[bctt tweet=”Non creare mai una simulazione di una classe TypeScript estendendo la sua implementazione originale in Angular.”] Altrimenti dovrai fornire un gran numero di classi per ogni suite di test.

Usa una finta libreria

Per gli sviluppatori orientati agli oggetti, l'uso di una libreria fittizia è ovvio nel contesto degli unit test perché il loro linguaggio spesso non è dinamico come JavaScript e non ti consente di scrivere Mock al volo ("al volo"). snatch?" - Chi ha detto questo?).
Una libreria Mock utilizzerà la definizione del tipo creata tramite TypeScript per creare una simulazione di una classe.
Vecchio riflesso di “Javaiste” forse, personalmente mi piace è mockito come il libraio di Mock, ma ce ne sono altri: TS-finzione o Tipomoq
Se prendiamo il nostro esempio con ts-mockito, il nostro codice sarebbe simile a questo:

import { instance, mock, when } from "ts-mockito";
/***/
let userServiceMock: UserService;
beforeEach(() => {
  userServiceMock = mock(UserService);
  when(userServiceMock.user).thenReturn({ name: "A user" });
  TestBed.configureTestingModule({
    providers: [
      WelcomeComponent,
      { provide: UserService, useValue: instance(userServiceMock) }
    ]
  });
  userService = TestBed.get(UserService);
});

Semplice, non è vero? ts-mockito creerà prima una classe presa in giro usando la funzione mock quindi crea istanze fittizie basate su questa classe con la funzione instance.
Per simulare i risultati, useremo spesso la funzione when. Usa sempre when su una classe presa in giro e chiama instance solo in seguito, altrimenti la tua istanza mock non avrà i comportamenti definiti.

Nel resto dell'articolo useremo sistematicamente ts-mockito

La guida

Caso n°1: Test di un servizio senza dipendenza con Angular

Partiamo dal più semplice, il servizio. In Angular, un servizio non è né più né meno di una classe TypeScript che verrà istanziata da CIO-container di Angular e iniettato in tutti gli altri elementi che lo definiranno come dipendenze.
Se il servizio non utilizza una dipendenza proveniente da Angular (come il HttpClient per esempio) puoi testarlo come qualsiasi classe TypeScript in qualsiasi progetto come questo:

import { MyService } from "./my.service";
import { instance, mock, when } from "ts-mockito";
import { MathLib } from "./my.service";
describe("MyService", () => {
  it("should add correctly two positive integers and multiply them by two", () => {
    // given
    const mathMock = mock(MathLib);
    when(mathMock.add(1, 2)).thenReturn(3);
    when(mathMock.multiply(3)).thenReturn(6);
    const myService = new MyService(instance(mathMock));
    // when
    const result = myService.addAndMultiplyByTwo(1, 2);
    // then
    expect(result).toBe(6);
  });
});

Primo punto, noterai che contrariamente a angolare-cli non creiamo un test should be created. Questo test che servirebbe per verificare l'esistenza della classe è di fatto già verificato dagli altri unit test, quindi inutile.
Secondo e potenzialmente più importante punto di questo articolo:
[bctt tweet=”Usa `TestBed` solo raramente nei test di unità angolari.”] Il TestBed, più specificamente la chiamata al relativo metodo configureTestingModule, è lungo e porta maggiore complessità nei test. E per una buona ragione, la chiamata al metodo TestBed.configureTestingModule come suggerisce il nome, creerà a Modulo angolare ad ogni chiamata e istanziare tutti gli elementi di questo 'modulo falso' grazie al contenitore IOC di Angular.
Abbiamo osservato nei nostri test che l'uso di TestBed impiegherà potenzialmente cinque volte più tempo di una semplice istanziazione di un oggetto e dei suoi mock come nel nostro esempio (maggiori informazioni qui): TestBed angolare è troppo lungo)
Non abbiamo inventato nulla lì, si chiama a Prova isolata nel documento Angular (di cui non riesco più a trovare il link perché è enorme :D)
Ok, ma cosa succede se lo uso Module proveniente da Angular come il HttpModule ?

Caso n°2: Testare un servizio che dipende da un modulo Angular

Eseguiamo un test abbastanza standard di un servizio di autenticazione che utilizza il HttpClient :

import { AuthService } from "./auth.service";
import { inject, TestBed } from "@angular/core/testing";
import {
  HttpClientTestingModule,
  HttpTestingController
} from "@angular/common/http/testing";
describe("AuthService", () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [AuthService],
      imports: [HttpClientTestingModule]
    });
  });
  it(
    "should return null user when server responds HTTP error",
    inject(
      [HttpTestingController, AuthService],
      (httpMock: HttpTestingController, service: AuthService) => {
        service.user.subscribe(user => {
          expect(user).toBe(null);
        });
        httpMock.expectOne("auth/user").error(new ErrorEvent("401"));
        httpMock.verify();
      }
    )
  );
});

Non abbiamo altra scelta che usare il TestBed perché è lui che consente l'accesso al httpMock responsabile della simulazione delle risposte del server.
[bctt tweet=”Utilizzeremo `TestBed` solo per testare componenti o classi dipendenti dal modulo Angular.”] Noterai anche l'uso del piccolo helper inject, utile per recuperare le istanze create all'interno del contenitore IOC di Angular. Potresti anche aver notato che il mio beforeEach non contiene una chiamata a async. Async tende ad essere utilizzato indiscriminatamente, ma questa funzione è lì per attendere un'asincronia.
Se guardi il documentazione ufficiale configureTestingModule non tornare da Promise, quindi non è asincrono, quindi non vale la pena chiamare async in questo caso.

Caso n°3: Testare un componente “base”.

Questo è il tipo di componente che scrivi molto in Angular.
Vedremo nel caso seguente che anche i componenti posti in alto nella gerarchia saranno oggetto di ulteriore elaborazione.
Per illustrare questo test, immaginiamo di essere in fase di verifica di un componente ListComponent che visualizza un elenco caricato da un servizio ItemsService : un caso da manuale...
Il modo corretto per farlo sarebbe simile a questo:

import { ComponentFixture, TestBed } from "@angular/core/testing";
import { anyString, instance, mock, when } from "ts-mockito";
import { ListComponent } from "./list.component";
import { ItemsService } from "./item/items.service";
import { Item } from "./item/item.model";
import { of } from "rxjs/observable/of";
describe("ListComponent", () => {
  let component: ListComponent;
  let fixture: ComponentFixture<ListComponent>;
  let mockedItemsService: ItemsService;
  beforeEach(() => {
    mockedItemsService = mock(ItemsService);
  });
  async function configureTestingModule() {
    await TestBed.configureTestingModule({
      providers: [
        { provide: ItemsService, useValue: instance(mockedItemsService) }
      ],
      declarations: [ListComponent]
    }).compileComponents();
    fixture = TestBed.createComponent(ListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }
  it("should display an empty list", async () => {
    // given
    when(mockedItemsService.loadItems()).thenReturn(of([new Item()]));
    // when
    await configureTestingModule();
    // then
    const nbEl = fixture.debugElement.queryAll(By.css("li")).length;
    expect(nbEl).toEqual(0);
  });
});

La prima cosa che potrebbe averti scioccato è stata la funzione configureTestingModule al di fuori di beforeEach. Sarà infatti necessario richiamarlo nei nostri unit test e non prima per mantenere l'ordine di chiamata delle funzioni mockito citate nel preambolo:

mock -> quando -> istanza

mock sarà chiamato nel beforeEach puis when all'inizio del test unitario e infine instance attraverso la chiamata di configureTestingModule nel bel mezzo del test unitario.
Se ci concentriamo su configureTestingModule, vediamo che è asincrono perché invochiamo compileComponents che restituisce una promessa. Questa async "contamina" il nostro test unitario perché dobbiamo contrassegnarlo come async anche.
Noteremo anche che stiamo sovraccaricando il provider con ItemsService per dargli l'istanza del nostro mock ts-mockito:

{fornire: ItemsService, useValue: instance(mockedItemsService)}

Ciò garantirà che il TestBed inietta questo simulacro di ItemsService al componente quando viene creato.
Infine, simuliamo il ritorno del metodo loadItems del servizio in modo che restituisca a Observable&lt;Item[]&gt; vuoto e controlliamo che l'elenco html degli elementi sia effettivamente vuoto.

Caso n°4: Testare un componente di alto livello

Quelli che chiamo componenti di "alto livello", sono i componenti che si trovano in alto nella gerarchia dei componenti della nostra applicazione. Le pagine componenti o contenenti molti figli corrispondono perfettamente a questa denominazione. il AppComponent sarà l'esempio utilizzato.
La cosa fastidiosa per verificare il comportamento di questo tipo di componenti: dipendono per definizione da molti sottocomponenti e sottomoduli e sarà necessario TOUS aggiungerli nel TestBed. Per il AppComponent è come dargli quasi tutta l'app in dipendenza...
Se, inoltre, ti dicessi che il TestBed.configureTestingModule era lento nel caso 1, diventa più lento all'aumentare del numero di dipendenze.
Per ovviare a questo problema c'è il parametro schemas da fornire a TestBed.configureTestingModule con il valore NO_ERRORS_SCHEMA :

import { TestBed } from "@angular/core/testing";
import { AppComponent } from "./app.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
describe("AppComponent", () => {
  let component: ListComponent;
  let fixture: ComponentFixture<ListComponent>;
  beforeEach(async () => {
    TestBed.configureTestingModule({
      providers: [],
      imports: [],
      declarations: [AppComponent],
      schemas: [NO_ERRORS_SCHEMA]
    }).compileComponents();
    fixture = TestBed.createComponent(ListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  it("should render title in a h1 tag", async () => {
    // given
    const selectorText = compiled.querySelector("h1").textContent;
    // then
    expect(selectorText).toBe("My awesome app");
  });
});

Questo parametro magico, configura il TestBed in modo che non generi un errore se non riesce a trovare il provider di un componente o un modulo utilizzato nei modelli dei componenti testati. Questo metodo è chiamato shallow testing. Testiamo solo il componente AppComponent e non interpretarne i sottocomponenti.
Le NO_ERRORS_SCHEMA deve essere usato con cautela, perché applicato ovunque, rende inerti i controlli del sistema delle dipendenze e potrebbe farti dimenticare di caricare un componente quando è necessario nel tuo test case.
[bctt tweet="In Angular, `NO_ERRORS_SCHEMA` dovrebbe essere usato con cautela e solo per testare i componenti in alto nella gerarchia."]

Caso n°5: Tubi, classi, il resto

Le pipe, come il resto degli elementi che potrebbero comporre la tua applicazione, verranno testate come servizi senza dipendenza Angular, ovvero classi TypeScript di base.
Immaginiamo una pipe il cui obiettivo sia unire sotto forma di stringa di caratteri tutte le chiavi di un oggetto il cui valore non sia nullo. Lo chiameremo AliciaKeys… (sì, eravamo abbastanza orgogliosi del nome)

import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
  name: "aliciaKeys"
})
export class AliciaKeys implements PipeTransform {
  transform(obj: any, arg: string[]): string {
    return obj
      ? Object.entries(obj)
          .filter(([key, value]) => !!value)
          .map(([key]) => key)
          .join(", ")
      : "";
  }
}

Il file di unit test allegato è banale poiché è una semplice istanza della classe AliciaKeys con due chiamate da transform in due diversi casi:

import { AliciaKeys } from "./aliciakeys.pipe";
describe("AliciaKeys", () => {
  it("should return valued keys", () => {
    const result = new AliciaKeys().transform({
      validKey: {},
      inValidKey: null,
      anotherValidKey: 1
    });
    expect(result).toEqual("validKey, anotherValidKey");
  });
  it("should return blank when input is falsy", () => {
    const result = new AliciaKeys().transform(null;
    expect(result).toEqual("");
  });
});

Conclusione e andare oltre

Ci auguriamo che ogni descrizione del caso aiuti anche te a capire meglio come testare correttamente le tue applicazioni Angular.
I punti importanti da ricordare sono che le classi in Angular rimangono classi TypeScript classiche e possono essere testate facilmente. Il secondo punto è che il TestBed non è sistematico, che dovrebbe essere usato solo quando non hai scelta. Infine, i mock in Angular devono essere gestiti con una libreria mock: nel nostro caso ts-mockito.
Dovresti sapere che nel nostro progetto abbiamo avuto circa 700 unit test che hanno richiesto più di 30 secondi per essere eseguiti. Snellire i test e accelerarli è diventato fondamentale per noi.
Abbiamo anche continuato questa ricerca di accelerazione superando questi ultimi Scherzare. Questo è del tutto possibile seguendo le istruzioni nel repository jest-preset-angolare e fa risparmiare un sacco di tempo!
Torneremo da te con maggiori dettagli su questi argomenti durante il nostro feedback "JS-Talks" (giornata di controllo della tecnologia che facciamo ogni mese). Alcune foto finali per vedere come appare:

Pubblicato da Matteo Bretone CTO presso JS-Repubblica