Componenti di reattività per test unitari

Foto di un primo tentativo di testare un componente React di clement127 (CC BY-NC-ND 2.0)

I test unitari sono un'ottima disciplina che può portare a riduzioni del 40% -80% nella densità dei bug di produzione. I test unitari hanno anche molti altri importanti vantaggi:

  • Migliora l'architettura e la manutenibilità dell'applicazione.
  • Porta a migliori API e compostabilità focalizzando gli sviluppatori sull'esperienza degli sviluppatori (API) prima dei dettagli di implementazione.
  • Fornisce un feedback rapido sul salvataggio dei file per dirti se le modifiche hanno funzionato o meno. Questo può sostituire console.log () e fare clic sull'interfaccia utente per verificare le modifiche. I nuovi arrivati ​​ai test unitari potrebbero spendere un ulteriore 15% - 30% sul processo TDD mentre scoprono come testare vari componenti, ma i professionisti TDD esperti possono sperimentare risparmi nei tempi di implementazione usando TDD.
  • Fornisce una grande rete di sicurezza che può aumentare la tua sicurezza quando è il momento di aggiungere funzionalità o refactoring di funzionalità esistenti.

Ma alcune cose sono più facili da testare rispetto ad altre. In particolare, i test unitari funzionano perfettamente per le funzioni pure: funzioni che hanno lo stesso input, restituiscono sempre lo stesso output e non hanno effetti collaterali.

Spesso i componenti dell'interfaccia utente non rientrano in quella categoria di cose che sono facili da testare, il che rende più difficile attenersi alla disciplina del TDD: scrivere prima i test.

Scrivere prima i test è necessario per ottenere alcuni dei vantaggi che ho elencato: miglioramenti dell'architettura, migliore progettazione dell'esperienza degli sviluppatori e feedback più rapido durante lo sviluppo della tua app. Ci vuole disciplina e pratica per allenarsi ad usare il TDD. Molti sviluppatori preferiscono armeggiare prima di scrivere i test, ma se non scrivi prima i test, ti deruberai di molte delle migliori funzionalità dei test unitari.

Vale la pena la pratica e la disciplina, però. TDD con unit test può aiutarti a scrivere componenti dell'interfaccia utente che sono molto più semplici, più facili da mantenere e più facili da comporre e riutilizzare con altri componenti.

Una recente innovazione nella mia disciplina di test è lo sviluppo del framework di test delle unità RITEway, che è un piccolo involucro attorno a Tape che ti aiuta a scrivere test più semplici e più gestibili.

Indipendentemente dal framework utilizzato, i seguenti suggerimenti ti aiuteranno a scrivere componenti dell'interfaccia utente migliori, più testabili, più leggibili e più compostabili:

  • Favorisci componenti puri per il codice UI: dati gli stessi oggetti di scena, rendi sempre lo stesso componente. Se hai bisogno di stato dall'app, puoi avvolgere quei componenti puri con un componente contenitore che gestisce lo stato e gli effetti collaterali.
  • Isolare la logica di applicazione / le regole di business nelle funzioni di puro riduttore.
  • Isolare gli effetti collaterali usando i componenti del contenitore.

Favorire i componenti puri

Un componente puro è un componente che, dati gli stessi oggetti di scena, esegue sempre il rendering della stessa UI e non ha effetti collaterali. Per esempio.,

import React da 'reagire';
const Hello = ({userName}) => (
  
Ciao, {userName}!
);
esportazione predefinita Hello;

Questi tipi di componenti sono generalmente molto facili da testare. Avrai bisogno di un modo per selezionare il componente (in questo caso, selezioneremo il nome della classe di saluto) e dovrai conoscere l'output previsto. Per scrivere test di componenti puri, utilizzo render-component di RITEway.

Per iniziare, installa RITEway:

npm install --save-dev riteway

Internamente, RITEway utilizza il render-dom / server renderToStaticMarkup () e avvolge l'output in un oggetto Cheerio per una facile selezione. Se non stai usando RITEway, puoi fare tutto ciò manualmente per creare la tua funzione per rendere i componenti di React al markup statico che puoi interrogare con Cheerio.

Una volta che hai una funzione di rendering per produrre un oggetto Cheerio dal tuo markup, puoi scrivere test dei componenti in questo modo:

