Scrivere codice verificabile in JavaScript: una breve panoramica
Pubblicato: 2022-03-11Sia che utilizziamo Node abbinato a un framework di test come Mocha o Jasmine, sia che stiamo avviando test dipendenti dal DOM in un browser headless come PhantomJS, le nostre opzioni per il test unitario di JavaScript sono migliori che mai.
Tuttavia, questo non significa che il codice che stiamo testando sia facile per noi come lo sono i nostri strumenti! Organizzare e scrivere codice facilmente testabile richiede un certo sforzo e pianificazione, ma ci sono alcuni schemi, ispirati da concetti di programmazione funzionale, che possiamo usare per evitare di finire in una situazione difficile quando arriva il momento di testare il nostro codice. In questo articolo, analizzeremo alcuni suggerimenti e modelli utili per scrivere codice verificabile in JavaScript.
Mantieni la logica aziendale e la logica di visualizzazione separate
Uno dei lavori principali di un'applicazione browser basata su JavaScript è ascoltare gli eventi DOM attivati dall'utente finale e quindi rispondere eseguendo alcune logiche di business e visualizzando i risultati sulla pagina. Si è tentati di scrivere una funzione anonima che svolga la maggior parte del lavoro proprio dove stai configurando i listener di eventi DOM. Il problema che questo crea è che ora devi simulare eventi DOM per testare la tua funzione anonima. Ciò può creare un sovraccarico sia nelle righe di codice che nel tempo necessario per l'esecuzione dei test.
Invece, scrivi una funzione denominata e passala al gestore eventi. In questo modo puoi scrivere test per funzioni con nome direttamente e senza saltare tra i cerchi per attivare un falso evento DOM.
Questo vale per più del DOM però. Molte API, sia nel browser che in Node, sono progettate attorno all'attivazione e all'ascolto di eventi o all'attesa del completamento di altri tipi di lavoro asincrono. Una regola pratica è che se stai scrivendo molte funzioni di callback anonime, il tuo codice potrebbe non essere facile da testare.
// hard to test $('button').on('click', () => { $.getJSON('/path/to/data') .then(data => { $('#my-list').html('results: ' + data.join(', ')); }); }); // testable; we can directly run fetchThings to see if it // makes an AJAX request without having to trigger DOM // events, and we can run showThings directly to see that it // displays data in the DOM without doing an AJAX request $('button').on('click', () => fetchThings(showThings)); function fetchThings(callback) { $.getJSON('/path/to/data').then(callback); } function showThings(data) { $('#my-list').html('results: ' + data.join(', ')); }
Usa callback o promesse con codice asincrono
Nell'esempio di codice precedente, la nostra funzione fetchThings refactored esegue una richiesta AJAX, che esegue la maggior parte del suo lavoro in modo asincrono. Ciò significa che non possiamo eseguire la funzione e verificare che abbia eseguito tutto ciò che ci aspettavamo, perché non sapremo quando avrà terminato l'esecuzione.
Il modo più comune per risolvere questo problema consiste nel passare una funzione di callback come parametro alla funzione che viene eseguita in modo asincrono. Nei tuoi unit test puoi eseguire le tue asserzioni nel callback che superi.
Un altro modo comune e sempre più diffuso per organizzare il codice asincrono è con l'API Promise. Fortunatamente, $.ajax e la maggior parte delle altre funzioni asincrone di jQuery restituiscono già un oggetto Promise, quindi molti casi d'uso comuni sono già coperti.
// hard to test; we don't know how long the AJAX request will run function fetchData() { $.ajax({ url: '/path/to/data' }); } // testable; we can pass a callback and run assertions inside it function fetchDataWithCallback(callback) { $.ajax({ url: '/path/to/data', success: callback, }); } // also testable; we can run assertions when the returned Promise resolves function fetchDataWithPromise() { return $.ajax({ url: '/path/to/data' }); }
Evita gli effetti collaterali
Scrivi funzioni che accettano argomenti e restituiscono un valore basato esclusivamente su tali argomenti, proprio come inserire numeri in un'equazione matematica per ottenere un risultato. Se la tua funzione dipende da uno stato esterno (le proprietà di un'istanza di classe o il contenuto di un file, per esempio) e devi impostare quello stato prima di testare la tua funzione, devi fare più impostazioni nei tuoi test. Dovrai fidarti che qualsiasi altro codice in esecuzione non sta alterando lo stesso stato.
Allo stesso modo, evita di scrivere funzioni che alterano lo stato esterno (come scrivere su un file o salvare valori su un database) durante l'esecuzione. Ciò previene gli effetti collaterali che potrebbero influire sulla tua capacità di testare altri codici con sicurezza. In generale, è meglio mantenere gli effetti collaterali il più vicino possibile ai bordi del codice, con la minor "area di superficie" possibile. In caso di classi e istanze di oggetti, gli effetti collaterali di un metodo di classe dovrebbero essere limitati allo stato dell'istanza di classe da testare.
// hard to test; we have to set up a globalListOfCars object and set up a // DOM with a #list-of-models node to test this code function processCarData() { const models = globalListOfCars.map(car => car.model); $('#list-of-models').html(models.join(', ')); } // easy to test; we can pass an argument and test its return value, without // setting any global values on the window or checking the DOM the result function buildModelsString(cars) { const models = cars.map(car => car.model); return models.join(','); }
Usa l'iniezione di dipendenza
Un modello comune per ridurre l'uso di uno stato esterno da parte di una funzione è l'iniezione di dipendenza, ovvero il passaggio di tutte le esigenze esterne di una funzione come parametri di funzione.

