Come testare efficacemente i tuoi script Bash

Testa gli script Bash con modalità strict, tracing, Bats, shUnit2, comandi mock, directory temporanee, ShellCheck e automazione CI.

Come Testare Efficacemente i Tuoi Script Bash

Gli script Bash spesso interagiscono con file, servizi, deployment e dati di produzione. Testare efficacemente i tuoi script Bash ti aiuta a individuare presupposti errati prima che un job di pulizia rimuova la directory sbagliata o uno script di deployment salti un comando fallito.

Non hai bisogno di un framework enorme per iniziare. Combina opzioni difensive della shell, controlli statici, test unitari mirati e ambienti di test temporanei in modo che i tuoi script falliscano in modo rumoroso e prevedibile.


Fondamenti: Codifica Difensiva e Debug

Prima di implementare test unitari formali, il primo livello di difesa contro i bug risiede nella struttura stessa dello script. Utilizzare impostazioni operative rigorose può aiutare a trasformare errori di runtime sottili in fallimenti immediati, rendendoli più facili da debuggare.

Intestazione Difensiva Essenziale

Molti script Bash di produzione iniziano con opzioni più restrittive:

#!/bin/bash
# Esce immediatamente se un comando termina con uno stato non zero.
set -e

# Tratta le variabili non impostate come un errore durante la sostituzione.
set -u

# Impedisce che gli errori in una pipeline vengano mascherati.
set -o pipefail

Combinare queste opzioni in set -euo pipefail è comune. Tieni presente che set -e ha casi limite in condizionali, subshell e pipeline, quindi controlla comunque esplicitamente i fallimenti previsti invece di assumere che la modalità strict sostituisca i test.

Debug Manuale con il Tracing

Per un debug rapido o per comprendere il flusso di esecuzione dello script, Bash offre capacità di tracing integrate:

  • Tracing dei Comandi (-x): Stampa i comandi e i loro argomenti mentre vengono eseguiti, preceduti da +.
  • Nessuna Esecuzione (-n): Legge i comandi ma non li esegue (utile per controllare errori di sintassi).

Puoi abilitare il tracing sia quando esegui lo script che all'interno dello script stesso:

# Esecuzione dello script con tracing
bash -x ./mio_script.sh

# Abilitazione del tracing all'interno dello script per una sezione specifica
echo "Avvio operazione complessa..."
set -x # Abilita tracing
chiamata_funzione_complessa arg1 arg2
set +x # Disabilita tracing
echo "Operazione completata."

Adozione di Framework Formali per Test Unitari

Il debug manuale non è sostenibile per logiche complesse. I framework formali per test unitari ti permettono di definire casi di test ripetibili, assertire risultati attesi e automatizzare il processo di validazione.

1. Bats (Bash Automated Testing System)

Bats è probabilmente il framework più popolare e facile per il testing Bash. Ti permette di scrivere test usando la sintassi familiare di Bash, rendendo le asserzioni semplici e leggibili.

Caratteristiche Principali di Bats:

  • I test sono scritti con sintassi simile a Bash.
  • Usa il semplice comando run per eseguire lo script/funzione target.
  • Fornisce variabili di asserzione integrate come $status, $output e $lines.

Esempio: Test di una Funzione Semplice

Immagina di avere uno script (calcolatrice.sh) contenente una funzione calcola_somma.

Frammento di calcolatrice.sh:

calcola_somma() {
  if [[ $# -ne 2 ]]; then
    echo "Errore: Richiede due argomenti" >&2
    return 1
  fi
  echo $(( $1 + $2 ))
}

test/calcolatrice.bats:

#!/usr/bin/env bats

# Sorgente dello script contenente le funzioni da testare.
# BATS_TEST_DIRNAME punta alla directory che contiene questo file di test.
source "$BATS_TEST_DIRNAME/../calcolatrice.sh"

@test "Input validi dovrebbero restituire la somma corretta" {
  run calcola_somma 10 5
  # Asserisce che la funzione ha restituito uno stato di successo (0)
  [ "$status" -eq 0 ]
  # Asserisce che l'output corrisponde all'aspettativa
  [ "$output" = "15" ]
}

@test "Input mancanti dovrebbero restituire stato di errore (1)" {
  run calcola_somma 5
  [ "$status" -ne 0 ]
  [ "$status" -eq 1 ]
  # Nelle versioni recenti di bats-core, stderr è disponibile quando si usa `run`.
  # [ "$stderr" = "Errore: Richiede due argomenti" ] 
}

Per eseguire i test:

bats test/calcolatrice.bats

2. ShUnit2

ShUnit2 segue lo stile di testing xUnit, rendendolo familiare agli sviluppatori che provengono da linguaggi come Python o Java. Richiede il sourcing dei file del framework e aderisce a una rigida convenzione di denominazione (setUp, tearDown, test_...).

Caratteristiche Principali di ShUnit2:

  • Supporta routine di setup e teardown per la pulizia.
  • Fornisce un ricco insieme di funzioni di asserzione integrate (es. assertTrue, assertEquals).

Struttura di ShUnit2

#!/bin/bash
# Sorgente di shUnit2. Regola questo percorso per la tua installazione.
. /usr/local/share/shunit2/shunit2

# Definisci variabili/fixture

setUp() {
  # Codice da eseguire prima di ogni test
  FILE_TEMP=$(mktemp)
}

tearDown() {
  # Codice da eseguire dopo ogni test (pulizia)
  rm -f "$FILE_TEMP"
}

test_addizione_base() {
  local risultato
  # Chiama la funzione in fase di test
  risultato=$(funzione_mio_script 1 2)
  
  # Usa una funzione di asserzione
  assertEquals "3" "$risultato"
}

# Se il tuo pacchetto shUnit2 si aspetta un sourcing esplicito alla fine,
# fallo dopo le tue funzioni di test invece che vicino all'inizio.

Migliori Pratiche per il Testing di Script Bash

Un testing efficace va oltre l'esecuzione di un framework; richiede un attento isolamento dei componenti e la gestione delle dipendenze ambientali.

1. Gestione di Input, Output ed Errori

I tuoi test devono verificare i flussi standard (stdout, stderr) e il codice di uscita finale, che è il meccanismo principale per segnalare successo o fallimento in Bash.

  • Codici di Uscita: Testa per status -eq 0 per il successo e valori non zero per condizioni di errore come fallimento di parsing o file mancanti.
  • Output Standard (stdout): Questo è tipicamente l'output primario dei dati. Usa $output di Bats o cattura l'output in ShUnit2 per assertire la correttezza.
  • Errore Standard (stderr): Errori, avvisi e messaggi di debug dovrebbero essere reindirizzati qui. Fondamentalmente, assicurati che gli script di produzione siano silenziosi su stderr durante le esecuzioni riuscite.

2. Isolamento delle Dipendenze (Mocking)

I test unitari dovrebbero testare il tuo codice, non gli strumenti di sistema esterni (come curl, kubectl o git). Se il tuo script dipende da un comando esterno, dovresti mockare quel comando durante il test.

Metodo: Crea una directory temporanea contenente file eseguibili mock che hanno lo stesso nome delle dipendenze reali. Anteponi questa directory al tuo $PATH prima di eseguire il test, assicurandoti che il tuo script chiami il mock invece dello strumento reale.

Esempio di Mock:

#!/bin/bash
# File: /tmp/mock_bin/curl

if [[ "$1" == "--version" ]]; then
  echo "Mock Curl 7.6"
  exit 0
else
  # Simula una risposta API di successo
  echo '{"status": "ok"}'
  exit 0
fi

Nel tuo setup di test:

export PATH="/tmp/mock_bin:$PATH"

3. Test di Integrazione con Ambienti Temporanei

I test di integrazione verificano che lo script interagisca correttamente con il filesystem e il sistema operativo. Usa directory temporanee per evitare di inquinare il sistema o interferire con altri test.

Usando mktemp

Il comando mktemp -d crea una directory temporanea sicura e unica. Dovresti eseguire tutta la manipolazione dei file (creazione, modifica, pulizia) all'interno di questa directory durante l'esecuzione del test.

setUp() {
  # Crea una directory temporanea per questa esecuzione di test
  RADICE_TEST=$(mktemp -d)
  cd "$RADICE_TEST"
}

tearDown() {
  # Pulisci la directory temporanea
  cd - >/dev/null
  rm -rf "$RADICE_TEST"
}

@test "Lo script dovrebbe creare il file di log richiesto" {
  run mio_script_che_scrive_log
  
  # Asserisce che il file atteso esiste nella directory temporanea
  [ -f "./log/script.log" ]
}

4. Test di Portabilità

Le implementazioni di Bash variano leggermente (es. GNU Bash vs. macOS/BSD Bash). Se la portabilità è un problema, esegui la tua suite di test su vari ambienti target (es. usando container Docker) per cogliere sottili differenze nei comandi di utilità o nell'espansione dei parametri.

Integrazione del Testing nel Flusso di Lavoro

Il testing non dovrebbe essere un ripensamento. Incorpora la tua suite di test nel tuo controllo versione e nella pipeline CI/CD (Integrazione Continua/Consegna Continua).

  1. Controllo Versione: Archivia la directory di test (es. test/) insieme ai tuoi script sorgente.
  2. Hook Pre-Commit: Usa strumenti come shellcheck (uno strumento di analisi statica) e formattatori per garantire la qualità del codice prima dei commit.
  3. Automazione CI: Configura il tuo server CI (GitHub Actions, GitLab CI, Jenkins) per eseguire automaticamente la suite di test Bats o ShUnit2 ad ogni push. Fai fallire la build se un qualsiasi test restituisce uno stato non zero.

Avvertenza: Gli strumenti di analisi statica come shellcheck sono eccellenti compagni per i test unitari. Individuano errori comuni, problemi di portabilità e vulnerabilità di sicurezza che i test potrebbero non cogliere. Esegui sempre shellcheck come parte della tua routine pre-test.

Conclusione

Inizia con shellcheck e set -euo pipefail, poi aggiungi test intorno alle parti del tuo script che analizzano input, scelgono file, chiamano strumenti esterni o apportano modifiche irreversibili. Una piccola suite Bats con dipendenze mockate e directory temporanee è spesso sufficiente per trasformare uno script rischioso in un'automazione che puoi modificare con sicurezza.