import {descrivi} da 'riteway';
importare il rendering da 'riteway / render-component';
import React da 'reagire';
import Hello da '../hello';
descrivi ('Hello componente', async assert => {
  const userName = 'Spiderman';
  const $ = render ();
  asserire({
    dato: "un nome utente",
    dovrebbe: 'Rendere un saluto al nome utente corretto.',
    effettivo: $ ('. saluto')
      .html ()
      .trim (),
    previsto: `Ciao, $ {userName}!`
  });
});

Ma questo non è molto interessante. Cosa succede se è necessario testare un componente con stato o un componente con effetti collaterali? È qui che TDD diventa davvero interessante per i componenti di React, perché la risposta a quella domanda è la stessa di un'altra importante domanda: "Come posso rendere i miei componenti di React più gestibili e facili da eseguire il debug?"

La risposta: isola il tuo stato e gli effetti collaterali dai componenti della presentazione. Puoi farlo incapsulando il tuo stato e la gestione degli effetti collaterali in un componente contenitore, e quindi passare lo stato in un componente puro attraverso i puntelli.

Ma l'API hooks non ha fatto in modo che possiamo avere gerarchie di componenti piatti e dimenticare tutto ciò che nidifica componente? Bene, non proprio. È comunque una buona idea mantenere il codice in tre diversi bucket e mantenere questi bucket isolati l'uno dall'altro:

  • Componenti display / UI
  • Logica del programma / regole aziendali: le cose che affrontano il problema che stai risolvendo per l'utente.
  • Effetti collaterali (I / O, rete, disco, ecc.)

Nella mia esperienza, se i problemi di visualizzazione / interfaccia utente sono separati dalla logica del programma e dagli effetti collaterali, ti semplifica la vita. Questa regola empirica è sempre stata vera per me, in tutte le lingue e in tutti i framework che abbia mai usato, incluso React with hooks.

Dimostriamo componenti con stato creando un contatore di clic. Innanzitutto, creeremo il componente dell'interfaccia utente. Dovrebbe apparire qualcosa come "Clic: 13" per dirti quante volte è stato cliccato un pulsante. Il pulsante dirà semplicemente "Click".

I test unitari per il componente di visualizzazione sono piuttosto semplici. Dobbiamo solo verificare che il pulsante venga visualizzato (non ci interessa cosa dice l'etichetta - potrebbe dire cose diverse in lingue diverse, a seconda delle impostazioni locali dell'utente). Vogliamo assicurarci che venga visualizzato il numero corretto di clic. Scriviamo due test: uno per la visualizzazione dei pulsanti e uno per il rendering corretto del numero di clic.

Quando utilizzo TDD, utilizzo spesso due diverse asserzioni per assicurarmi di aver scritto il componente in modo da estrarre il valore corretto dagli oggetti di scena. È possibile scrivere un test in modo da poter codificare il valore nella funzione. Per evitare ciò, è possibile scrivere due test, ciascuno dei quali ha un valore diverso.

In questo caso, creeremo un componente chiamato e quel componente avrà un supporto per il conteggio dei clic, chiamato clic. Per usarlo, è sufficiente eseguire il rendering del componente e impostare la proprietà dei clic sul numero di clic che si desidera visualizzare.

Diamo un'occhiata a un paio di unit test che potrebbero assicurarci di ottenere il conteggio dei clic dagli oggetti di scena. Creiamo un nuovo file, click-counter / click-counter-component.test.js:

import {descrivi} da 'riteway';
importare il rendering da 'riteway / render-component';
import React da 'reagire';
importare ClickCounter da '../click-counter/click-counter-component';
descrivi ('Componente ClickCounter', async assert => {
  const createCounter = clickCount =>
    render ()
  ;
  {
    const count = 3;
    const $ = createCounter (count);
    asserire({
      dato: "un conteggio dei clic",
      dovrebbe: 'visualizzare il numero corretto di clic.',
      actual: parseInt ($ ('. clicks-count'). html (). trim (), 10),
      previsto: contare
    });
  }
  {
    const count = 5;
    const $ = createCounter (count);
    asserire({
      dato: "un conteggio dei clic",
      dovrebbe: 'visualizzare il numero corretto di clic.',
      actual: parseInt ($ ('. clicks-count'). html (). trim (), 10),
      previsto: contare
    });
  }
});

