La libreria di test in Angular
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';
"jest": {
"preset": "jest-preset-angular",
"setupFilesAfterEnv": [
"<rootDir>/setup-jest.ts"
]
}
"scripts": {
// ... other scripts used to run
"test": "jest",
"test:watch": "jest --watch"
}
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jest"
]
},
"files": [
"src/polyfills.ts"
],
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
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:
- Interroga il DOM per la presenza dell'elemento.
- Eseguire le azioni dell'utente e valutare il risultato nel DOM.
- Controlla se lo stato del componente è corretto.
<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');
});
});
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:
- Controlla se il nostro FormControl è correttamente associato al tag <input> .
- Controlla se abbiamo configurato correttamente i validatori FormControl .
- 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 ).
- 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;
});
// ...
});
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:
- 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.
- 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.
- 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.
- Percorsi critici che l'utente esegue normalmente sull'interfaccia utente (test DOM).
- API pubbliche o metodi da componenti, servizi, pipe, intercettatori, ecc.
- 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.

![Che cos'è un elenco collegato, comunque? [Parte 1]](https://post.nghiatu.com/assets/images/m/max/724/1*Xokk6XOjWyIGCBujkJsCzQ.jpeg)



































