La libreria di test in Angular

Dec 27 2022
Il framework angolare ha un enorme set di strumenti e configurazioni integrati che ci consentono di aggiornarci in modo molto più rapido e organizzato rispetto ad altri framework/librerie. Un set di strumenti è Jasmine e Karma, che fornisce strumenti per scrivere unit test.

Il framework angolare ha un enorme set di strumenti e configurazioni integrati che ci consentono di aggiornarci in modo molto più rapido e organizzato rispetto ad altri framework/librerie. Un set di strumenti è Jasmine e Karma, che fornisce strumenti per scrivere unit test. Ma dal punto di vista dello sviluppatore che ha bisogno di creare funzionalità che potrebbero dover essere rifattorizzate a causa di una rotazione dell'azienda o che dipendono troppo dai dettagli di implementazione, la scrittura di unit test inizia a diventare noiosa e richiede tempo.

Con questo in mente, gli sviluppatori iniziano a cercare alternative e best practice per evitare il refactoring degli unit test per ogni semplice modifica dell'interfaccia utente e scrivono anche test più puliti e incentrati sull'utente. La Testing Library viene in aiuto in questa materia. La libreria è molto famosa nel mondo React ed è stata ampliata per essere utilizzata anche in altri framework e ora sta diventando sempre più popolare tra gli sviluppatori Angular.

L'obiettivo di questo articolo è presentare una guida su come configurare da zero le applicazioni Angular per utilizzare la Testing Library. Inoltre, mostrerà come scrivere i test unitari per casi di test semplici incentrati sui test DOM e mirati a come un utente interagisce con l'app invece di un test unitario tradizionale dipendente dai dettagli programmatici e di implementazione.

Pro e contro di Testing Library in Angular

Professionisti

  • Filosofia chiara e semplice per la scrittura di test unitari buoni e gestibili.
  • Overhead ridotto durante il refactoring del codice o l'aggiunta di nuove funzionalità poiché i dettagli di implementazione non vengono testati*.
  • Dovrebbe essere testato solo ciò che l'utente vede, interagisce o API pubbliche*.
  • Gli eventi utente vengono simulati correttamente con un'API pulita e semplice.
  • È facile fare riferimento agli elementi DOM semplicemente interrogandoli in un modo di "prospettiva dell'utente".
  • Buona via di fuga: data-testid.
  • Aiuta gli sviluppatori a scrivere un'interfaccia utente conforme all'accessibilità imponendo loro di interrogare gli elementi DOM in base al ruolo e alla semantica corretta anziché a ID casuali e classi CSS, che sono dettagli di implementazione.
  • È tecnicamente compatibile con la libreria Jasmine predefinita utilizzata da Angular.
  • Richiede una mentalità diversa per scrivere buoni unit test
  • Potrebbe essere difficile da implementare su un grande progetto esistente, ma un piccolo sforzo potrebbe rivelarsi utile a lungo termine.

Installazione e configurazione

Rimuovi le librerie Jasmine e Karma

  • Rimuovere le voci seguenti in package.json e quindi eseguire npm install .
  • “@tipi/gelsomino”
  • "gelsomino-core"
  • "karma"
  • "karma-chrome-launcher"
  • "copertura del karma"
  • "karma-gelsomino"
  • "karma-jasmine-html-reporter"
  • Controlla se ci sono altri pacchetti relativi a jasmine o karma e rimuovi anche quelle voci.

Elimina i file relativi alle configurazioni di test Karma e Angular

  • karma.conf.js
  • test.ts all'interno della cartella dell'app.
  • Nel file angular.json , eliminare la configurazione "test" all'interno della sezione "architect".