Mi piace creare piccole funzioni di fabbrica per facilitare la scrittura dei test. In questo caso, createCounter richiederà un numero di clic per iniettare e restituisce un componente renderizzato utilizzando quel numero di clic:

const createCounter = clickCount =>
  render ()
;

Con i test scritti, è il momento di creare il nostro componente display ClickCounter. Ho raggruppato il mio nella stessa cartella con il mio file di test, con il nome click-counter-component.js. Innanzitutto, scriviamo un frammento di componente e osserviamo che il nostro test ha esito negativo:

import React, {Fragment} da 'reagire';
export default () =>
  
  
;

Se salviamo ed eseguiamo i nostri test, otterremo un TypeError, che attualmente attiva UnhandledPromiseRejectionWarning di Node - alla fine, Node si fermerà con gli avvisi irritanti con il paragrafo aggiuntivo di DeprecationWarning e lancerà invece UnhandledPromiseRejectionError. Otteniamo TypeError perché la nostra selezione restituisce null e stiamo provando a eseguire .trim () su di esso. Risolviamolo visualizzando il selettore previsto:

import React, {Fragment} da 'reagire';
export default () =>
  
     3 
  
;

Grande. Ora dovremmo avere un test di superamento e un test non riuscito:

# Componente ClickCounter
ok 2 Dato un conteggio dei clic: dovrebbe visualizzare il numero corretto di clic.
non ok 3 Dato un conteggio dei clic: dovrebbe visualizzare il numero corretto di clic.
  ---
    operatore: deepEqual
    previsto: 5
    attuale: 3
    at: assert (/home/eric/dev/react-pure-component-starter/node_modules/riteway/source/riteway.js:15:10)
...

Per risolverlo, prendi il conteggio come prop e usa il valore di prop live nel JSX:

import React, {Fragment} da 'reagire';
export default ({clicks}) =>
  
     {clicks} 
  
;

Ora tutta la nostra suite di test sta passando:

TAP versione 13
# Ciao componente
ok 1 Dato un nome utente: deve rendere un saluto al nome utente corretto.
# Componente ClickCounter
ok 2 Dato un conteggio dei clic: dovrebbe visualizzare il numero corretto di clic.
ok 3 Dato un conteggio dei clic: dovrebbe visualizzare il numero corretto di clic.
1..3
# test 3
# passa 3
# ok

È ora di testare il pulsante. Innanzitutto, aggiungi il test e guardalo fallire (stile TDD):

{
  const $ = createCounter (0);
  asserire({
    dato: "oggetti di scena previsti",
    dovrebbe: 'render il pulsante clic.',
    attuale: $ ('. click-button'). lunghezza,
    previsto: 1
  });
}

Questo produce un test fallito:

non ok 4 Dati di scena previsti: dovrebbe visualizzare il pulsante clic
  ---
    operatore: deepEqual
    previsto: 1
    effettivo: 0
...

Ora implementeremo il pulsante clic:

export default ({clicks}) =>
  
     {clicks} 
    
  
;

E il test ha superato:

TAP versione 13
# Ciao componente
ok 1 Dato un nome utente: deve rendere un saluto al nome utente corretto.
# Componente ClickCounter
ok 2 Dato un conteggio dei clic: dovrebbe visualizzare il numero corretto di clic.
ok 3 Dato un conteggio dei clic: dovrebbe visualizzare il numero corretto di clic.
ok 4 Dati di scena previsti: dovrebbe visualizzare il pulsante clic.
1..4
# test 4
# passa 4
# ok

Ora non ci resta che implementare la logica di stato e collegare il gestore eventi.

Test unitario Componenti con stato

L'approccio che sto per mostrarti è probabilmente eccessivo per un contatore di clic, ma la maggior parte delle app sono molto più complesse di un contatore di clic. Lo stato viene spesso salvato nel database o condiviso tra i componenti. Il ritornello popolare nella comunità di React è iniziare con lo stato del componente locale e quindi portarlo a un componente padre o allo stato dell'app globale in base alle necessità.

