Tester un script Bash en prod : 38 tests sans framework

Le contexte : un script Bash pour afficher les rate limits Claude Code dans une status bar terminal. À la base, ça paraît trivial — quelques fonctions, une lecture de fichier JSON, du formatage. Mais rapidement le script gère : parsing de JSON avec jq, formatage d'heures avec timezone, calculs de pourcentages, génération de progress bars Unicode, logique de countdown. Et surtout : plusieurs cas limites (données absentes, JSON corrompu, quotas à 0, timezone inconnue).

Sans tests, chaque modification devient une roulette. On change le formatage du countdown, on ne sait pas si on a cassé le calcul de pourcentage. On ajoute la gestion du modèle actif, on ne sait pas si les fixtures de JSON corrompu passent encore. Avec 38 tests unitaires sur script Bash, on modifie avec confiance — et bash tests/test_statusline.sh en 2 secondes confirme que rien n'est cassé.

Pourquoi pas de framework de test Bash

Il existe des frameworks de test Bash : bats-core, shunit2, shellspec. Ils sont bien. Pour ce projet, le choix était de ne pas en ajouter — zéro dépendance externe dans le repo, installation en one-liner. Un framework de test est une dépendance. La contrainte était simple : les tests tournent avec bash natif, rien à installer.

Résultat : ~100 lignes de helpers de test maison. Pas de magie, pas de DSL, pas de plugin à mettre à jour — juste des fonctions et des conventions. Pour un script de 300 lignes, c'est le bon niveau d'ingénierie. Un testing framework pour tester un script Bash, c'est souvent plus de complexité que le script lui-même.

La structure de base : assertions et compteurs

Le fichier de test démarre avec deux compteurs globaux et deux fonctions d'assertion :

#!/usr/bin/env bash
# tests/test_statusline.sh

PASS=0
FAIL=0

assert_eq() {
    local description="$1"
    local expected="$2"
    local actual="$3"

    if [[ "$expected" == "$actual" ]]; then
        echo "  ✓ $description"
        ((PASS++))
    else
        echo "  ✗ $description"
        echo "    expected: $(echo "$expected" | cat -A)"
        echo "    actual:   $(echo "$actual" | cat -A)"
        ((FAIL++))
    fi
}

assert_contains() {
    local description="$1"
    local needle="$2"
    local haystack="$3"

    if [[ "$haystack" == *"$needle"* ]]; then
        echo "  ✓ $description"
        ((PASS++))
    else
        echo "  ✗ $description"
        echo "    expected to contain: $needle"
        echo "    actual: $haystack"
        ((FAIL++))
    fi
}

# À la fin du fichier de tests :
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1

Le cat -A dans le message d'erreur affiche les caractères invisibles (espaces, tabs, \n, $ en fin de ligne) — indispensable pour déboguer des tests qui échouent sur un espace en trop dans une chaîne formatée. Sans ça, on passe 20 minutes à comparer deux chaînes identiques visuellement qui ne sont pas égales.

Tester des fonctions Bash : le problème d'isolation

Le script principal (statusline.sh) n'est pas conçu pour être sourcé dans les tests — il exécute du code au top-level dès qu'on le lance. Le pattern pour isoler les fonctions sans modifier le comportement en production :

# Dans statusline.sh, wrapper le code exécutable :
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    # Exécuté seulement quand le script est lancé directement
    main "$@"
fi

# Les fonctions définies dans le fichier restent disponibles quand on source

Dans le fichier de test, on source les fonctions sans déclencher le main :

# Sourcer les fonctions sans exécuter le main
source "$(dirname "$0")/../statusline.sh"

# Maintenant on peut tester les fonctions directement
result=$(format_percentage 67)
assert_eq "67% donne jaune" "🟡" "$result"

BASH_SOURCE[0] vaut le chemin du script en cours d'exécution. $0 vaut le chemin du script lancé par l'utilisateur. Quand on source un fichier, ces deux valeurs diffèrent — le if ne s'exécute pas.

Mocker des dépendances externes

Le script appelle jq, lit des fichiers JSON, utilise date avec timezone. Pour les tests unitaires sur script Bash, on mock en redéfinissant les commandes dans le scope du test — Bash résout les fonctions avant les binaires dans le PATH :

# Mock jq pour retourner des données contrôlées
jq() {
    echo "42"
}
export -f jq