Installa i pacchetti necessari

  • Installa i seguenti pacchetti usando il comando npm install --save-dev:
  • “@libreria-di-test/angular”
  • “@testing-library/jest-dom”
  • “@testing-library/evento-utente”
  • “@tipi/scherzo”
  • "scherzo"
  • "jest-preset-angolare"
  • Inoltre, Angular li ignorerà e non li includerà nel pacchetto di produzione.
  • Dopo l'installazione, Jest deve essere configurato per eseguire i nostri test e devono essere impostati gli script npm.
  • Innanzitutto, crea un file denominato setup-jest.ts nella radice del progetto con il seguente contenuto:
  • import '@testing-library/jest-dom';
    import 'jest-preset-angular/setup-jest';
    

  • In secondo luogo, aggiungi una sezione di configurazione Jest alla fine del file package.json , all'interno dell'oggetto root con quanto segue:
  • "jest": {
      "preset": "jest-preset-angular",
      "setupFilesAfterEnv": [
        "<rootDir>/setup-jest.ts"
      ]
    }
    

    "scripts": {
      // ... other scripts used to run
      "test": "jest",
      "test:watch": "jest --watch"
    }
    

  • In quarto luogo, anche il compilatore TypeScript necessita di alcune configurazioni aggiuntive. Su tsconfig.spec.json aggiungi quanto segue:
  • {
      "extends": "./tsconfig.json",
      "compilerOptions": {
        "outDir": "./out-tsc/spec",
        "types": [
          "jest"
        ]
      },
      "files": [
        "src/polyfills.ts"
      ],
      "include": [
        "src/**/*.spec.ts",
        "src/**/*.d.ts"
      ]
    }
    

  • Infine, per verificare se Jest è almeno in esecuzione, esegui il comando npm run test , che dovrebbe produrre qualcosa di simile a questo sulla console:
  • Output della console dopo l'esecuzione del comando "npm run test".

Per questa guida è necessaria un'applicazione di prova. L'applicazione ha un solo componente e un solo input di testo. L'input ha un validatore e mostra i messaggi di convalida se non valido e toccato. L'obiettivo è mostrare come scrivere unit test di base che:

  1. Interroga il DOM per la presenza dell'elemento.
  2. Eseguire le azioni dell'utente e valutare il risultato nel DOM.
  3. Controlla se lo stato del componente è corretto.
  4. L'applicazione di prova.

<section class="page-content">
  <form>
    <div class="control-group">
      <label for="username">Username <span class="danger-color">*</span></label>
      <div class="control">
        <input type="text" id="username" [formControl]="usernameControl">
        <div
          class="messages"
          *ngIf="usernameControl.invalid && usernameControl.touched">
            <ng-container *ngIf="usernameControl.hasError('required')">
             Username is required.
            </ng-container>
          </div>
        </div>
      </div>
  </form>
</section>

.page-content {
  width: 64rem;
  margin: 0 auto;
  padding: 1.5rem;
}

.control-group {
  display: flex;
  flex-direction: column;
}
.control-group label {
  display: block;
  font-weight: bold;
  font-size: 0.85rem;
  margin-bottom: 0.25rem;
  white-space: nowrap;
}

.control-group .control {
  width: 100%;
  margin-bottom: 1.1rem;
}

.control-group .control input {
  display: block;
  width: 100%;
  height: 2rem;
  font-size: 1rem;
  border: Max(1px, 0.0625rem) solid #9E9E9E;
  border-radius: Max(3px, 0.1875rem);
  padding: 0.25rem 0.5rem;
}
.control-group .control input:hover {
  border-color: #1976D2;
}
.control-group .control input:focus {
  border-color: #1976D2;
  outline: none;
  box-shadow: 0 0 6px #1976D2;
}
.control-group .messages {
  position: absolute;
  color: #D32F2F;
  font-size: 0.85rem;
  margin-top: 0.25rem;
}

.danger-color {
  color: #D32F2F;
}

import { Component, OnInit } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  usernameControl!: FormControl<string>;

  ngOnInit(): void {
      this.usernameControl = new FormControl<string>(
          '',
          {
              nonNullable: true,
              validators: [Validators.required]
          }
      )
  }
}

Per testarlo, come da convenzione della comunità, creiamo un file accanto al file che vogliamo testare con il seguente nome: file-to-test-name.spec.ts . Puoi anche separare i file di prova in una cartella specifica, ma non è consigliato perché all'inizio potrebbe sembrare più organizzato, ma man mano che l'applicazione cresce, avere quei file in cartelle diverse causa 2 problemi principali:

  • Difficile trovare e aprire quei file.
  • Più incline a eliminare il file di origine e il file di prova rimane "vivo", causando problemi.

import '@testing-library/jest-dom';

describe('AppComponent', () => {

  it('should render an input with a label', async () => {

  });
});

