Negli anni 60, era considerata una buona pratica di base nell’ingegneria del software testare il proprio codice mentre lo si scriveva. I pionieri dello sviluppo del software in quell’epoca erano sostenitori di vari livelli di test; alcuni sostenevano il test “unitario” e altri no, ma tutti riconoscevano l’importanza di testare il codice.

I test eseguibili potrebbero essere stati introdotti per la prima volta da Margaret Hamilton sul progetto Apollo a metà degli anni ’60, dove ha dato origine a un tipo di controllo eseguibile che oggi chiamiamo “analisi statica del codice”. Lo chiamò “software di ordine superiore”, intendendo con ciò un software che opera contro altri software piuttosto che direttamente contro il dominio del problema. Il suo software di ordine superiore esaminava il codice sorgente per cercare schemi che erano noti per portare a problemi di integrazione.

Nel 1970, la gente aveva ampiamente dimenticato il test eseguibile. Certo, le persone eseguivano le applicazioni e le toccavano qua e là a mano, ma finché l’edificio non bruciava intorno a loro, pensavano che il codice fosse “abbastanza buono”. Il risultato è stato più di 35 anni di codice in produzione in tutto il mondo che è testato in modo inadeguato, e in molti casi non funziona completamente come previsto, o in un modo che soddisfa i suoi clienti.

L’idea dei programmatori che testano mentre vanno ha fatto un ritorno a partire dalla metà degli anni ’90, anche se fino ad oggi la grande maggioranza dei programmatori ancora non lo fa. Gli ingegneri dell’infrastruttura e gli amministratori di sistema testano i loro script ancora meno diligentemente di quanto i programmatori testino il loro codice applicativo.

Mentre ci muoviamo in un’era in cui la distribuzione rapida di soluzioni complicate che comprendono numerosi componenti autonomi sta diventando la norma, e le infrastrutture “cloud” ci richiedono di gestire migliaia di VM e container che vanno e vengono su una scala che non può essere gestita con metodi manuali, l’importanza di test e controlli eseguibili e automatizzati durante il processo di sviluppo e consegna non può essere ignorata; non solo per i programmatori di applicazioni, ma per chiunque sia coinvolto nel lavoro IT.

Con l’avvento di devops (impollinazione incrociata di competenze, metodi e strumenti per lo sviluppo e le operazioni), e tendenze come “infrastruttura come codice” e “automatizzare tutte le cose”, il test delle unità è diventato una competenza di base per programmatori, tester, amministratori di sistema e ingegneri delle infrastrutture.

In questa serie di post, introdurremo l’idea dei test unitari degli script di shell, e poi esploreremo diversi framework di test unitari che possono aiutare a rendere questo compito pratico e sostenibile su larga scala.

Un’altra pratica che potrebbe non essere familiare a molti ingegneri dell’infrastruttura è il controllo delle versioni. Più avanti in questa serie, toccheremo i sistemi di controllo della versione e i flussi di lavoro che gli sviluppatori di applicazioni usano, e che possono essere efficaci e utili anche per gli ingegneri dell’infrastruttura.

Uno script per testare

Vivek Gite ha pubblicato uno script shell di esempio per monitorare l’uso del disco e generare una notifica via email quando certi filesystem superano una soglia. Il suo articolo è qui: https://www.cyberciti.biz/tips/shell-script-to-watch-the-disk-space.html. Usiamolo come soggetto di prova.

La versione iniziale del suo script, con l’aggiunta dell’opzione -P sul comando df per evitare interruzioni di linea nell’output, come suggerito in un commento di Per Lindahl, assomiglia a questo:

#!/bin/shdf -HP | grep -vE '^Filesystem|tmpfs|cdrom' | awk '{ print  " "  }' | while read output;do usep=$(echo $output | awk '{ print }' | cut -d'%' -f1 ) partition=$(echo $output | awk '{ print  }' ) if ; then echo "Running out of space \"$partition ($usep%)\" on $(hostname) as on $(date)" | mail -s "Alert: Almost out of disk space $usep%" [email protected] fidone

Vivek continua a perfezionare lo script oltre questo punto, ma questa versione servirà agli scopi del presente post.

