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