Il deridere è un odore di codice

Smoke Art Cubes to Smoke - MattysFlicks - (CC BY 2.0)
Nota: fa parte della serie "Composing Software" (ora un libro!) Sull'apprendimento da zero della programmazione funzionale e delle tecniche software compositive in JavaScript ES6 +. Rimanete sintonizzati. C'è molto di più a venire!

Una delle maggiori lamentele che sento parlare di TDD e test unitari è che le persone lottano con tutto il beffardo richiesto per isolare le unità. Alcune persone fanno fatica a capire come i loro test unitari siano persino significativi. In effetti, ho visto gli sviluppatori perdere così tanto in simulazioni, falsi e stub che hanno scritto interi file di unit test in cui non è stato assolutamente utilizzato alcun codice di implementazione. Ops.

Dall'altra parte dello spettro, è comune vedere gli sviluppatori essere così risucchiati dal dogma di TDD che pensano di dover assolutamente ottenere una copertura del codice del 100%, con ogni mezzo necessario, anche se ciò significa che devono rendere la loro base di codice più complessa per toglierlo.

Dico spesso alle persone che il deridere è un odore di codice, ma la maggior parte degli sviluppatori passa attraverso una fase delle loro abilità di TDD in cui vogliono raggiungere il 100% di copertura dei test unitari e non riescono a immaginare un mondo in cui non usano ampiamente le beffe. Al fine di spremere beffe nella loro applicazione, tendono ad avvolgere le funzioni di iniezione di dipendenza attorno alle loro unità o (peggio), imballare i servizi in contenitori di iniezione di dipendenza.

Angular porta questo estremo facendo l'iniezione di dipendenza direttamente in tutte le classi di componenti angolari, tentando gli utenti a vedere l'iniezione di dipendenza come il principale mezzo di disaccoppiamento. Ma l'iniezione di dipendenza non è il modo migliore per realizzare il disaccoppiamento.

TDD dovrebbe portare a una migliore progettazione

Il processo di apprendimento efficace TDD è il processo di apprendimento su come costruire applicazioni più modulari.

TDD tende ad avere un effetto semplificante sul codice, non un effetto complicante. Se scopri che il tuo codice diventa più difficile da leggere o mantenere quando lo rendi più testabile, o devi gonfiare il tuo codice con il boilerplate di iniezione delle dipendenze, stai sbagliando TDD.

Non perdere tempo a inserire l'iniezione di dipendenza nella tua app in modo da poter deridere il mondo intero. È molto probabile che ti faccia più male di quanto ti aiuti. Scrivere più codice testabile dovrebbe semplificare il tuo codice. Dovrebbe richiedere meno righe di codice e costruzioni più leggibili, flessibili e mantenibili. L'iniezione di dipendenza ha l'effetto opposto.

Questo testo esiste per insegnarti due cose:

  1. È possibile scrivere codice disaccoppiato senza iniezione di dipendenza e
  2. L'ottimizzazione della copertura del codice porta a rendimenti decrescenti: più ti avvicini alla copertura del 100%, più devi complicare il codice dell'applicazione per avvicinarti ancora di più, il che può sovvertire l'obiettivo importante di ridurre i bug nella tua applicazione.

Il codice più complesso è spesso accompagnato da un codice più ingombrante. Vuoi produrre codice ordinato per gli stessi motivi per cui vuoi tenere in ordine la tua casa:

  • Più disordine porta a luoghi più convenienti per nascondere i bug, che porta a più bug e
  • È più facile trovare quello che stai cercando quando c'è meno confusione in cui perdersi.

Che cos'è un odore di codice?

"Un odore di codice è un'indicazione di superficie che di solito corrisponde a un problema più profondo nel sistema." ~ Martin Fowler

Un odore di codice non significa che qualcosa sia definitivamente sbagliato o che qualcosa debba essere risolto immediatamente. È una regola empirica che dovrebbe avvisarti di una possibile opportunità per migliorare qualcosa.

Questo testo e il suo titolo non implicano in alcun modo che tutto il deridere sia cattivo o che non si dovrebbe mai deridere nulla.

Inoltre, diversi tipi di codice richiedono livelli diversi (e diversi tipi) di derisioni. Esiste un codice principalmente per facilitare l'I / O, nel qual caso, non c'è altro da fare oltre all'I / O di prova e la riduzione delle simulazioni potrebbe significare che la copertura del test dell'unità sarebbe prossima allo 0.

Se non è presente alcuna logica nel codice (solo pipe e composizioni pure), la copertura del test unitario dello 0% potrebbe essere accettabile, supponendo che la copertura del test di integrazione o funzionale sia prossima al 100%. Tuttavia, se esiste una logica (espressioni condizionali, assegnazioni a variabili, chiamate esplicite di funzioni alle unità, ecc ...), probabilmente avrai bisogno della copertura del test unitario e potrebbero esserci opportunità per semplificare il tuo codice e ridurre i requisiti di derisione.

Che cos'è un finto?

Un finto è un doppio test che rappresenta il vero codice di implementazione durante il processo di test dell'unità. Un finto è in grado di produrre affermazioni su come è stato manipolato dall'oggetto del test durante l'esecuzione del test. Se il doppio test produce asserzioni, è una derisione nel senso specifico della parola.