Controlli funzionali automatizzati

Un paio di regole empiriche sui controlli funzionali automatizzati, sia che stiamo controllando il codice di un’applicazione o uno script o qualsiasi altro tipo di software:

  • il controllo deve essere identico ogni volta, senza alcuna modifica manuale necessaria per preparare ogni esecuzione; e
  • il risultato non può essere vulnerabile ai cambiamenti nell’ambiente di esecuzione, o nei dati, o altri fattori esterni al codice sotto test.

Passato, Fallito ed Errore

Si potrebbe sottolineare che è possibile che lo script non venga eseguito affatto. Questo è normale per qualsiasi tipo di struttura di test unitario per qualsiasi tipo di applicazione. Sono possibili tre risultati, piuttosto che due:

  • Il codice sotto test mostra il comportamento atteso
  • Il codice sotto test gira, ma non mostra il comportamento atteso
  • Il codice sotto test non gira

Per scopi pratici, il terzo risultato è lo stesso del secondo; dovremo capire cosa è andato male e sistemarlo. Quindi, generalmente pensiamo a queste cose come binarie: Passa o fallisce.

Cosa dobbiamo controllare?

In questo caso, siamo interessati a verificare che lo script si comporti come previsto dati vari valori di input. Non vogliamo inquinare i nostri controlli unitari con altre verifiche oltre a questo.

Ripercorrendo il codice in prova, vediamo che quando l’utilizzo del disco raggiunge una soglia del 90%, lo script chiama mail per inviare una notifica all’amministratore di sistema.

In linea con la buona pratica generalmente accettata per i controlli unitari, vogliamo definire casi separati per verificare ogni comportamento che ci aspettiamo per ogni serie di condizioni iniziali.

Mettendo il nostro cappello da “tester”, vediamo che questa è una specie di condizione limite. Non abbiamo bisogno di controllare numerose percentuali diverse di utilizzo del disco individualmente. Abbiamo solo bisogno di controllare il comportamento ai confini. Quindi, l’insieme minimo di casi per fornire una copertura significativa sarà:

  • Invia un’email quando l’utilizzo del disco raggiunge la soglia
  • Non invia un’email quando l’utilizzo del disco è sotto la soglia

Cosa non dovremmo controllare?

In linea con la buona pratica generalmente accettata per l’isolamento dei test unitari, vogliamo assicurarci che ognuno dei nostri casi possa fallire esattamente per una ragione: Il comportamento atteso non si verifica. Per quanto possibile, vogliamo impostare i nostri controlli in modo che altri fattori non causino il fallimento del caso.

Può non essere sempre conveniente (o anche possibile) garantire che fattori esterni non influenzino i nostri controlli automatici. Ci sono momenti in cui non possiamo controllare un elemento esterno, o quando farlo comporterebbe più tempo, sforzo e costi rispetto al valore del controllo, e/o coinvolge un oscuro caso limite che ha una probabilità molto bassa di verificarsi o un impatto molto piccolo quando si verifica. È una questione di giudizio professionale. Come regola generale, fate del vostro meglio per evitare di creare dipendenze da fattori che vanno oltre lo scopo del codice in prova.

Non abbiamo bisogno di verificare che i comandi df, grep, awk, cut e mail funzionino. Questo è fuori dallo scopo dei nostri scopi. Chiunque mantenga le utilità è responsabile di questo.

Vogliamo sapere se l’output del comando df non viene processato come ci aspettiamo da grep o awk. Pertanto, vogliamo che i veri comandi grep e awk vengano eseguiti nei nostri controlli, in base all’output del comando df che corrisponde all’intento di ogni caso di test. Questo è nell’ambito perché gli argomenti della linea di comando di df sono parte dello script, e lo script è il codice sotto test.

Questo significa che avremo bisogno di una versione falsa del comando df da usare con i nostri controlli unitari. Questo tipo di componente falso è spesso chiamato mock. Un mock sostituisce un componente reale e fornisce un output predefinito per guidare il comportamento del sistema in modo controllato, in modo da poter controllare il comportamento del codice sotto test in modo affidabile.