Si scopre che se si avvia la gestione dello stato dei componenti locali con funzioni pure, tale processo sarà più facile da gestire in seguito. Per questo e altri motivi (come la confusione del ciclo di vita di React, la coerenza dello stato, la prevenzione di bug comuni), mi piace implementare la mia gestione dello stato usando le funzioni di puro riduttore. Per lo stato del componente locale, è quindi possibile importarli e applicare il gancio useReducer React.

Se devi revocare lo stato per essere gestito da un dirigente statale come Redux, sei già a metà strada prima ancora di iniziare: unit test e tutto il resto.

Innanzitutto, creerò un nuovo file di prova per i riduttori di stato. Lo collegherò nella stessa cartella, ma userò un file diverso. Sto chiamando questo un clic-contatore / clic-contatore-riduttore.test.js:

import {descrivi} da 'riteway';
importare {riduttore, fare clic} da "../click-counter/click-counter-reducer";
descrivi ("click counter riduttore", async assert => {
  asserire({
    dato: "nessun argomento",
    dovrebbe: 'restituire lo stato iniziale valido',
    attuale: riduttore (),
    previsto: 0
  });
});

Comincio sempre con un'asserzione per garantire che il riduttore produca uno stato iniziale valido. Se in seguito decidi di utilizzare Redux, chiamerà ogni riduttore senza stato per produrre lo stato iniziale per il negozio. Ciò semplifica inoltre la creazione di uno stato iniziale valido ogni volta che è necessario per scopi di test dell'unità o per inizializzare lo stato del componente.

Ovviamente, dovremo creare un file riduttore corrispondente. Lo chiamo click-counter / click-counter-riduzioneer.js:

const click = () => {};
const riduttore = () => {};
export {riduttore, fare clic};

Sto iniziando semplicemente esportando un riduttore vuoto e un creatore di azioni. Per saperne di più sull'importante ruolo di cose come i creatori di azioni e i selettori, leggi "10 Suggerimenti per una migliore architettura Redux". In questo momento non faremo un tuffo profondo nei modelli di architettura di React / Redux, ma una comprensione dell'argomento farà molto per capire cosa stiamo facendo qui, anche se non utilizzerai la libreria Redux .

Innanzitutto, il test fallirà:

# riduttore contatore clic
non ok 5 Dato nessun argomento: dovrebbe restituire lo stato iniziale valido
  ---
    operatore: deepEqual
    previsto: 0
    attuale: indefinito

Ora facciamo passare il test:

const riduttore = () => 0;

Il test del valore iniziale passerà ora, ma è tempo di aggiungere test più significativi:

  asserire({
    dato: "stato iniziale e un'azione clic",
    dovrebbe: "aggiungere un clic al conteggio",
    attuale: riduttore (indefinito, click ()),
    previsto: 1
  });
  asserire({
    dato: "un conteggio dei clic e un'azione per i clic",
    dovrebbe: "aggiungere un clic al conteggio",
    attuale: riduttore (3, clic ()),
    previsto: 4
  });

Guarda i test falliti (entrambi restituiscono 0 quando dovrebbero restituire rispettivamente 1 e 4). Quindi implementare la correzione.

Nota che sto usando il creatore di azioni click () come API pubblica del riduttore. A mio avviso, dovresti pensare al riduttore come a qualcosa con cui la tua applicazione non interagisce direttamente. Utilizza invece i creatori e i selettori di azioni come API pubblica per il riduttore.

Inoltre non scrivo unit test separati per creatori di azioni e selettori. Le collaudo sempre in combinazione con il riduttore. Testare il riduttore sta testando i creatori e i selettori di azioni e viceversa. Se segui questa regola empirica, avrai bisogno di meno test, ma otterrai comunque lo stesso test e copertura del caso che avresti se li avessi testati in modo indipendente.

const click = () => ({
  digitare: "click-counter / click",
});
const riduttore = (stato = 0, {tipo} = {}) => {
  switch (tipo) {
    case click (). type: return state + 1;
    impostazione predefinita: stato di ritorno;
  }
};
export {riduttore, fare clic};

Ora tutti i test unitari passeranno:

