bats-testing-patterns

Par wshobson · agents

Maîtrisez Bash Automated Testing System (Bats) pour des tests complets de scripts shell. À utiliser lors de l'écriture de tests pour des scripts shell, des pipelines CI/CD, ou lorsqu'une approche de développement piloté par les tests est requise pour des utilitaires shell.

npx skills add https://github.com/wshobson/agents --skill bats-testing-patterns

Motifs de test Bats

Guide complet pour écrire des tests unitaires complets pour les scripts shell avec Bats (Bash Automated Testing System), incluant les motifs de test, les fixtures et les bonnes pratiques pour les tests shell de qualité production.

Quand utiliser cette compétence

  • Écrire des tests unitaires pour les scripts shell
  • Implémenter le développement piloté par les tests (TDD) pour les scripts
  • Configurer les tests automatisés dans les pipelines CI/CD
  • Tester les cas limites et les conditions d'erreur
  • Valider le comportement sur différents environnements shell
  • Construire des suites de test maintenables pour les scripts
  • Créer des fixtures pour les scénarios de test complexes
  • Tester plusieurs dialectes shell (bash, sh, dash)

Bases de Bats

Qu'est-ce que Bats ?

Bats (Bash Automated Testing System) est un framework de test conforme au TAP (Test Anything Protocol) pour les scripts shell qui fournit :

  • Une syntaxe de test simple et naturelle
  • Format de sortie TAP compatible avec les systèmes CI
  • Support des fixtures et setup/teardown
  • Assistants d'assertion
  • Exécution des tests en parallèle

Installation

# macOS avec Homebrew
brew install bats-core

# Ubuntu/Debian
git clone https://github.com/bats-core/bats-core.git
cd bats-core
./install.sh /usr/local

# Depuis npm (Node.js)
npm install --global bats

# Vérifier l'installation
bats --version

Structure des fichiers

project/
├── bin/
│   ├── script.sh
│   └── helper.sh
├── tests/
│   ├── test_script.bats
│   ├── test_helper.sh
│   ├── fixtures/
│   │   ├── input.txt
│   │   └── expected_output.txt
│   └── helpers/
│       └── mocks.bash
└── README.md

Structure de test basique

Fichier de test simple

#!/usr/bin/env bats

# Charger l'assistant de test s'il est présent
load test_helper

# Setup s'exécute avant chaque test
setup() {
    export TMPDIR=$(mktemp -d)
}

# Teardown s'exécute après chaque test
teardown() {
    rm -rf "$TMPDIR"
}

# Test : assertion simple
@test "Function returns 0 on success" {
    run my_function "input"
    [ "$status" -eq 0 ]
}

# Test : vérification de la sortie
@test "Function outputs correct result" {
    run my_function "test"
    [ "$output" = "expected output" ]
}

# Test : gestion des erreurs
@test "Function returns 1 on missing argument" {
    run my_function
    [ "$status" -eq 1 ]
}

Motifs d'assertion

Assertions de code de sortie

#!/usr/bin/env bats

@test "Command succeeds" {
    run true
    [ "$status" -eq 0 ]
}

@test "Command fails as expected" {
    run false
    [ "$status" -ne 0 ]
}

@test "Command returns specific exit code" {
    run my_function --invalid
    [ "$status" -eq 127 ]
}

@test "Can capture command result" {
    run echo "hello"
    [ $status -eq 0 ]
    [ "$output" = "hello" ]
}

Assertions de sortie

#!/usr/bin/env bats

@test "Output matches string" {
    result=$(echo "hello world")
    [ "$result" = "hello world" ]
}

@test "Output contains substring" {
    result=$(echo "hello world")
    [[ "$result" == *"world"* ]]
}

@test "Output matches pattern" {
    result=$(date +%Y)
    [[ "$result" =~ ^[0-9]{4}$ ]]
}