Vediamo che lo script invia una notifica via email quando un filesystem raggiunge la soglia di utilizzo. Non vogliamo che i nostri controlli di unità sputino fuori un mucchio di email inutili, quindi vorremo deridere anche il comando mail.

Questo script è un buon esempio per illustrare la derisione di questi comandi, poiché lo faremo in un modo diverso per mail che per df.

Mock del comando df

Lo script è costruito intorno al comando df. La linea rilevante nello script è:

df -HP | grep -vE '^Filesystem|tmpfs|cdrom' | awk '{ print  " "  }'

Se si esegue solo df -HP, senza piping in grep, si vedrà un output simile a questo:

Filesystem Size Used Avail Use% Mounted onudev 492M 0 492M 0% /devtmpfs 103M 6.0M 97M 6% /run/dev/sda1 20G 9.9G 9.2G 52% /tmpfs 511M 44M 468M 9% /dev/shmtmpfs 5.3M 0 5.3M 0% /run/locktmpfs 511M 0 511M 0% /sys/fs/cgrouptmpfs 103M 8.2k 103M 1% /run/user/1000

I comandi grep e awk riducono l’output a questo:

0% udev52% /dev/sda1

Abbiamo bisogno di controllare l’output di df per guidare i nostri test. Non vogliamo che il risultato del controllo vari in base all’effettivo utilizzo del disco sul sistema dove stiamo eseguendo la suite di test. Non stiamo controllando l’uso del disco; stiamo controllando la logica dello script. Quando lo script viene eseguito in produzione, controllerà l’utilizzo del disco. Quello che stiamo facendo qui è per la validazione, non per le operazioni di produzione. Pertanto, abbiamo bisogno di un falso o “finto” comando df con cui possiamo generare i “dati di prova” per ogni caso.

Su una piattaforma *nix è possibile sovrascrivere il vero comando df definendo un alias. Vogliamo che il comando alias emetta valori di test nello stesso formato dell’output di df -HP. Ecco un modo per farlo (questa è tutta una linea; è spezzata sotto per leggibilità):

alias df="shift;echo -e 'Filesystem Size Used Avail Use% Mounted on'; echo -e 'tempfs 511M 31M 481M 6% /dev/shm'; echo -e '/dev/sda1 20G 9.9G 9.2G 52% /'"

Lo shift salta l’argomento ‘-HP’ quando lo script viene eseguito, in modo che il sistema non si lamenti che -HP è un comando sconosciuto. Il comando alias df emette output nella stessa forma di df -HP.

I valori di test sono convogliati in grep e poi in awk quando lo script viene eseguito, così stiamo facendo mocking solo del minimo necessario per controllare i nostri test case. Vogliamo che il nostro caso di test sia il più vicino possibile alla “cosa reale” in modo da non ottenere falsi positivi.

I mock creati tramite una libreria mock possono restituire un valore predefinito quando vengono chiamati. Il nostro approccio al mock del comando df rispecchia la funzione di un mock; stiamo specificando un output predefinito da restituire ogni volta che il codice sotto test chiama df.

Mocking del comando mail

Vogliamo sapere se lo script cerca di inviare una mail nelle giuste condizioni, ma non vogliamo che invii una vera mail da nessuna parte. Pertanto, vogliamo dare un alias al comando mail, come abbiamo fatto prima con il comando df. Dobbiamo impostare qualcosa che possiamo controllare dopo ogni caso di test. Una possibilità è quella di scrivere un valore in un file quando mail viene chiamato, e poi controllare il valore nel nostro caso di test. Questo è mostrato nell’esempio qui sotto. Sono possibili anche altri metodi.

I mock creati tramite una libreria di mocking possono contare il numero di volte che vengono chiamati dal codice sotto test, e possiamo asserire il numero previsto di invocazioni. Il nostro approccio al mock del comando mail rispecchia la funzione di un mock; se il testo “mail” è presente nel file mailsent dopo che abbiamo eseguito lo script diskusage.sh, significa che lo script ha chiamato il comando mail.

Modello per eseguire controlli automatici

I controlli automatici o eseguibili a qualsiasi livello di astrazione, per qualsiasi tipo di applicazione o script, in qualsiasi linguaggio, comprendono tipicamente tre passi. Questi di solito si chiamano:

  • Arrange
  • Act
  • Assert