Il termine "finto" è anche usato più in generale per riferirsi all'uso di qualsiasi tipo di test doppio. Ai fini di questo testo, useremo le parole "finto" e "test doppio" in modo intercambiabile per abbinare l'uso popolare. Tutti i doppi di test (manichini, spie, falsi, ecc.) Rappresentano il vero codice a cui il soggetto del test è strettamente accoppiato, quindi tutti i doppi di test sono un'indicazione di accoppiamento e potrebbe esserci un'opportunità per semplificare l'implementazione e migliorare la qualità del codice in prova. Allo stesso tempo, eliminare la necessità di deridere può semplificare radicalmente i test stessi, perché non dovrai costruire le beffe.

Che cos'è un test unitario?

I test unitari verificano singole unità (moduli, funzioni, classi) isolatamente dal resto del programma.

Test di unità di contrasto con test di integrazione, che testano integrazioni tra due o più unità e test funzionali, che testano l'applicazione dal punto di vista dell'utente, inclusi flussi di lavoro di interazione utente completi dalla manipolazione simulata dell'interfaccia utente, agli aggiornamenti del livello dati e viceversa all'output dell'utente (ad es. la rappresentazione sullo schermo dell'app). I test funzionali sono un sottoinsieme di test di integrazione, in quanto testano tutte le unità di un'applicazione, integrate nel contesto dell'applicazione in esecuzione.

In generale, le unità vengono testate utilizzando solo l'interfaccia pubblica dell'unità (nota anche come "API pubblica" o "area di superficie"). Questo è indicato come test della scatola nera. Il test della scatola nera porta a test meno fragili, poiché i dettagli di implementazione di un'unità tendono a cambiare più nel tempo rispetto all'API pubblica dell'unità. Se si utilizza il test white box, in cui i test sono a conoscenza dei dettagli di implementazione, qualsiasi modifica ai dettagli di implementazione potrebbe interrompere il test, anche se l'API pubblica continua a funzionare come previsto. In altre parole, i test in white box portano a rilavorazioni sprecate.

Che cos'è la copertura del test?

La copertura del codice si riferisce alla quantità di codice coperta dai casi di test. I report di copertura possono essere creati strumentando il codice e registrando quali linee sono state esercitate durante una prova. In generale, proviamo a produrre un livello elevato di copertura, ma la copertura del codice inizia a produrre rendimenti decrescenti man mano che si avvicina al 100%.

Nella mia esperienza, aumentare la copertura oltre il ~ 90% sembra avere una correlazione poco continua con una densità di bug inferiore.

Perché dovrebbe essere? Il codice testato al 100% significa che sappiamo con certezza al 100% che il codice fa ciò per cui è stato progettato?

Si scopre, non è così semplice.

Ciò che la maggior parte delle persone non capisce è che esistono due tipi di copertura:

  1. Copertura del codice: la quantità di codice esercitata e
  2. Copertura dei casi: quanti casi d'uso sono coperti dalle suite di test

La copertura dei casi si riferisce a scenari di casi d'uso: come il codice si comporterà nel contesto dell'ambiente del mondo reale, con utenti reali, reti reali e persino hacker che cercano intenzionalmente di sovvertire il design del software per scopi nefasti.

I report di copertura identificano i punti deboli della copertura del codice, non i punti deboli della copertura del caso. Lo stesso codice può essere applicato a più di un caso d'uso e un singolo caso d'uso può dipendere da un codice al di fuori del soggetto sottoposto a test o persino in un'applicazione separata o API di terze parti.

Poiché i casi d'uso possono riguardare l'ambiente, più unità, utenti e condizioni di rete, è impossibile coprire tutti i casi d'uso richiesti con una suite di test che contenga solo test unitari. Test unitari per definizione test unit in isolamento, non in integrazione, il che significa che una suite di test contenente solo test unitari avrà sempre una copertura del caso prossima allo 0% per scenari di integrazione e casi d'uso funzionali.

La copertura del codice al 100% non garantisce la copertura del caso al 100%.

Gli sviluppatori che hanno come target una copertura del 100% del codice stanno inseguendo la metrica sbagliata.

Cos'è l'accoppiamento stretto?

La necessità di deridere al fine di ottenere l'isolamento dell'unità ai fini dei test unitari è causata dall'accoppiamento tra unità. L'accoppiamento stretto rende il codice più rigido e fragile: è più probabile che si rompa quando sono necessarie modifiche. In generale, è auspicabile meno accoppiamento per se stesso, poiché semplifica l'estensione e la manutenzione del codice. Il fatto che semplifichi anche i test eliminando la necessità di derisioni è solo la ciliegina sulla torta.

Da ciò possiamo dedurre che se prendiamo in giro qualcosa, potrebbe esserci l'opportunità di rendere il nostro codice più flessibile riducendo l'accoppiamento tra unità. Fatto ciò, non avrai più bisogno delle beffe.

L'accoppiamento è il grado in cui un'unità di codice (modulo, funzione, classe, ecc.) Dipende da altre unità di codice. L'accoppiamento stretto o un alto grado di accoppiamento si riferisce alla probabilità che un'unità si rompa quando vengono apportate modifiche alle sue dipendenze. In altre parole, più stretto è l'accoppiamento, più difficile è mantenere o estendere l'applicazione. L'accoppiamento lento riduce la complessità della correzione di bug e dell'adattamento dell'applicazione a nuovi casi d'uso.