result=$(get_context_percentage)
assert_eq "context à 42%" "42" "$result"

# Nettoyer le mock après le test
unset -f jq

Pour des données JSON plus complexes, on crée des fixtures dans tests/fixtures/ et on surcharge la variable de chemin via l'environnement :

USAGE_FILE="tests/fixtures/usage_normal.json" \
    result=$(render_statusline)
assert_contains "affiche le modèle" "Sonnet 4.6" "$result"

USAGE_FILE="tests/fixtures/usage_corrupted.json" \
    result=$(render_statusline)
assert_contains "JSON corrompu → fallback" "N/A" "$result"

L'avantage : les fixtures sont de vrais fichiers JSON lisibles, versionnables, faciles à mettre à jour quand le format change. Pas besoin de mocker jq pour chaque cas — on fournit directement la donnée d'entrée.

Un exemple concret : tester le progress bar Unicode

La progress bar (▓▓▓░░░░░) est l'une des fonctions les plus testées — elle doit gérer correctement les cas limites de l'integer division Bash :

test_progress_bar() {
    echo "--- Progress bar ---"
    assert_eq "0%  → 8 blocs vides"    "░░░░░░░░" "$(make_bar 0)"
    assert_eq "50% → 4 pleins 4 vides" "▓▓▓▓░░░░" "$(make_bar 50)"
    assert_eq "100%→ 8 blocs pleins"   "▓▓▓▓▓▓▓▓" "$(make_bar 100)"
    assert_eq "34% → arrondi bas"      "▓▓░░░░░░" "$(make_bar 34)"
    assert_eq "99% → 7 pleins"         "▓▓▓▓▓▓▓░" "$(make_bar 99)"
}

5 tests, 5 cas limites. Le cas 34% est le plus important : 34 * 8 / 100 = 2.72 → arrondi à 2 en integer division Bash (pas de bc, pas de awk). Sans test explicite, ce comportement est découvert en production quand la barre affiche un bloc de trop. Le cas 99% vérifie que le dernier bloc reste vide — 7 pleins, pas 8.

Le résultat : 38 tests, 0 framework externe

Organisation finale des tests par catégorie :

  • Progress bar (5 tests) — valeurs limites, arrondis integer division
  • Color coding (6 tests) — seuils vert/jaune/rouge par pourcentage
  • Percentage formatting (4 tests) — affichage avec %, zéro, 100
  • Time formatting (8 tests) — countdown, timezone, heures de reset, minuit
  • JSON parsing (7 tests) — données manquantes, valeurs null, JSON corrompu
  • Full statusline render (8 tests) — sortie complète avec fixtures de données réelles

Lancement : bash tests/test_statusline.sh. Sortie :

--- Progress bar ---
  ✓ 0% → 8 blocs vides
  ✓ 50% → 4 pleins 4 vides
  ✓ 100% → 8 blocs pleins
  ✓ 34% → arrondi bas
  ✓ 99% → 7 pleins

--- Color coding ---
  ✓ 0% → vert
  ✓ 74% → vert
  ✓ 75% → jaune
  ✓ 89% → jaune
  ✓ 90% → rouge
  ✓ 100% → rouge

[...]

Results: 38 passed, 0 failed

Ce que ça change concrètement

La statusline a évolué 12+ fois en une journée de développement : ajout du modèle actif, refonte du countdown, ajout du statut dirty git, gestion des quotas épuisés. À chaque modification, bash tests/test_statusline.sh en 2 secondes. Les régressions sont détectées immédiatement — pas après avoir redémarré Claude Code pour voir que la barre affiche N/A au lieu du pourcentage.

Bash n'a pas de typage, pas de linter strict, pas d'IDE qui souligne les erreurs de logique. Les tests compensent. Ce n'est pas un substitut à un vrai langage — c'est le filet de sécurité minimal qui rend le script modifiable sans appréhension.

Conclusion

100 lignes de helpers. 38 tests unitaires bash. Zéro dépendance externe. Pour un script Bash de production qui doit être modifiable sans risque, c'est le bon investissement. Le pattern est portable sur n'importe quel projet Bash : assert_eq, assert_contains, mock par redéfinition de fonction, fixtures en fichiers JSON. Pas besoin d'un testing framework pour tester un script de 300 lignes.

Le projet complet est sur GitHub : github.com/ohugonnot/claude-code-statusline

Commentaires (0)