Abbattere lo scheletro

Nota: se hai già esperienza con la scrittura di unit test, potresti voler saltare questo argomento.

L' invocazione della funzione describe crea una cosiddetta suite di test per AppComponent . La stringa qui descrive la suite di test e può essere qualsiasi cosa. La mia preferenza è nominarla come la classe o la cosa che sto testando. Quindi passiamo una funzione di callback. Qui è dove scriviamo i nostri casi di test.

All'interno della callback della suite, abbiamo invocato una funzione it con una descrizione del test case. Il modo consigliato per scrivere una descrizione del test unitario è: "dovrebbe <fare qualcosa o comportarsi da testare in un determinato scenario>". Scrivere in questo formato rende le cose autoesplicative e molto più facili da capire sul rapporto di prova.

Per le funzioni it passiamo anche una callback. All'interno del callback è dove viene scritta la logica del test unitario che segue il modello AAA (Arrange, Act, Assert). La parola chiave async che precede il callback non è tecnicamente richiesta se il test è sincrono, ma quasi tutti i test scritti nella libreria di test avranno chiamate asincrone che richiedono l'uso di await .

Configurazione e istanziazione del componente per il test

Molte guide ci insegnano prima a fare le cose in modo disorganizzato per poi refactoring in un modo migliore. Per questa guida, andremo direttamente al punto e mostreremo come organizzare il file di unit test e in modo corretto.

Per quanto riguarda il primo test, vogliamo semplicemente verificare se l'input del nome utente viene visualizzato correttamente sullo schermo e se il suo tipo è testo.

import '@testing-library/jest-dom';

import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { render, RenderResult, screen } from '@testing-library/angular';
import userEvent from "@testing-library/user-event";

import { AppComponent } from './app.component';

describe('AppComponent', () => {

  const controlLabel: string = 'Username';

  // this is not necessary since we are using 'screen', but you can use this variable instead of
  // 'screen' to query DOM nodes and access the component instance
  let renderResult: RenderResult<AppComponent, AppComponent>;

  // Arrange, instead of writing this for each test, we use the hook
  beforeEach(async () => {
    renderResult = await render(
      AppComponent,
      {
        imports: [
          CommonModule,
          ReactiveFormsModule
        ]
      }
    );
  });

  it('should render an input with a label', async () => {
    // Arrange is done by the beforeEach hook, but sometimes you need additional things
    // specific to the test to be arranged, so you include them here as well

    // Act
    // This test doesn't have an act (e.g. trigger event).

    // Assert
    const input: HTMLElement = screen.getByLabelText(controlLabel, {exact: false});
    expect(input).toBeInstanceOf(HTMLInputElement);
    expect(input).toHaveAttribute('type', 'text');
  });
});

Rapporto di prova sulla console

Analizzare il codice di prova

Modello AAA:

  • Arrange: in questa parte, scriviamo il boilerplate per inizializzare le cose, come configurare il contenitore DI (Dependency Injection), per impostare alcune variabili e costanti che verranno utilizzate come parte del test, come gli input o gli argomenti della funzione stiamo testando e input e output dei componenti. Il boilerplate e la configurazione/inizializzazione del DI o del componente vengono normalmente estratti nell'hook beforeEach , quindi il test stesso diventa più snello e focalizzato sul caso d'uso.
  • Act: questa è la parte su cui agiamo sui nostri componenti o oggetti che vogliamo che reagiscano e testiamo in seguito se lo stato di output è corretto. Qui attiviamo eventi, come clic, input di dati e inviamo richieste http.
  • Assert: come suggerisce il nome, questa è la parte in cui verifichiamo se lo stato del componente risultante o il risultato di una chiamata di funzione sono corretti. Questa è la parte in cui vengono generati gli errori di aspettativa e il nostro test fallisce se il risultato non è quello previsto.
  • Se sei nuovo ai test, ci sono un paio di hook (cioè metodi del ciclo di vita) che possiamo usare per aumentare i nostri test e organizzare meglio il codice.
  • beforeEach : viene eseguito prima di ogni esecuzione nella suite di test (definita da describe ). Utilizzato per l'inizializzazione di componenti e DI.
  • beforeAll : viene eseguito una volta per la suite prima che tutto funzioni.
  • afterEach : opposto di beforeEach. Viene eseguito dopo ogni esecuzione it . Utilizzato per le attività di pulizia per evitare perdite di memoria o immondizia sul restante test sulla suite. Un caso d'uso è pulire localstorage , sessionstorage , indexeddb , ecc.
  • afterAll : opposto di beforeAll. Viene eseguito una volta per la suite dopo tutto funziona .