L'accoppiamento assume forme diverse:

  • Accoppiamento sottoclasse: le sottoclassi dipendono dall'implementazione e dall'intera gerarchia della classe genitore: la forma più stretta di accoppiamento disponibile nella progettazione OO.
  • Controllo delle dipendenze: codice che controlla le sue dipendenze dicendo loro cosa fare, ad es. Passando i nomi dei metodi, ecc ... Se l'API di controllo della dipendenza cambia, il codice dipendente si interromperà.
  • Dipendenze dello stato mutabili: il codice che condivide lo stato mutabile con altro codice, ad esempio, può modificare le proprietà di un oggetto condiviso. Se i tempi relativi delle mutazioni cambiano, potrebbe rompere il codice dipendente. Se il tempismo non è deterministico, potrebbe essere impossibile ottenere la correttezza del programma senza una revisione completa di tutte le unità dipendenti: ad esempio, potrebbe esserci un groviglio irreparabile di condizioni di gara. Risolvere un bug potrebbe far apparire gli altri in altre unità dipendenti.
  • Dipendenze della forma dello stato: codice che condivide le strutture dati con altro codice e utilizza solo un sottoinsieme della struttura. Se la forma della struttura condivisa cambia, potrebbe interrompere il codice dipendente.
  • Accoppiamento evento / messaggio: codice che comunica con altre unità tramite trasmissione di messaggi, eventi, ecc ...

Quali sono le cause dell'accoppiamento stretto?

L'accoppiamento stretto ha molte cause:

  • Mutazione vs immutabilità
  • Effetti collaterali vs purezza / effetti collaterali isolati
  • Sovraccarico di responsabilità vs Do One Thing (DOT)
  • Istruzioni procedurali vs descrizione della struttura
  • Ereditarietà delle classi vs composizione

Il codice imperativo e orientato agli oggetti è più suscettibile all'accoppiamento stretto rispetto al codice funzionale. Ciò non significa che la programmazione in uno stile funzionale renda il tuo codice immune all'accoppiamento stretto, ma il codice funzionale utilizza funzioni pure come unità elementale di composizione e le funzioni pure sono meno vulnerabili all'accoppiamento stretto per natura.

Funzioni pure:

  • Dato lo stesso input, restituisce sempre lo stesso output e
  • Non produce effetti collaterali

In che modo le funzioni pure riducono l'accoppiamento?

  • Immutabilità: le funzioni pure non mutano i valori esistenti. Ne restituiscono di nuovi, invece.
  • Nessun effetto collaterale: l'unico effetto osservabile di una funzione pura è il suo valore di ritorno, quindi non c'è alcuna possibilità che interferisca con il funzionamento di altre funzioni che potrebbero osservare uno stato esterno come lo schermo, il DOM, la console, lo standard out , la rete o il disco.
  • Fai una cosa: le funzioni pure fanno una cosa: associa un input a un output corrispondente, evitando il sovraccarico di responsabilità che tende a tormentare l'oggetto e il codice di classe.
  • Struttura, non istruzioni: le funzioni pure possono essere memorizzate in modo sicuro, nel senso che, se il sistema avesse una memoria infinita, qualsiasi funzione pura potrebbe essere sostituita con una tabella di ricerca che utilizza l'input della funzione come indice per recuperare un valore corrispondente dalla tabella. In altre parole, le funzioni pure descrivono le relazioni strutturali tra i dati, non le istruzioni che il computer deve seguire, quindi due diversi insiemi di istruzioni contrastanti in esecuzione contemporaneamente non possono calpestarsi l'un l'altro e causare problemi.

Cosa c'entra la composizione con il deridere?

Qualunque cosa. L'essenza di tutto lo sviluppo del software è il processo di scomporre un grosso problema in pezzi più piccoli e indipendenti (decomposizione) e comporre le soluzioni insieme per formare un'applicazione che risolva il grande problema (composizione).

Il derisione è necessario quando la nostra strategia di decomposizione non è riuscita.

La derisione è necessaria quando le unità utilizzate per suddividere il problema di grandi dimensioni in parti più piccole dipendono l'una dall'altra. In altre parole, è necessario prendere in giro quando le nostre presunte unità atomiche di composizione non sono realmente atomiche e la nostra strategia di decomposizione non è riuscita a scomporre il problema più grande in problemi più piccoli e indipendenti.

Quando la decomposizione ha esito positivo, è possibile utilizzare un'utilità di composizione generica per ricomporre i pezzi. Esempi:

  • Composizione della funzione, ad esempio lodash / fp / compose
  • Composizione dei componenti, ad es. Composizione di componenti di ordine superiore con composizione delle funzioni
  • Archivio di stato / composizione del modello, ad es. Redux, combinareRiduttori
  • Composizione dell'oggetto o della fabbrica, ad esempio mixin o mixin funzionali
  • Composizione del processo, ad esempio trasduttori
  • Composizione promessa o monadica, ad es. AsyncPipe (), composizione Kleisli con composeM (), composeK (), ecc ...
  • eccetera…