La ragione di ciò è probabilmente che tutti amano l’allitterazione, specialmente sulla lettera A, poiché la parola “allitterazione” stessa inizia con la lettera A.

Qualunque sia la ragione, nel passo arrange impostiamo le precondizioni del nostro caso di test. Nel passo act invochiamo il codice sotto test. Nel passo assert dichiariamo il risultato che ci aspettiamo di vedere.

Quando usiamo un framework o una libreria di test, lo strumento gestisce bene il passo assert per noi in modo che non abbiamo bisogno di codificare un sacco di ingombrante logica if/else nelle nostre suite di test. Per il nostro esempio iniziale, non stiamo usando un framework o una libreria di test, quindi controlliamo i risultati di ogni caso con un blocco if/else. Nella prossima puntata, giocheremo con framework di test unitari per linguaggi di shell, e vedremo come sarà.

Ecco il nostro script di test grezzo ma efficace per testare lo script di shell di Vivek, che abbiamo chiamato diskusage.sh:

#!/bin/bashshopt -s expand_aliases# Before allalias mail="echo 'mail' > mailsent;false"echo 'Test results for diskusage.sh' > test_resultstcnt=0# It does nothing when disk usage is below 90%# Before (arrange)alias df="echo 'Filesystem Size Used Avail Use% Mounted on';echo '/dev/sda2 100G 89.0G 11.0G 89% /'"echo 'no mail' > mailsent# Run code under test (act). ./diskusage.sh# Check result (assert)((tcnt=tcnt+1))if ]; then echo "$tcnt. FAIL: Expected no mail to be sent for disk usage under 90%" >> test_resultselse echo "$tcnt. PASS: No action taken for disk usage under 90%" >> test_resultsfi # It sends an email notification when disk usage is at 90%alias df="echo 'Filesystem Size Used Avail Use% Mounted on';echo '/dev/sda1 100G 90.0G 10.0G 90% /'"echo 'no mail' > mailsent. ./diskusage.sh((tcnt=tcnt+1))if ]; then echo "$tcnt. PASS: Notification was sent for disk usage of 90%" >> test_resultselse echo "$tcnt. FAIL: Disk usage was 90% but no notification was sent" >> test_resultsfi # After allunalias dfunalias mail# Display test results cat test_results

Ecco una spiegazione dello script di test.

Primo, vedete che stiamo usando bash per testare un semplice vecchio file .sh. Questo va perfettamente bene. Non è necessario, ma va bene.

Poi, vedete un comando shopt. Questo farà sì che la shell espanda i nostri alias di test quando la subshell viene invocata per eseguire lo script diskusage.sh. Nella maggior parte dei casi d’uso, non passeremmo alias nelle subshell, ma i test unitari sono un’eccezione.

Il commento, “Prima di tutto”, è per le persone che hanno familiarità con i framework di test unitari che hanno comandi di set up e tear down. Questi sono spesso nominati qualcosa come “prima” e “dopo”, e di solito c’è una coppia che mette in parentesi l’intera suite di test e un’altra coppia che viene eseguita individualmente per ogni caso di test.

Vogliamo mostrare che la definizione dell’alias per la posta, l’inizializzazione del file dei risultati del test, e l’inizializzazione del contatore dei casi di test sono tutti fatti esattamente una volta, all’inizio della suite di test. Questo genere di cose è normale nelle suite di test eseguibili. Il fatto che stiamo testando uno script di shell invece di un programma applicativo non cambia questo fatto.

Il prossimo commento, “Non fa niente…” indica l’inizio del nostro primo caso di test individuale. La maggior parte dei framework di test unitari offrono un modo per fornire un nome ad ogni caso, così possiamo tenere traccia di ciò che sta succedendo e in modo che altri strumenti possano cercare, filtrare ed estrarre i casi di test per vari motivi.

Prossimo, c’è un commento che dice, “Before (arrange)”. Questo rappresenta un set up che si applica solo a un caso di test. Stiamo impostando l’alias df per emettere l’output di cui abbiamo bisogno per questo caso particolare. Stiamo anche scrivendo il testo “no mail” in un file. Questo è il modo in cui saremo in grado di dire se lo script diskusage.sh ha tentato di inviare un’email di notifica.