Variabili e costanti della tuta di prova:

  • Abbiamo creato 1 costante e 1 variabile.
  • Normalmente usiamo le costanti in una suite di test per memorizzare cose a cui facciamo riferimento molte volte nella suite. Un esempio è il nome dell'etichetta dell'input. Se scriviamo più test per quell'input, vedremo che dobbiamo copiare e incollare il testo dell'etichetta ogni volta, quindi è per questo che lo memorizziamo in una costante e ci riferiamo invece alla costante. Se l'etichetta cambia, è più facile sostituire il testo successivamente in un'unica posizione.
  • Le variabili a livello di suite vengono utilizzate per memorizzare i dati di inizializzazione che eseguiamo su beforeEach e fare riferimento a it e afterEach . Ad esempio, abbiamo memorizzato il renderResult , nonostante non lo usiamo come indicato nel codice.
  • Ricorda il principio DRY. Se stai copiando e incollando stringhe o numeri magici, crea una costante all'inizio della suite.
  • Qui diciamo a Testing Library di eseguire il rendering di un componente, AppComponent .
  • Poiché viene eseguito in un ambiente isolato, non è configurato alcun NgModule .
  • Il componente viene poi eseguito all'interno di un modulo “anonimo”, ecco perché dobbiamo configurarlo su renderOptions .
  • Nota che abbiamo scritto un imports array con le dipendenze di cui ha bisogno AppComponent , principalmente ReactiveFormsModule e CommonModule .
  • La descrizione segue la raccomandazione menzionata in precedenza.
  • Al suo interno non abbiamo extra Arrange cose da fare, né un Act (vedremo un esempio più avanti)
  • Assert è piuttosto semplice, usiamo semplicemente la variabile globale dello schermo importata da @testing-library/angular e chiediamo di trovare qualcosa basato su un'etichetta chiamando getByLabelText.
  • Se c'è un'etichetta sullo schermo con un testo simile (a causa delle opzioni esatto: falso), otterrà l'elemento HTML a cui punta l'etichetta nel suo attributo for . Il risultato è l'elemento di input.
  • Utilizzando questo approccio, Testing Library ci costringe a scrivere HTML semanticamente corretto e utilizzare i ruoli corretti e la configurazione dell'id per gli elementi. Sui componenti personalizzati HTML avanzati, possiamo utilizzare i ruoli Aria per definire selezioni personalizzate (noto anche come menu a discesa) e se seguite le specifiche corrette, verrà trattato quasi come se fosse una selezione nativa.
  • Qualsiasi metodo sull'oggetto dello schermo con prefisso get è sincrono e fallirà il test se l'elemento non viene trovato. D'altra parte, i metodi con prefisso find sono asincroni e restituiscono promesse.
  • Abbiamo anche reso il test più rigoroso controllando se l'elemento di input restituito su getByLabelText è effettivamente di tipo text .

Ora che abbiamo testato le basi, dobbiamo effettivamente fare dei veri test sul comportamento dell'input quando l'utente esegue alcune azioni su di esso. Per questa semplice applicazione ci sono almeno 4 test che dobbiamo fare:

  1. Controlla se il nostro FormControl è correttamente associato al tag <input> .
  2. Controlla se abbiamo configurato correttamente i validatori FormControl .
  3. Controlla se stiamo mostrando messaggi di convalida quando l'input non è valido e toccato (cioè l'utente ha inserito un input non valido - o nessuno in questo caso - e poi ha cambiato il focus su qualcos'altro - evento sfocato ).
  4. Controlla se il messaggio di convalida scompare immediatamente quando l'utente immette un input valido senza la necessità di sfocatura.

describe('Username control', () => {

  it.todo('should be correctly bind to the HTML input tag');

  it.todo('should not accept empty values - required validator');

  it.todo('should show the validation message if the input is invalid and was touched');

  it.todo('should remove the validation message immediately upon user entered a not empty value');
});