Quando si utilizzano utilità di composizione generiche, ogni elemento della composizione può essere testato in unità isolatamente senza deridere gli altri.

Le composizioni stesse saranno dichiarative, quindi conterranno una logica zero unit-testable (presumibilmente l'utilità di composizione è una libreria di terze parti con i propri test unitari).

In tali circostanze, non c'è nulla di significativo nel test unitario. È necessario invece test di integrazione.

Facciamo un contrasto tra composizione imperativa e dichiarativa usando un esempio familiare:

// Composizione delle funzioni OR
// importare pipe da 'lodash / fp / flow';
const pipe = (... fns) => x => fns.reduce ((y, f) => f (y), x);
// Funzioni da comporre
const g = n => n + 1;
const f = n => n * 2;
// Composizione imperativa
const doStuffBadly = x => {
  const afterG = g (x);
  const afterF = f (afterG);
  ritorno dopo F;
};
// Composizione dichiarativa
const doStuffBetter = pipe (g, f);
console.log (
  doStuffBadly (20), // 42
  doStuffBetter (20) // 42
);

La composizione della funzione è il processo di applicazione di una funzione al valore restituito di un'altra funzione. In altre parole, si crea una pipeline di funzioni, quindi si passa un valore alla pipeline e il valore passerà attraverso ciascuna funzione come una fase in una linea di assemblaggio, trasformando il valore in qualche modo prima che venga passato alla funzione successiva nella pipeline. Alla fine, l'ultima funzione nella pipeline restituisce il valore finale.

valore iniziale -> [g] -> [f] -> risultato

È il mezzo principale per organizzare il codice dell'applicazione in ogni linguaggio tradizionale, indipendentemente dal paradigma. Persino Java utilizza funzioni (metodi) come meccanismo di passaggio del messaggio principale tra diverse istanze di classe.

È possibile comporre le funzioni manualmente (in modo imperativo) o automaticamente (in modo dichiarativo). Nelle lingue senza funzioni di prima classe, non hai molta scelta. Sei bloccato con l'imperativo. In JavaScript (e quasi tutte le altre principali lingue popolari), puoi farlo meglio con la composizione dichiarativa.

Lo stile imperativo significa che stiamo comandando al computer di fare qualcosa passo dopo passo. È una guida pratica. Nell'esempio sopra, lo stile imperativo dice:

  1. Prendi un argomento e assegnalo a x
  2. Crea un'associazione chiamata afterG e assegna ad essa il risultato di g (x)
  3. Crea un'associazione chiamata afterF e assegna ad essa il risultato di f (afterG)
  4. Restituisce il valore di afterF.

La versione in stile imperativo richiede una logica che dovrebbe essere testata. So che questi sono solo dei semplici compiti, ma ho spesso visto (e scritto) bug in cui passo o restituisco la variabile sbagliata.

Lo stile dichiarativo significa che stiamo raccontando al computer le relazioni tra le cose. È una descrizione della struttura che utilizza il ragionamento equazionale. L'esempio dichiarativo dice:

  • doStuffBetter è la composizione convogliata di gef.

Questo è tutto.

Supponendo che feg abbiano i propri test unitari e pipe () abbia i propri test unitari (usare flow () da Lodash o pipe () da Ramda, e lo farà), non c'è nuova logica qui per test unitari.

Affinché questo stile funzioni correttamente, le unità che componiamo devono essere disaccoppiate.

Come rimuoviamo l'accoppiamento?

Per rimuovere l'accoppiamento, dobbiamo prima comprendere meglio da dove provengono le dipendenze dell'accoppiamento. Ecco le fonti principali, all'incirca in ordine di quanto è stretto il giunto:

Accoppiamento stretto:

  • Eredità di classe (l'accoppiamento viene moltiplicato per ogni strato di eredità e ciascuna classe discendente)
  • Variabili globali
  • Altro stato globale mutabile (DOM del browser, memoria condivisa, rete, ecc ...)
  • Importazioni di moduli con effetti collaterali
  • Dipendenze implicite da composizioni, ad esempio const constWidgetWidgetFactory = compose (eventEmitter, widgetFactory, miglioramenti); dove widgetFactory dipende da eventEmitter
  • Contenitori per iniezione di dipendenza
  • Parametri di iniezione di dipendenza
  • Parametri di controllo (un'unità esterna sta controllando l'unità in questione dicendole cosa fare)
  • Parametri mutabili

Accoppiamento lasco:

  • Importazioni di moduli senza effetti collaterali (nei test della scatola nera, non tutte le importazioni devono essere isolate)
  • Passaggio messaggi / pubsub
  • Parametri immutabili (possono comunque causare dipendenze condivise sulla forma dello stato)

Ironia della sorte, la maggior parte delle fonti di accoppiamento sono meccanismi originariamente progettati per ridurre l'accoppiamento. Ciò ha senso, perché per ricomporre le nostre soluzioni a problemi minori in un'applicazione completa, devono in qualche modo integrarsi e comunicare. Ci sono modi buoni e modi cattivi. Le fonti che causano un accoppiamento stretto dovrebbero essere evitate ogni volta che è pratico farlo. Le opzioni di accoppiamento libero sono generalmente desiderabili in un'applicazione sana.

Potresti essere confuso dal fatto che ho classificato i contenitori di iniezione di dipendenza e i parametri di iniezione di dipendenza nel gruppo "accoppiamento stretto", quando così tanti libri e post di blog li classificano come "accoppiamento libero". L'accoppiamento non è binario. È una scala sfumata. Ciò significa che qualsiasi raggruppamento sarà in qualche modo soggettivo e arbitrario.

Traccio la linea con un semplice tornasole oggettivo:

L'unità può essere testata senza deridere dipendenze? In caso contrario, è strettamente accoppiato alle dipendenze derise.

Maggiore è la dipendenza dell'unità, maggiore è la probabilità che si verifichino problemi di accoppiamento. Ora che comprendiamo come avviene l'accoppiamento, cosa possiamo fare al riguardo?

  1. Usa le funzioni pure come unità atomica di composizione, al contrario di classi, procedure imperative o funzioni mutanti.
  2. Isolare gli effetti collaterali dal resto della logica del programma. Ciò significa che non mescolare la logica con l'I / O (inclusi I / O di rete, rendering dell'interfaccia utente, registrazione, ecc ...).
  3. Rimuovi la logica dipendente dalle composizioni imperative in modo che possano diventare composizioni dichiarative che non necessitano dei propri test unitari. Se non c'è logica, non c'è nulla di significativo per il test unitario.

Ciò significa che il codice utilizzato per impostare le richieste di rete e i gestori delle richieste non avrà bisogno di unit test. Utilizzare invece test di integrazione per quelli.

Ciò porta a ripetere:

Non effettuare l'I / O di unit test.
L'I / O è per integrazioni. Utilizzare invece i test di integrazione.

Va benissimo deridere e falsificare per i test di integrazione.

Usa le funzioni pure

L'uso delle funzioni pure richiede un po 'di pratica e, senza quella pratica, non è sempre chiaro come scrivere una funzione pura per fare quello che vuoi fare. Le funzioni pure non possono mutare direttamente le variabili globali, gli argomenti passati in esse, la rete, il disco o lo schermo. Tutto ciò che possono fare è restituire un valore.

Se viene passato un array o un oggetto e si desidera restituire una versione modificata di tale oggetto, non è possibile apportare le modifiche all'oggetto e restituirlo. Devi creare una nuova copia dell'oggetto con le modifiche richieste. Puoi farlo con i metodi di accesso all'array (non con i metodi mutatore), Object.assign (), usando un nuovo oggetto vuoto come destinazione o la sintassi di diffusione dell'array o dell'oggetto. Per esempio:

// Non puro
const signInUser = user => user.isSignedIn = true;
const foo = {
  nome: "Foo",
  isSignedIn: false
};
// Foo è stato mutato
console.log (
  signInUser (foo), // true
  foo // {nome: "Foo", isSignedIn: true}
);

vs ...

// Puro
const signInUser = user => ({... user, isSignedIn: true});
const foo = {
  nome: "Foo",
  isSignedIn: false
};
// Foo non è stato mutato
console.log (
  signInUser (foo), // {name: "Foo", isSignedIn: true}
  foo // {nome: "Foo", isSignedIn: false}
);

In alternativa, puoi provare una libreria per tipi di dati immutabili, come Mori o Immutable.js. Spero che un giorno avremo un bel set di tipi di dati immutabili simili a quelli di Clojure in JavaScript, ma non sto trattenendo il respiro.

Potresti pensare che la restituzione di nuovi oggetti potrebbe causare un calo delle prestazioni perché stiamo creando un nuovo oggetto invece di riutilizzare quelli esistenti, ma un effetto collaterale fortunato è che possiamo rilevare le modifiche agli oggetti utilizzando un confronto di identità (= == check), quindi non dobbiamo attraversare l'intero oggetto per scoprire se qualcosa è cambiato.

Puoi usare questo trucco per rendere più veloce il rendering dei componenti di React se hai un albero di stato complesso che potrebbe non essere necessario attraversare in profondità con ogni passaggio di rendering. Eredita da PureComponent e implementa shouldComponentUpdate () con un confronto superficiale di prop e stato. Quando rileva l'uguaglianza delle identità, sa che nulla è cambiato in quella parte dell'albero statale e può andare avanti senza un profondo attraversamento dello stato.

Le funzioni pure possono anche essere memorizzate, il che significa che non è necessario ricostruire nuovamente l'intero oggetto se si sono già visti gli stessi input prima. È possibile scambiare la complessità di calcolo con la memoria e archiviare i valori precalcolati in una tabella di ricerca. Per i processi computazionalmente costosi che non richiedono memoria illimitata, questa potrebbe essere un'ottima strategia di ottimizzazione.

Un'altra proprietà delle funzioni pure è che, poiché non hanno effetti collaterali, è sicuro distribuire calcoli complessi su grandi gruppi di processori, usando una strategia di divisione e conquista. Questa tattica viene spesso utilizzata per elaborare immagini, video o frame audio utilizzando GPU massicciamente parallele originariamente progettate per la grafica, ma ora comunemente utilizzate per molti altri scopi, come il calcolo scientifico.

In altre parole, la mutazione non è sempre più veloce ed è spesso più lenta in quanto gli ordini di grandezza perché richiedono una micro-ottimizzazione a scapito delle macro-ottimizzazioni.

Isolare gli effetti collaterali dal resto della logica del programma

Esistono diverse strategie che possono aiutarti a isolare gli effetti collaterali dal resto della logica del tuo programma. Qui ce ne sono un po:

  1. Utilizzare pub / sub per disaccoppiare l'I / O dalle viste e dalla logica del programma. Anziché attivare direttamente gli effetti collaterali nelle viste dell'interfaccia utente o nella logica del programma, emetti un evento o un oggetto azione che descriva un evento o un intento.
  2. Isolare la logica dall'I / O, ad esempio, comporre funzioni che restituiscono promesse utilizzando asyncPipe ().
  3. Utilizzare oggetti che rappresentano calcoli futuri anziché attivare direttamente il calcolo con I / O, ad esempio call () da redux-saga in realtà non chiama una funzione. Al contrario, restituisce un oggetto con un riferimento a una funzione e ai suoi argomenti e il middleware saga lo chiama per te. Ciò rende call () e tutte le funzioni che lo usano funzioni pure, che sono facili da testare l'unità senza necessità di derisione.

Usa pub / sub

Pub / sub è l'abbreviazione del modello di pubblicazione / iscrizione. Nel modello di pubblicazione / sottoscrizione, le unità non si chiamano direttamente tra loro. Al contrario, pubblicano messaggi che altre unità (abbonati) possono ascoltare. Gli editori non sanno quali (se presenti) unità si iscriveranno e gli abbonati non sanno quali (se presenti) editori pubblicheranno.

Pub / sub viene inserito nel Document Object Model (DOM). Qualsiasi componente dell'applicazione può ascoltare eventi inviati da elementi DOM, quali movimenti del mouse, clic, eventi di scorrimento, sequenze di tasti e così via. All'epoca in cui tutti creavano app Web con jQuery, era comune agli eventi personalizzati jQuery trasformare il DOM in un bus pub / sub-evento per separare le preoccupazioni di rendering della vista dalla logica di stato.

Pub / sub è anche cotta in Redux. In Redux, si crea un modello globale per lo stato dell'applicazione (chiamato store). Invece di manipolare direttamente modelli, viste e gestori I / O inviano oggetti azione al negozio. Un oggetto azione ha una chiave speciale, chiamata tipo a cui i vari riduttori possono ascoltare e rispondere. Inoltre, Redux supporta il middleware, che può anche ascoltare e rispondere a tipi di azioni specifici. In questo modo, le tue viste non hanno bisogno di sapere nulla su come viene gestito lo stato della tua applicazione e la logica dello stato non ha bisogno di sapere nulla delle viste.

Rende inoltre banale eseguire la patch nel dispatcher tramite middleware e innescare preoccupazioni trasversali, come la registrazione / analisi delle azioni, la sincronizzazione dello stato con l'archiviazione o il server e l'applicazione di patch nelle funzionalità di comunicazione in tempo reale con server e peer di rete.

Isolare la logica dall'I / O

A volte puoi usare composizioni monade (come le promesse) per eliminare la logica dipendente dalle tue composizioni. Ad esempio, la seguente funzione contiene una logica secondo cui non è possibile testare l'unità senza deridere tutte le funzioni asincrone:

Funzione asincrona uploadFiles ({utente, cartella, file}) {
  const dbUser = await readUser (utente);
  const folderInfo = await getFolderInfo (cartella);
  if (wait have haveWriteAccess ({dbUser, folderInfo})) {
    return uploadToFolder ({dbUser, folderInfo, files});
  } altro {
    genera un nuovo errore ("Nessun accesso in scrittura a quella cartella");
  }
}

Aggiungiamo alcuni pseudo-codice helper per renderlo eseguibile:

const log = (... args) => console.log (... args);
// Ignora questi. Nel tuo vero codice importeresti
// le cose vere.
const readUser = () => Promise.resolve (true);
const getFolderInfo = () => Promise.resolve (true);
const haveWriteAccess = () => Promise.resolve (true);
const uploadToFolder = () => Promise.resolve ('Success!');
// variabili iniziali incomprensibili
const user = '123';
const cartella = '456';
const files = ['a', 'b', 'c'];
Funzione asincrona uploadFiles ({utente, cartella, file}) {
  const dbUser = await readUser ({utente});
  const folderInfo = await getFolderInfo ({cartella});
  if (wait have haveWriteAccess ({dbUser, folderInfo})) {
    return uploadToFolder ({dbUser, folderInfo, files});
  } altro {
    genera un nuovo errore ("Nessun accesso in scrittura a quella cartella");
  }
}
uploadFiles ({utente, cartella, file})
  .poi (log)
;

E ora riformattalo per usare la composizione promessa tramite asyncPipe ():

const asyncPipe = (... fns) => x => (
  fns.reduce (async (y, f) => f (wait y), x)
);
const uploadFiles = asyncPipe (
  readUser,
  getFolderInfo,
  haveWriteAccess,
  uploadToFolder
);
uploadFiles ({utente, cartella, file})
  .poi (log)
;

La logica condizionale viene facilmente rimossa perché le promesse hanno una ramificazione condizionale integrata. L'idea è che la logica e l'I / O non si mescolino bene, quindi vogliamo rimuovere la logica dal codice dipendente dall'I / O.

Per far funzionare questo tipo di composizione, dobbiamo garantire 2 cose:

  1. haveWriteAccess () rifiuterà se l'utente non ha accesso in scrittura. Ciò sposta la logica condizionale nel contesto della promessa, quindi non dobbiamo testarla unitamente o preoccuparsene affatto (le promesse hanno i loro test inseriti nel codice del motore JS).
  2. Ognuna di queste funzioni accetta e si risolve con lo stesso tipo di dati. Potremmo creare un tipo di pipelineData per questa composizione che è solo un oggetto contenente le seguenti chiavi: {utente, cartella, file, dbUser ?, folderInfo? }. Ciò crea una struttura che condivide la dipendenza tra i componenti, ma è possibile utilizzare versioni più generiche di queste funzioni in altri luoghi e specializzarle per questa pipeline con funzioni di wrapping sottili.

Con tali condizioni soddisfatte, è banale testare ciascuna di queste funzioni isolandole l'una dall'altra senza deridere le altre funzioni. Dal momento che abbiamo estratto tutta la logica dalla pipeline, non c'è nulla di significativo da testare in questo file. Tutto ciò che resta da testare sono le integrazioni.

Ricorda: la logica e l'I / O sono problemi separati.
La logica sta pensando. Gli effetti sono azioni. Pensa prima di agire!

Usa oggetti che rappresentano calcoli futuri

La strategia utilizzata da redux-saga è quella di utilizzare oggetti che rappresentano calcoli futuri. L'idea è simile al ritorno di una monade, tranne per il fatto che non deve sempre essere una monade che viene restituita. Le monadi sono in grado di comporre le funzioni con l'operazione a catena, ma è possibile invece collegare manualmente le funzioni utilizzando il codice in stile imperativo. Ecco un abbozzo di come lo fa la Redux-saga:

// sugar per console.log useremo più avanti
const log = msg => console.log (msg);
const call = (fn, ... args) => ({fn, args});
const put = (msg) => ({msg});
// importato dall'API I / O
const sendMessage = msg => Promise.resolve ('some response');
// importato dal gestore / riduttore di stato
const handleResponse = response => ({
  tipo: "RECEIVED_RESPONSE",
  payload: risposta
});
const handleError = err => ({
  tipo: 'IO_ERROR',
  payload: err
});
funzione * sendMessageSaga (msg) {
  provare {
    const response = yield call (sendMessage, msg);
    resa put (handleResponse (response));
  } catch (err) {
    yield put (handleError (err));
  }
}

Puoi vedere tutte le chiamate effettuate nei test delle unità senza deridere l'API di rete o invocare effetti collaterali. Bonus: questo rende estremamente facile il debug della tua applicazione senza preoccuparti dello stato di rete non deterministico, ecc ...

Vuoi simulare cosa succede nella tua app quando si verifica un errore di rete? Basta chiamare iter.throw (NetworkError)

Altrove, alcuni middleware di libreria guidano la funzione e attivano effettivamente gli effetti collaterali nell'applicazione di produzione:

const iter = sendMessageSaga ('Hello, world!');
// Restituisce un oggetto che rappresenta lo stato e il valore:
const step1 = iter.next ();
log (step1);
/ * =>
{
  fatto: falso,
  valore: {
    fn: sendMessage
    args: ["Ciao, mondo!"]
  }
}
* /

Distruggere l'oggetto call () dal valore ceduto per ispezionare o invocare il calcolo futuro:

const {value: {fn, args}} = step1;

Gli effetti vengono eseguiti nel middleware reale. Puoi saltare questa parte durante i test e il debug.

const step2 = fn (args);
step2.then (log); // "qualche risposta"

Se si desidera simulare una risposta di rete senza deridere API o chiamate http, è possibile passare una risposta simulata in .next ():

iter.next (simulatedNetworkResponse);

Da lì puoi continuare a chiamare .next () fino a quando true è true e la tua funzione è terminata.

Utilizzando generatori e rappresentazioni di calcoli nei test unitari, è possibile simulare qualsiasi cosa, escluso il fatto di invocare gli effetti collaterali reali. Puoi trasferire valori in chiamate .next () a risposte false o lanciare errori nell'iteratore per falsi errori e promettere rifiuti.

Utilizzando questo stile, non è necessario prendere in giro nulla nei test unitari, anche per complessi flussi di lavoro integrativi con molti effetti collaterali.

Gli "odori di codice" sono segnali di pericolo, non leggi. Le beffe non sono cattive.

Tutta questa roba sull'uso di un'architettura migliore è fantastica, ma nel mondo reale, dobbiamo usare le API di altre persone e integrarci con il codice legacy, e ci sono molte API che non sono pure. In questi casi possono essere utili doppi test isolati. Ad esempio, express passa lo stato mutabile condiviso e modella gli effetti collaterali tramite il passaggio di continuazione.

Vediamo un esempio comune. Le persone cercano di dirmi che il file di definizione del server express necessita dell'iniezione di dipendenza perché in quale altro modo testerai tutte le cose che vanno nell'app express? Per esempio.:

const express = require ('express');
const app = express ();
app.get ('/', function (req, res) {
  res.send ('Hello World!')
});
app.listen (3000, function () {
  console.log ('Esempio di ascolto dell'app sulla porta 3000!')
});

Per "testare l'unità" questo file, dovremmo elaborare una soluzione per l'iniezione delle dipendenze e quindi passare i mock per tutto (eventualmente includendo express () stesso). Se questo fosse un file molto complesso in cui diversi gestori di richieste utilizzavano diverse funzionalità di express e contando su quella logica per essere lì, probabilmente dovresti trovare un falso piuttosto sofisticato per farlo funzionare. Ho visto gli sviluppatori creare elaborati falsi e simulazioni di cose come express, il middleware di sessione, i gestori di log, i protocolli di rete in tempo reale, lo chiami tu. Ho affrontato dure domande beffardo, ma la risposta corretta è semplice.

Questo file non richiede test unitari.

Il file di definizione del server per un'app express è per definizione il principale punto di integrazione dell'app. Il test di un file di app express sta per definizione testando un'integrazione tra la logica del programma, express e tutti i gestori per quell'app express. Non si dovrebbe assolutamente saltare i test di integrazione anche se è possibile ottenere una copertura del test unitario del 100%.

Invece di provare a testare l'unità di questo file, isolare la logica del programma in unità separate e testare tali file. Scrivi test di integrazione reali per il file del server, nel senso che raggiungerai effettivamente la rete, o almeno creerai i veri messaggi http, completi di intestazioni utilizzando uno strumento come supertest.

Riflettiamo l'esempio di Hello World Express per renderlo più testabile:

Tirare il gestore ciao nel proprio file e scrivere test unitari per esso. Non è necessario deridere il resto dei componenti dell'app. Questa ovviamente non è una funzione pura, quindi dovremo spiare o deridere l'oggetto response per assicurarci di chiamare .send ().

const hello = (req, res) => res.send ('Hello World!');

Potresti provare qualcosa del genere. Scambia l'istruzione if per le tue aspettative del framework di test preferito:

{
  const previsti = 'Hello World!';
  const msg = `dovrebbe chiamare .send () con $ {previsti}`;
  const res = {
    invia: (effettivo) => {
      if (effettivo! == previsto) {
        genera un nuovo errore (`NOT OK $ {msg}`);
      }
      console.log (`OK: $ {msg}`);
    }
  }
  ciao ({}, res);
}

Tirare il gestore di ascolto nel proprio file e scrivere anche test unitari. Abbiamo lo stesso problema qui. I gestori Express non sono puri, quindi dobbiamo spiare il logger per assicurarci che venga chiamato. Il test è simile all'esempio precedente:

const handleListen = (log, port) => () => log (`Esempio di app in ascolto sulla porta $ {port}!`);

Tutto ciò che rimane nel file del server ora è la logica di integrazione:

const express = require ('express');
const hello = require ('./ hello.js');
const handleListen = require ('./ handleListen');
const log = require ('./ log');
const port = 3000;
const app = express ();
app.get ('/', ciao);
app.listen (port, handleListen (port, log));

Hai ancora bisogno di test di integrazione per questo file, ma ulteriori test unitari non miglioreranno significativamente la copertura del tuo caso. Usiamo un'iniezione di dipendenza molto minima per passare un logger in handleListen (), ma di certo non è necessario alcun framework di iniezione di dipendenza per le app express.

Il derisione è ottimo per i test di integrazione

Poiché i test di integrazione testano le integrazioni collaborative tra le unità, è perfettamente OK falsificare server, protocolli di rete, messaggi di rete e così via per riprodurre tutte le varie condizioni che incontrerai durante la comunicazione con altre unità, potenzialmente distribuite tra cluster di CPU o macchine separate su una rete.

A volte vorrai testare il modo in cui la tua unità comunicherà con un'API di terze parti, a volte tali API sono proibitivamente costose da testare davvero. È possibile registrare le transazioni del flusso di lavoro reale rispetto ai servizi reali e riprodurle da un server falso per verificare se l'unità si integra con un servizio di terze parti effettivamente in esecuzione in un processo di rete separato. Spesso questo è il modo migliore per testare cose come "abbiamo visto le intestazioni dei messaggi corrette?"

Esistono molti utili strumenti di test di integrazione che limitano la larghezza di banda della rete, introducono ritardi nella rete, producono errori di rete e testano molte altre condizioni impossibili da testare utilizzando test unitari che deridono il livello di comunicazione.

È impossibile ottenere una copertura del 100% dei casi senza test di integrazione. Non saltarli anche se riesci a ottenere una copertura unitaria del 100%. A volte il 100% non è 100%.

Prossimi passi

  • Scopri perché penso che ogni team di sviluppo dovrebbe utilizzare TDD sul podcast di Cross Cutting Concerns.
  • JS Cheerleader sta documentando le nostre avventure su Instagram.

Maggiori informazioni su EricElliottJS.com

Le lezioni video sui test unitari sono disponibili per i membri di EricElliottJS.com. Se non sei un membro, iscriviti oggi.

Eric Elliott è l'autore di "Programmazione di applicazioni JavaScript" (O’Reilly) e "Impara JavaScript con Eric Elliott". Ha contribuito alle esperienze software per Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC e i migliori artisti della registrazione tra cui Usher, Frank Ocean, Metallica e molti altri.

Lavora da remoto ovunque con la donna più bella del mondo.