Il passo successivo è quello di esercitare il codice sotto test. In questo caso, ciò significa eseguire lo stesso script diskusage.sh. Lo generiamo invece di eseguirlo direttamente.

Ora facciamo il passo di asserzione, che stiamo facendo nel modo più difficile in questo esempio perché non abbiamo ancora introdotto un framework di test. Incrementiamo il contatore dei test in modo da poter numerare i casi di test nel file dei risultati. Altrimenti, se avessimo un gran numero di casi, potrebbe diventare difficile capire quali sono falliti. I framework di test gestiscono questo per noi.

L’alias che abbiamo definito per il comando mail scrive il testo ‘mail’ nel file mailsent. Se diskusage.sh chiama mail, allora il file mailsent conterrà ‘mail’ invece del valore iniziale, ‘no mail’. Potete vedere quali sono le condizioni di successo e fallimento leggendo le stringhe echeggiate nel file dei risultati del test.

Partendo dal commento, “Invia una notifica e-mail…” ripetiamo i passi arrange, act, assert per un altro caso di test. Questa volta faremo emettere al nostro falso comando df dei dati diversi, per guidare un comportamento diverso dal codice in prova.

Dove appare il commento “After all”, ci stiamo ripulendo eliminando le definizioni che abbiamo creato nell’impostazione “Before all” vicino alla parte superiore dello script di test.

Finalmente, scarichiamo il contenuto del file test_results così possiamo vedere cosa abbiamo ottenuto. Assomiglia a questo:

Test results for diskusage.sh1. PASS: No action taken for disk usage under 90%2. PASS: Notification was sent for disk usage of 90%

Perché usare un test framework/biblioteca?

Abbiamo appena scritto un paio di casi di test unitari per uno script di shell senza usare un test framework, una mocking library o una assertion library. Abbiamo scoperto che i comandi di sistema possono essere presi in giro definendo degli alias (almeno sui sistemi *nix), che le asserzioni possono essere implementate come dichiarazioni condizionali, e la struttura di base di un test unitario è facile da impostare a mano.

Non è stato difficile farlo senza un framework o una libreria. Quindi, qual è il vantaggio?

I framework e le librerie di test semplificano e standardizzano il codice di test e permettono suite di test molto più leggibili rispetto agli script fatti a mano che contengono un sacco di dichiarazioni condizionali. Alcune librerie contengono utili caratteristiche aggiuntive, come la capacità di catturare le eccezioni o la capacità di scrivere casi di test guidati da tabelle e dati. Alcune sono fatte su misura per supportare prodotti specifici di interesse per gli ingegneri dell’infrastruttura, come Chef e Puppet. E alcuni includono funzionalità per tracciare la copertura del codice e/o per formattare i risultati dei test in una forma consumabile da strumenti nella pipeline CI/CD, o almeno da un browser web.

Quadri di test unitari per script