Successivamente, introduciamo una nuova variabile a livello di suite AppComponent denominata component . Questa è solo una variabile di convenienza per abbreviare il modo in cui accediamo all'istanza del componente:

describe('AppComponent', () => {

  const controlLabel: string = 'Username';

  let renderResult: RenderResult<AppComponent, AppComponent>;
  let component: AppComponent; // holds the component instance

  beforeEach(async () => {
      renderResult = await render(
          AppComponent,
          {
              imports: [
                  CommonModule,
                  ReactiveFormsModule
              ]
          }
      )
      // store the component instance
      component = renderResult.debugElement.componentInstance;
  });

  // ...
});

npm run test:watch — Ci sono 4 cose da fare.

Quando aggiungiamo .todo alla chiamata della funzione it , stiamo dicendo a Jest che intendiamo eseguire un test, ma non ora, quindi si ricorderà di noi ogni volta che eseguiamo i test. Se vogliamo implementare la cosa da fare, dobbiamo rimuovere .todo e aggiungere l'implementazione del callback, altrimenti riceveremo errori.

1° scenario di prova

Per verificare l'associazione, scriviamo un semplice test che inserirà alcuni dati nell'input e quindi verificherà se il valore di FormControl corrisponde a quanto digitato.

it('should be correctly bind to the HTML input tag', async () => {
  // Arrange
  const user = userEvent.setup();

  // Act
  const userInputValue = 'Prophet_95';
  const input: HTMLElement = screen.getByLabelText(controlLabel, {exact: false});

  await user.type(input, userInputValue);

  // Assert
  expect(component.usernameControl.value).toBe(userInputValue);
});

Per questo test, simuliamo l'utente che digita e quindi cancella l'input. Alla fine controlliamo solo se invalid e hasError sono nello stato desiderato.

it('should not accept empty values - required validator', async () => {
  // Arrange
  const user = userEvent.setup();

  // Act
  const userInputValue = 'Prophet_95';
  const input: HTMLElement = screen.getByLabelText(controlLabel, {exact: false});

  await user.type(input, userInputValue);
  await user.clear(input);

  // Assert
  expect(component.usernameControl.invalid).toBe(true);
  expect(component.usernameControl.hasError('required')).toBe(true);
});]

Nota: a causa del modo in cui si comporta Validators.required, l'immissione di spazi (ad es. ' ') è accettata come valore valido. Per trattarli come non validi abbiamo bisogno di un validatore personalizzato, che non è lo scopo di questo articolo. Fare riferimento alla documentazione di Angular per ulteriori informazioni.

3° scenario di prova:

Molto semplice, simuliamo che l'utente metta a fuoco l'input e lo lasci (cioè sfocatura) senza inserire alcun valore. Successivamente interroghiamo lo schermo per vedere se il messaggio di convalida è presente.

it('should show the validation message if the input is invalid and was touched', async () => {
  // Arrange
  const user = userEvent.setup();

  // Act
  const input: HTMLElement = screen.getByLabelText(controlLabel, {exact: false});

  await user.click(input);
  await user.tab();

  // Assert
  screen.getByText('Username is required.');
});

Per verificare se il messaggio scompare, ripeteremo la logica di test precedente ma l'utente focalizzerà l'input e digiterà alcuni dati al suo interno.

it('should remove the validation message immediately upon user entered a not empty value', async () => {
  // Arrange
  const user = userEvent.setup();
  const input: HTMLElement = screen.getByLabelText(controlLabel, {exact: false});

  // Act 1
  await user.click(input);
  await user.tab();

  // Assert 1
  screen.getByText('Username is required.');

  // Act 2
  const userInputValue = 'Prophet_95';
  await user.type(input, userInputValue);

  // Assert 2
  expect(() => screen.getByText('Username is required.')).toThrow();
});

Nota: normalmente non abbiamo più atti o asserzioni nello stesso it , ma a volte aiuta a garantire che il contesto del test sia nello stato giusto per prevenire falsi negativi.

Abbattere la logica del test