// depends on an external state database connector instance; hard to test function updateRow(rowId, data) { myGlobalDatabaseConnector.update(rowId, data); } // takes a database connector instance in as an argument; easy to test! function updateRow(rowId, data, databaseConnector) { databaseConnector.update(rowId, data); }
Uno dei principali vantaggi dell'utilizzo dell'iniezione di dipendenza è che puoi passare oggetti fittizi dai tuoi unit test che non causano effetti collaterali reali (in questo caso, l'aggiornamento delle righe del database) e puoi semplicemente affermare che il tuo oggetto fittizio è stato agito su nel modo previsto.
Assegna a ciascuna funzione un unico scopo
Suddividi le funzioni lunghe che fanno diverse cose in una raccolta di funzioni brevi e con un unico scopo. Ciò rende molto più semplice verificare che ogni funzione svolga correttamente la sua parte, piuttosto che sperare che una funzione di grandi dimensioni stia facendo tutto correttamente prima di restituire un valore.
Nella programmazione funzionale, l'atto di mettere insieme più funzioni a scopo singolo è chiamato composizione. Underscore.js ha anche una funzione _.compose
, che prende un elenco di funzioni e le concatena insieme, prendendo il valore di ritorno di ogni passaggio e passandolo alla funzione successiva in linea.
// hard to test function createGreeting(name, location, age) { let greeting; if (location === 'Mexico') { greeting = '!Hola'; } else { greeting = 'Hello'; } greeting += ' ' + name.toUpperCase() + '! '; greeting += 'You are ' + age + ' years old.'; return greeting; } // easy to test function getBeginning(location) { if (location === 'Mexico') { return 'Hola'; } else { return 'Hello'; } } function getMiddle(name) { return ' ' + name.toUpperCase() + '! '; } function getEnd(age) { return 'You are ' + age + ' years old.'; } function createGreeting(name, location, age) { return getBeginning(location) + getMiddle(name) + getEnd(age); }
Non modificare i parametri
In JavaScript, gli array e gli oggetti vengono passati per riferimento anziché per valore e sono modificabili. Ciò significa che quando si passa un oggetto o un array come parametro in una funzione, sia il codice che la funzione in cui si è passato l'oggetto o l'array hanno la possibilità di modificare la stessa istanza di tale array o oggetto in memoria. Ciò significa che se stai testando il tuo codice, devi fidarti che nessuna delle funzioni che il tuo codice chiama sta alterando i tuoi oggetti. Ogni volta che aggiungi una nuova posizione nel tuo codice che altera lo stesso oggetto, diventa sempre più difficile tenere traccia di come dovrebbe apparire quell'oggetto, rendendo più difficile il test.
Invece, se hai una funzione che accetta un oggetto o una matrice, falla agire su quell'oggetto o matrice come se fosse di sola lettura. Crea un nuovo oggetto o array nel codice e aggiungi valori in base alle tue esigenze. Oppure, usa Underscore o Lodash per clonare l'oggetto o l'array passato prima di operare su di esso. Ancora meglio, usa uno strumento come Immutable.js che crea strutture di dati di sola lettura.
// alters objects passed to it function upperCaseLocation(customerInfo) { customerInfo.location = customerInfo.location.toUpperCase(); return customerInfo; } // sends a new object back instead function upperCaseLocation(customerInfo) { return { name: customerInfo.name, location: customerInfo.location.toUpperCase(), age: customerInfo.age }; }
Scrivi i tuoi test prima del tuo codice
Il processo di scrittura degli unit test prima del codice che stanno testando è chiamato sviluppo guidato da test (TDD). Molti sviluppatori trovano che TDD sia molto utile.
Scrivendo prima i tuoi test, sei costretto a pensare all'API che stai esponendo dal punto di vista di uno sviluppatore che la consuma. Aiuta anche a garantire che stai solo scrivendo codice sufficiente per soddisfare il contratto imposto dai test, piuttosto che progettare eccessivamente una soluzione inutilmente complessa.
In pratica, il TDD è una disciplina in cui può essere difficile impegnarsi per tutte le modifiche al codice. Ma quando sembra che valga la pena provare, è un ottimo modo per garantire che tutto il codice sia testabile.
Incartare
Sappiamo tutti che ci sono alcune insidie in cui è molto facile cadere quando si scrivono e si testano app JavaScript complesse. Ma si spera che con questi suggerimenti e ricordandoci di mantenere sempre il nostro codice il più semplice e funzionale possibile, possiamo mantenere la nostra copertura di test elevata e la complessità complessiva del codice bassa!
- I 10 errori più comuni commessi dagli sviluppatori JavaScript
- The Need for Speed: una retrospettiva sulla sfida della codifica JavaScript