In questa serie esploreremo diversi quadri di test unitari per script di shell e linguaggi di scripting. Ecco una panoramica:

  • shunit2 è un progetto Open Source molto solido con una storia decennale. Originariamente sviluppato da Kate Ward, un Site Reliability Engineer e Manager di Google con sede a Zurigo, è attivamente sviluppato e supportato da un team di sei persone. Dalle umili origini come soluzione puntuale per testare una libreria di log per gli script di shell, è stato intenzionalmente sviluppato in un framework di test unitario generale che supporta più linguaggi di shell e sistemi operativi. Include una serie di caratteristiche utili oltre alle semplici asserzioni, incluso il supporto per test guidati dai dati e dalle tabelle. Utilizza il tradizionale stile di asserzioni “assertThat”. Il sito del progetto contiene un’eccellente documentazione. Per test unitari generici di script di shell, questa è la mia migliore raccomandazione.
  • BATS (Bash Automated Testing System) è un framework di test unitari per bash. È stato creato da Sam Stephenson circa sette anni fa, e ha avuto una dozzina di collaboratori. L’ultimo aggiornamento risale a quattro anni fa, ma questo non è niente di cui preoccuparsi, poiché questo tipo di strumento non richiede aggiornamenti o manutenzione frequenti. BATS è basato sul Test Anything Protocol (TAP), che definisce un’interfaccia coerente basata sul testo tra i moduli in qualsiasi tipo di test harness. Permette una sintassi pulita e coerente nei casi di test, anche se non sembra aggiungere molto zucchero sintattico oltre alle semplici dichiarazioni bash. Per esempio, non c’è una sintassi speciale per le asserzioni; si scrivono comandi bash per testare i risultati. Con questo in mente, il suo valore principale può risiedere nell’organizzare suite di test e casi in modo logico. Si noti, inoltre, che scrivere script di test in bash non ci impedisce di testare script non bash; lo abbiamo fatto prima in questo post. Il fatto che la sintassi di BATS sia così vicina alla sintassi di bash ci dà un sacco di flessibilità per gestire diversi linguaggi di shell nelle nostre suite di test, al possibile costo della leggibilità (a seconda di ciò che si trova “leggibile”; il pubblico previsto per questo post probabilmente trova la sintassi del linguaggio di shell abbastanza leggibile). Una caratteristica particolarmente interessante (secondo me) è che puoi impostare il tuo editor di testo con l’evidenziazione della sintassi per BATS, come documentato sul wiki del progetto. Emacs, Sublime Text 2, TextMate, Vim e Atom erano supportati alla data di questo post.
  • zunit (non quello IBM, l’altro) è un framework di test unitari per zsh sviluppato da James Dinsdale. Il sito del progetto afferma che zunit è stato ispirato da BATS, e include le utilissime variabili $state, $output e $lines. Ma ha anche una sintassi di asserzione definitiva che segue lo schema, “assert actual matches expected”. Ognuno di questi framework ha alcune caratteristiche uniche. Una caratteristica interessante di ZUnit, a mio parere, è che segnala tutti i casi di test che non contengono un’asserzione come “rischiosi”. Potete sovrascrivere questo e forzare l’esecuzione dei casi, ma per default il framework vi aiuta a ricordare di includere un’asserzione in ogni caso di test.
  • bash-spec è un framework di test in stile comportamentale che supporta solo bash (o almeno, è stato testato solo contro script bash). È un mio umile progetto secondario che è stato in giro per oltre quattro anni e ha pochi utenti “reali”. Non viene aggiornato molto, dato che al momento fa quello per cui è stato pensato. Un obiettivo del progetto era quello di fare uso di funzioni bash in uno stile “fluido”. Le funzioni sono chiamate in sequenza, ciascuna passando l’intera lista di argomenti alla successiva dopo aver consumato quanti argomenti ha bisogno per eseguire il suo compito. Il risultato è una suite di test leggibile, con dichiarazioni come “expect package-name to_be_installed” e “expect arrayname not to_contain value”. Quando viene usato per guidare lo sviluppo test-first degli script, il suo design tende a portare lo sviluppatore a scrivere funzioni che supportano l’idea di “modularità” o “responsabilità singola” o “separazione delle preoccupazioni” (chiamatela come volete), con conseguente facilità di manutenzione e funzioni facilmente riutilizzabili. “Stile comportamentale” significa che le asserzioni prendono la forma, “aspettati che questo corrisponda a quello.”
  • korn-spec è un port di bash-spec per la shell korn.
  • Pester è il framework di test unitari scelto per Powershell. Powershell assomiglia e si sente più come un linguaggio di programmazione di applicazioni che puramente un linguaggio di scripting, e Pester offre un’esperienza di sviluppo completamente coerente. Pester viene fornito con Windows 10 e può essere installato su qualsiasi altro sistema che supporti Powershell. Ha una robusta libreria di asserzioni, supporto integrato per il mocking e raccoglie metriche di copertura del codice.
  • ChefSpec si basa su rspec per fornire una struttura di test in stile comportamentale per le ricette di Chef. Chef è un’applicazione Ruby, e ChefSpec sfrutta appieno le capacità di rspec più il supporto integrato per le funzionalità specifiche di Chef.
  • rspec-puppet è un framework in stile comportamentale per Puppet, funzionalmente simile a ChefSpec.

admin

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.

lg