Una cosa molto importante da tenere a mente quando si scrivono test unitari per le interazioni DOM utilizzando Testing Library è avere in mente l'utente. Abbiamo usato un oggetto speciale per simulare l'utente e le sue azioni, lo userEvent (o il risultato dopo la chiamata di setup ).

L'evento userEvent ci espone una serie di azioni che possono essere eseguite da un utente reale e ha uno schema di denominazione molto intuitivo. Abbiamo usato il metodo del tipo nella maggior parte dei test e sotto il cofano simulerà ogni battitura di una tastiera simulando un utente che digita effettivamente qualcosa. Quando ciò accade, ci consente di testare molte altre cose perché verranno emessi tutti i normali eventi emessi da un DOM reale. Inoltre, abbiamo utilizzato click and tab , che, come suggerisce il nome, eseguirà un clic su un elemento HTML e premerà il tasto "tab" sulla tastiera.

L'ultima cosa che abbiamo fatto è stato aspettarci che venisse generato un errore se l'elemento non veniva trovato. Per questo, e per qualsiasi unit test che dovrebbe prevedere la generazione di un errore personalizzato o di un'eccezione, dobbiamo incapsulare la chiamata al metodo all'interno di un callback, quindi sotto il cofano l'eccezione può essere rilevata da un try/catch interno sull'implementazione di Jest .

Quando e cosa testare?

Come regola generale, ci sono alcune cose che non dovremmo testare. Principalmente ci sono tre casi in cui non scriviamo unit test:

  1. Cose che non abbiamo implementato noi stessi, quindi il comportamento di terze parti non viene testato da noi. Un esempio è testare se il validatore richiesto che abbiamo impostato sul nome utente FormControl viene chiamato quando l'utente inserisce i dati, ovvero codice e comportamento di terze parti già testati dal team di Angular.
  2. Cose che nonostante avessimo implementato noi stessi, risiedono in altri file o altre classi, componenti, pipe, ecc. Il test dovrebbe essere focalizzato solo sul contesto corrente e le dipendenze dovrebbero essere prese in giro se necessario.
  3. Membro privato o dettagli di implementazione. Testare quelli, l'80% delle volte, non è utile e dovrà essere rifattorizzato in futuro se l'implementazione cambia. Dovremmo testare solo le API pubbliche e le cose con cui l'utente interagisce.
  • Eccezione 1: se ciò che abbiamo creato è codice che funge da base o fa parte di un'API o di un SDK, come una classe astratta, e ha alcuni metodi protetti con logica. Questi metodi protetti devono essere testati per verificare se il comportamento previsto è corretto e, nelle versioni future, non introduciamo modifiche di rilievo indesiderate.
  • Eccezione 2: lo scenario in cui abbiamo un metodo privato (dettaglio di implementazione) che esegue una logica molto complessa, come un calcolo importante o una regola aziendale. Potremmo quindi voler scrivere alcuni unit test per garantire che la nostra logica sia corretta. Questo dovrebbe essere preso con cura per determinare cosa è considerato complesso/importante o meno.
  1. Percorsi critici che l'utente esegue normalmente sull'interfaccia utente (test DOM).
  2. API pubbliche o metodi da componenti, servizi, pipe, intercettatori, ecc.
  3. Casi limite comuni, sia nell'interfaccia utente che nell'API.

Conclusione

La libreria di test astrae gran parte della logica standard e fornisce un modo più pulito di scrivere test dell'interfaccia utente, spostando anche la mentalità dal test programmatico alla prospettiva dell'utente. Per i normali unit test delle API (es. servizi, pipe, ecc.), poiché utilizza Jest, lo sviluppatore può beneficiare anche di un supporto migliore rispetto a Jasmine e avere anche un report migliore rispetto a Karma.

Questo termina il primo articolo della serie Testing Library with Angular apps. Nel prossimo articolo tratteremo casi d'uso complessi che riguardano Input/Output e testeremo una piccola libreria dell'interfaccia utente creata utilizzando i ruoli di accessibilità. Inoltre, in futuro, un articolo speciale riguarderà il test del codice asincrono (ovvero Observables) e le richieste http.

Se hai qualcosa da contribuire, trovi un errore o forse vuoi discutere qualcosa che è stato scritto in questo articolo, sentiti libero di condividere un commento o contattarmi, sarò felice di discutere.