TAP versione 13
# Ciao componente
ok 1 Dato un nome utente: deve rendere un saluto al nome utente corretto.
# Componente ClickCounter
ok 2 Dato un conteggio dei clic: dovrebbe visualizzare il numero corretto di clic.
ok 3 Dato un conteggio dei clic: dovrebbe visualizzare il numero corretto di clic.
ok 4 Dati di scena previsti: dovrebbe visualizzare il pulsante clic.
# riduttore contatore clic
ok 5 Dato nessun argomento: dovrebbe restituire lo stato iniziale valido
ok 6 Dato lo stato iniziale e un'azione clic: dovrebbe aggiungere un clic al conteggio
ok 7 Dato un conteggio dei clic e un'azione per i clic: aggiungere un clic al conteggio
1..7
# test 7
# passa 7
# ok

Ancora un altro passo: collegare il nostro comportamento al nostro componente. Possiamo farlo con un componente contenitore. Chiamerò semplicemente index.js e lo collegherò con gli altri file. Dovrebbe assomigliare a qualcosa di simile a questo:

import React, {useReducer} da 'reagire';
import Counter da './click-counter-component';
importare {riduttore, fare clic} da "./click-counter-reducer";
export default () => {
  const [click, dispatch] = useReducer (riduttore, riduttore ());
  return  dispatch (click ())}
  />;
};

Questo è tutto. L'unico compito di questo componente è collegare la nostra gestione dello stato e passare lo stato come oggetti di scena al nostro componente puro testato dall'unità. Per provarlo, carica l'app nel tuo browser e fai clic sul pulsante clic.

Fino ad ora non abbiamo esaminato il componente nel browser o fatto alcun tipo di stile. Solo per chiarire ciò che stiamo contando, aggiungerò un'etichetta e un po 'di spazio al componente ClickCounter. Collegherò anche la funzione onClick. Ora il codice è simile al seguente:

import React, {Fragment} da 'reagire';
export default ({clicks, onClick}) =>
  
    Clic:  {clicks}  & nbsp;
    
  
;

E tutti i test unitari continuano a passare.

Che dire dei test per il componente contenitore? Non collaudo i componenti del contenitore di test. Al contrario, utilizzo test funzionali, che eseguono nel browser e simulano le interazioni dell'utente con l'interfaccia utente effettiva, eseguendo end-to-end. Hai bisogno di entrambi i tipi di test (unità e funzionale) nella tua applicazione e test di unità dei componenti del tuo contenitore (che sono per lo più componenti di collegamento / cablaggio come quello che collega il nostro riduttore, sopra) sarebbe troppo ridondante con i test funzionali per i miei gusti e non particolarmente facile da testare correttamente. Spesso, dovresti prendere in giro varie dipendenze dei componenti del contenitore per farli funzionare.

Nel frattempo, abbiamo testato tutte le unità importanti che non dipendono dagli effetti collaterali: stiamo testando che i dati corretti vengano resi e che lo stato sia gestito correttamente. Dovresti anche caricare il componente nel browser e vedere di persona che il pulsante funziona e l'interfaccia utente risponde.

L'implementazione di test funzionali / e2e per React equivale a implementarli per qualsiasi altro framework. Dai un'occhiata a "Sviluppo comportamentale guidato (BDD) e test funzionali" per i dettagli.

Prossimi passi

Iscriviti al TDD Day: 5 ore di contenuti video di qualità e lezioni interattive su tutti gli aspetti dello sviluppo guidato dai test. È progettato per essere un ottimo corso intensivo di tutto il giorno per aumentare le competenze TDD di tutto il tuo team. Indipendentemente dalla tua attuale esperienza TDD, imparerai molto.

Eric Elliott è un esperto di sistemi distribuiti e autore dei libri "Composing Software" e "Programmazione di applicazioni JavaScript". Come co-fondatore di DevAnywhere.io, insegna agli sviluppatori le competenze di cui hanno bisogno per lavorare in remoto e abbracciare l'equilibrio tra lavoro e vita privata. Crea e fornisce consulenza a team di sviluppo per progetti crittografici e ha contribuito alle esperienze software per Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC e i migliori artisti discografici tra cui Usher, Frank Ocean, Metallica e molti altri.

Gode ​​di uno stile di vita remoto con la donna più bella del mondo.