@test "Multi-line output" {
    run printf "line1\nline2\nline3"
    [ "$output" = "line1
line2
line3" ]
}

@test "Lines variable contains output" {
    run printf "line1\nline2\nline3"
    [ "${lines[0]}" = "line1" ]
    [ "${lines[1]}" = "line2" ]
    [ "${lines[2]}" = "line3" ]
}

Assertions de fichier

#!/usr/bin/env bats

@test "File is created" {
    [ ! -f "$TMPDIR/output.txt" ]
    my_function > "$TMPDIR/output.txt"
    [ -f "$TMPDIR/output.txt" ]
}

@test "File contents match expected" {
    my_function > "$TMPDIR/output.txt"
    [ "$(cat "$TMPDIR/output.txt")" = "expected content" ]
}

@test "File is readable" {
    touch "$TMPDIR/test.txt"
    [ -r "$TMPDIR/test.txt" ]
}

@test "File has correct permissions" {
    touch "$TMPDIR/test.txt"
    chmod 644 "$TMPDIR/test.txt"
    [ "$(stat -f %OLp "$TMPDIR/test.txt")" = "644" ]
}

@test "File size is correct" {
    echo -n "12345" > "$TMPDIR/test.txt"
    [ "$(wc -c < "$TMPDIR/test.txt")" -eq 5 ]
}

Motifs de setup et teardown

Setup et teardown basique

#!/usr/bin/env bats

setup() {
    # Créer un répertoire de test
    TEST_DIR=$(mktemp -d)
    export TEST_DIR

    # Charger le script en cours de test
    source "${BATS_TEST_DIRNAME}/../bin/script.sh"
}

teardown() {
    # Nettoyer le répertoire temporaire
    rm -rf "$TEST_DIR"
}

@test "Test using TEST_DIR" {
    touch "$TEST_DIR/file.txt"
    [ -f "$TEST_DIR/file.txt" ]
}

Setup avec ressources

#!/usr/bin/env bats

setup() {
    # Créer la structure du répertoire
    mkdir -p "$TMPDIR/data/input"
    mkdir -p "$TMPDIR/data/output"

    # Créer les fixtures de test
    echo "line1" > "$TMPDIR/data/input/file1.txt"
    echo "line2" > "$TMPDIR/data/input/file2.txt"

    # Initialiser l'environnement
    export DATA_DIR="$TMPDIR/data"
    export INPUT_DIR="$DATA_DIR/input"
    export OUTPUT_DIR="$DATA_DIR/output"
}

teardown() {
    rm -rf "$TMPDIR/data"
}

@test "Processes input files" {
    run my_process_script "$INPUT_DIR" "$OUTPUT_DIR"
    [ "$status" -eq 0 ]
    [ -f "$OUTPUT_DIR/file1.txt" ]
}

Setup/teardown global

#!/usr/bin/env bats

# Charger le setup partagé depuis test_helper.sh
load test_helper

# setup_file s'exécute une fois avant tous les tests
setup_file() {
    export SHARED_RESOURCE=$(mktemp -d)
    echo "Expensive setup" > "$SHARED_RESOURCE/data.txt"
}

# teardown_file s'exécute une fois après tous les tests
teardown_file() {
    rm -rf "$SHARED_RESOURCE"
}

@test "First test uses shared resource" {
    [ -f "$SHARED_RESOURCE/data.txt" ]
}

@test "Second test uses shared resource" {
    [ -d "$SHARED_RESOURCE" ]
}

Motifs de mock et stub

Mock de fonction

#!/usr/bin/env bats

# Mock d'une commande externe
my_external_tool() {
    echo "mocked output"
    return 0
}

@test "Function uses mocked tool" {
    export -f my_external_tool
    run my_function
    [[ "$output" == *"mocked output"* ]]
}

Stub de commande

#!/usr/bin/env bats

setup() {
    # Créer un répertoire de stub
    STUBS_DIR="$TMPDIR/stubs"
    mkdir -p "$STUBS_DIR"

    # Ajouter au PATH
    export PATH="$STUBS_DIR:$PATH"
}

create_stub() {
    local cmd="$1"
    local output="$2"
    local code="${3:-0}"

    cat > "$STUBS_DIR/$cmd" <<EOF
#!/bin/bash
echo "$output"
exit $code
EOF
    chmod +x "$STUBS_DIR/$cmd"
}

@test "Function works with stubbed curl" {
    create_stub curl "{ \"status\": \"ok\" }" 0
    run my_api_function
    [ "$status" -eq 0 ]
}

Stub de variable

#!/usr/bin/env bats

@test "Function handles environment override" {
    export MY_SETTING="override_value"
    run my_function
    [ "$status" -eq 0 ]
    [[ "$output" == *"override_value"* ]]
}

@test "Function uses default when var unset" {
    unset MY_SETTING
    run my_function
    [ "$status" -eq 0 ]
    [[ "$output" == *"default"* ]]
}

Gestion des fixtures

Utilisation de fichiers fixture

#!/usr/bin/env bats

# Répertoire de fixture : tests/fixtures/

setup() {
    FIXTURES_DIR="${BATS_TEST_DIRNAME}/fixtures"
    WORK_DIR=$(mktemp -d)
    export WORK_DIR
}

teardown() {
    rm -rf "$WORK_DIR"
}

@test "Process fixture file" {
    # Copier la fixture vers le répertoire de travail
    cp "$FIXTURES_DIR/input.txt" "$WORK_DIR/input.txt"

    # Exécuter la fonction
    run my_process_function "$WORK_DIR/input.txt"

    # Comparer la sortie
    diff "$WORK_DIR/output.txt" "$FIXTURES_DIR/expected_output.txt"
}

Génération dynamique de fixture

#!/usr/bin/env bats

generate_fixture() {
    local lines="$1"
    local file="$2"

    for i in $(seq 1 "$lines"); do
        echo "Line $i content" >> "$file"
    done
}

@test "Handle large input file" {
    generate_fixture 1000 "$TMPDIR/large.txt"
    run my_function "$TMPDIR/large.txt"
    [ "$status" -eq 0 ]
    [ "$(wc -l < "$TMPDIR/large.txt")" -eq 1000 ]
}

Motifs avancés

Test des conditions d'erreur

#!/usr/bin/env bats

@test "Function fails with missing file" {
    run my_function "/nonexistent/file.txt"
    [ "$status" -ne 0 ]
    [[ "$output" == *"not found"* ]]
}

@test "Function fails with invalid input" {
    run my_function ""
    [ "$status" -ne 0 ]
}

@test "Function fails with permission denied" {
    touch "$TMPDIR/readonly.txt"
    chmod 000 "$TMPDIR/readonly.txt"
    run my_function "$TMPDIR/readonly.txt"
    [ "$status" -ne 0 ]
    chmod 644 "$TMPDIR/readonly.txt"  # Cleanup
}

@test "Function provides helpful error message" {
    run my_function --invalid-option
    [ "$status" -ne 0 ]
    [[ "$output" == *"Usage:"* ]]
}

Test avec dépendances

#!/usr/bin/env bats

setup() {
    # Vérifier la présence des outils requis
    if ! command -v jq &>/dev/null; then
        skip "jq is not installed"
    fi

    export SCRIPT="${BATS_TEST_DIRNAME}/../bin/script.sh"
}

@test "JSON parsing works" {
    skip_if ! command -v jq &>/dev/null
    run my_json_parser '{"key": "value"}'
    [ "$status" -eq 0 ]
}

Test de compatibilité shell

#!/usr/bin/env bats

@test "Script works in bash" {
    bash "${BATS_TEST_DIRNAME}/../bin/script.sh" arg1
}

@test "Script works in sh (POSIX)" {
    sh "${BATS_TEST_DIRNAME}/../bin/script.sh" arg1
}

@test "Script works in dash" {
    if command -v dash &>/dev/null; then
        dash "${BATS_TEST_DIRNAME}/../bin/script.sh" arg1
    else
        skip "dash not installed"
    fi
}

Exécution parallèle

#!/usr/bin/env bats

@test "Multiple independent operations" {
    run bash -c 'for i in {1..10}; do
        my_operation "$i" &
    done
    wait'
    [ "$status" -eq 0 ]
}

@test "Concurrent file operations" {
    for i in {1..5}; do
        my_function "$TMPDIR/file$i" &
    done
    wait
    [ -f "$TMPDIR/file1" ]
    [ -f "$TMPDIR/file5" ]
}

Motif d'assistant de test

test_helper.sh

#!/usr/bin/env bash

# Charger le script en cours de test
export SCRIPT_DIR="${BATS_TEST_DIRNAME%/*}/bin"

# Utilitaires de test communs
assert_file_exists() {
    if [ ! -f "$1" ]; then
        echo "Expected file to exist: $1"
        return 1
    fi
}

assert_file_equals() {
    local file="$1"
    local expected="$2"

    if [ ! -f "$file" ]; then
        echo "File does not exist: $file"
        return 1
    fi

    local actual=$(cat "$file")
    if [ "$actual" != "$expected" ]; then
        echo "File contents do not match"
        echo "Expected: $expected"
        echo "Actual: $actual"
        return 1
    fi
}

# Créer un répertoire de test temporaire
setup_test_dir() {
    export TEST_DIR=$(mktemp -d)
}

cleanup_test_dir() {
    rm -rf "$TEST_DIR"
}

Intégration avec CI/CD

Flux de travail GitHub Actions

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Install Bats
        run: |
          npm install --global bats

      - name: Run Tests
        run: |
          bats tests/*.bats

      - name: Run Tests with Tap Reporter
        run: |
          bats tests/*.bats --tap | tee test_output.tap

Intégration Makefile

.PHONY: test test-verbose test-tap

test:
    bats tests/*.bats

test-verbose:
    bats tests/*.bats --verbose

test-tap:
    bats tests/*.bats --tap

test-parallel:
    bats tests/*.bats --parallel 4

coverage: test
    # Optional: Generate coverage reports

Bonnes pratiques

  1. Tester une seule chose par test - Principe de responsabilité unique
  2. Utiliser des noms de test descriptifs - Énonce clairement ce qui est testé
  3. Nettoyer après les tests - Toujours supprimer les fichiers temporaires dans teardown
  4. Tester les chemins de succès et d'échec - Ne testez pas que le cas idéal
  5. Mocker les dépendances externes - Isoler l'unité testée
  6. Utiliser des fixtures pour les données complexes - Rend les tests plus lisibles
  7. Exécuter les tests en CI/CD - Détecter les régressions tôt
  8. Tester sur plusieurs dialectes shell - Assurer la portabilité
  9. Garder les tests rapides - Exécuter en parallèle si possible
  10. Documenter la configuration de test complexe - Expliquer les motifs inhabituels

Skills similaires