Unit-Tests für Shell-Skripte mit dem Bash Automated Testing System
Shell-Skripte ohne Tests sind eine Blackbox in der Automatisierung. Das Bash Automated Testing System (BATS) bringt strukturierte Unit-Tests, Fixtures und Mocking in die Shell-Welt – und lässt sich nahtlos in GitHub Actions, GitLab CI und Jenkins integrieren, ohne externe Laufzeitabhängigkeiten.
Inhaltsverzeichnis
- 1. Warum Shell-Skripte getestet werden müssen
- 2. BATS installieren und einrichten
- 3. Den ersten BATS-Test schreiben
- 4. bats-assert und bats-support für lesbare Assertions
- 5. Fixtures und Testdaten organisieren
- 6. Mocking mit bats-mock und Stub-Funktionen
- 7. setup, teardown und Testorganisation
- 8. BATS-Hilfsbibliotheken im Vergleich
- 9. BATS in CI-Pipelines integrieren
- 10. Zusammenfassung
- 11. FAQ
1. Warum Shell-Skripte getestet werden müssen
Shell-Skripte bilden das Rückgrat vieler Automatisierungsinfrastrukturen: Deployments, Backups, Datenmigration, Systemkonfiguration. Doch während für Python, Go oder PHP ausgereifte Test-Frameworks existieren, werden Shell-Skripte in der Praxis kaum systematisch getestet. Stattdessen gilt das Motto „es läuft, bis es nicht mehr läuft" – und wenn es nicht mehr läuft, meistens in der Produktion. BATS, das Bash Automated Testing System, schließt diese Lücke mit einem Test-Framework, das sich natürlich in die Shell-Welt einfügt.
Das Problem mit ungetesteten Shell-Skripten ist nicht nur das Risiko von Fehlern in der Produktion. Refactoring wird gefährlich, weil niemand sicher sein kann, ob eine Änderung das Verhalten unbeabsichtigt geändert hat. Neue Entwickler, die ein bestehendes Skript erweitern sollen, haben keinen Kontrakt, an dem sie sich orientieren können. Regressionen schleichen sich ein, weil die Funktionalität nie formal beschrieben wurde. BATS-Tests lösen all das: Sie dokumentieren das erwartete Verhalten, fangen Regressionen automatisch ab und machen Shell-Skripte refactoring-sicher.
Ein weiteres häufiges Argument gegen das Testen von Shell-Skripten lautet, dass Shell-Skripte „zu klein" oder „zu trivial" seien, um Tests zu rechtfertigen. Die Praxis widerlegt das regelmäßig. Ein Backup-Skript mit 50 Zeilen hat oft ein halbes Dutzend Entscheidungspfade, von denen einige nur unter bestimmten Systembedingungen erreichbar sind. BATS erlaubt es, diese Pfade in einer kontrollierten Testumgebung zu verifizieren, ohne die Produktion zu tangieren.
2. BATS installieren und einrichten
Das moderne bats-core-Projekt ist der aktiv gepflegte Nachfolger des originalen BATS von Sam Stephenson. Die empfohlene Installation erfolgt als Git-Submodul innerhalb des Projekts, damit alle Entwickler und die CI-Pipeline dieselbe Version verwenden. Alternativ steht BATS in den meisten Paketmanagern zur Verfügung: brew install bats-core auf macOS, apt install bats auf Debian/Ubuntu, npm install -g bats plattformübergreifend. Für CI-Umgebungen ist das Git-Submodul-Muster jedoch zuverlässiger, weil es keine externe Abhängigkeit zur Laufzeit benötigt.
Die empfohlene Projektstruktur trennt Tests klar vom Produktionscode. Das Verzeichnis test/ im Projektstamm enthält alle BATS-Testdateien mit der Endung .bats, ein Unterverzeichnis test/fixtures/ für Testdaten und test/helpers/ für wiederverwendbare Hilfsfunktionen. Die drei häufig genutzten Hilfsbibliotheken bats-assert, bats-support und bats-mock werden ebenfalls als Submodule eingebunden. Dieser Aufbau ermöglicht es, den gesamten Test-Stack mit einem einzigen git clone --recurse-submodules-Befehl zu initialisieren.
#!/usr/bin/env bash
# setup-test-environment.sh — Install BATS and helper libraries as git submodules
set -euo pipefail
# Add bats-core and helper libraries as submodules
git submodule add https://github.com/bats-core/bats-core.git test/bats
git submodule add https://github.com/bats-core/bats-support.git test/test_helper/bats-support
git submodule add https://github.com/bats-core/bats-assert.git test/test_helper/bats-assert
git submodule add https://github.com/bats-core/bats-mock.git test/test_helper/bats-mock
git submodule update --init --recursive
# Create directory structure
mkdir -p test/fixtures test/helpers
# Add bats runner script
cat > test/run_tests.sh << 'EOF'
#!/usr/bin/env bash
# Run all BATS tests with TAP output for CI
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/bats/bin/bats" \
--formatter tap \
--report-formatter junit \
--output "${SCRIPT_DIR}/../reports/" \
"${SCRIPT_DIR}"/*.bats
EOF
chmod +x test/run_tests.sh
echo "BATS test environment initialized"
3. Den ersten BATS-Test schreiben
Eine BATS-Testdatei ähnelt auf den ersten Blick einem regulären Bash-Skript, verwendet aber spezielle Syntax für Testfälle. Jeder Test ist eine Funktion, die mit dem Keyword @test eingeleitet wird, gefolgt von einem beschreibenden Namen in Anführungszeichen. Die Assertion erfolgt über den run-Befehl, der den zu testenden Befehl ausführt und Exit-Code sowie Ausgabe in den Variablen $status und $output bereitstellt. Das Prüfen des Exit-Codes und der Ausgabe erfolgt dann mit Standard-Bash-Bedingungen oder den Assertions aus bats-assert.
Der wichtigste Grundsatz beim Schreiben von BATS-Tests ist, dass jeder Test isoliert und idempotent sein muss. Tests dürfen sich nicht auf Seiteneffekte anderer Tests verlassen, und sie müssen nach ihrer Ausführung den Systemzustand wiederherstellen. Dafür bietet BATS die Hooks setup und teardown, die vor bzw. nach jedem einzelnen Test ausgeführt werden. Mit setup_file und teardown_file gibt es seit bats-core 1.2 auch Hooks auf Dateiebene, die einmalig pro Testdatei laufen – ideal für teure Ressourcen wie Docker-Container oder Testdatenbanken.
#!/usr/bin/env bats
# test/validate_config.bats — Tests for the config validation script
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
# Script under test
SUT="${BATS_TEST_DIRNAME}/../bin/validate_config.sh"
setup() {
# Create a temporary directory for each test
export TEST_TMPDIR
TEST_TMPDIR="$(mktemp -d)"
}
teardown() {
# Clean up after each test — always runs, even on failure
rm -rf "${TEST_TMPDIR}"
}
@test "validate_config exits 0 for valid config file" {
cp "${BATS_TEST_DIRNAME}/fixtures/valid_config.env" "${TEST_TMPDIR}/app.env"
run "${SUT}" "${TEST_TMPDIR}/app.env"
assert_success
}
@test "validate_config exits 1 when config file is missing" {
run "${SUT}" "${TEST_TMPDIR}/nonexistent.env"
assert_failure
assert_output --partial "Config file not found"
}
@test "validate_config reports missing required keys" {
cp "${BATS_TEST_DIRNAME}/fixtures/incomplete_config.env" "${TEST_TMPDIR}/app.env"
run "${SUT}" "${TEST_TMPDIR}/app.env"
assert_failure
assert_output --partial "DB_HOST"
assert_output --partial "Required variable missing"
}
@test "validate_config accepts optional keys as absent" {
cp "${BATS_TEST_DIRNAME}/fixtures/minimal_config.env" "${TEST_TMPDIR}/app.env"
run "${SUT}" "${TEST_TMPDIR}/app.env"
assert_success
}
4. bats-assert und bats-support für lesbare Assertions
Die Kernbibliothek bats-core liefert nur run, $status und $output. Mit rohen Bash-Bedingungen wie [ "$status" -eq 0 ] zu arbeiten ist möglich, aber bei Fehlschlägen uninformativ: Man sieht nur, dass der Test fehlgeschlagen ist, nicht was erwartet wurde und was tatsächlich passiert ist. bats-assert ergänzt aussagekräftige Assertion-Funktionen, die bei Fehlschlägen eine strukturierte Fehlermeldung ausgeben und den erwarteten sowie den tatsächlichen Wert gegenüberstellen.
Die wichtigsten Assertions aus bats-assert sind assert_success, assert_failure für Exit-Codes, assert_output und assert_output --partial für die Standardausgabe sowie assert_line für einzelne Zeilen der Ausgabe. Mit refute_output und refute_line lässt sich prüfen, dass bestimmte Inhalte gerade nicht in der Ausgabe auftauchen – nützlich für Sicherheitstests, die sicherstellen sollen, dass sensible Daten nicht geloggt werden. Das Zusammenspiel von bats-support und bats-assert ergibt leserliche, selbstdokumentierende BATS-Tests.
5. Fixtures und Testdaten organisieren
Fixtures sind unveränderliche Testdaten, die einen bekannten Systemzustand für Tests repräsentieren. In der BATS-Welt sind Fixtures typischerweise Konfigurationsdateien, Eingabedaten oder Verzeichnisstrukturen, die im test/fixtures/-Verzeichnis abgelegt werden. BATS stellt dafür die Variable $BATS_TEST_DIRNAME bereit, die auf das Verzeichnis der aktuell laufenden Testdatei zeigt – damit lassen sich Fixtures mit einem relativen Pfad zuverlässig referenzieren, unabhängig davon, von wo BATS gestartet wird.
Für Tests, die Verzeichnisstrukturen benötigen, empfiehlt sich ein setup-Hook, der die Fixture-Daten in ein temporäres Verzeichnis kopiert, bevor der Test läuft. Das temporäre Verzeichnis wird mit mktemp -d erstellt, in einer exportierten Variable gespeichert und im teardown-Hook mit rm -rf wieder gelöscht. So sind Tests vollständig isoliert: Jeder Test startet mit einem sauberen, definierten Dateisystemzustand. Die Variable $BATS_TMPDIR zeigt auf ein BATS-verwaltetes temporäres Verzeichnis, das am Ende des Test-Runs automatisch bereinigt wird.
#!/usr/bin/env bats
# test/backup_script.bats — Integration tests for backup.sh using fixtures
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
load 'helpers/mock_helpers'
SUT="${BATS_TEST_DIRNAME}/../bin/backup.sh"
FIXTURES="${BATS_TEST_DIRNAME}/fixtures"
setup_file() {
# One-time setup: create fixture directory tree (runs once per file)
export FIXTURE_DATA_DIR="${BATS_TMPDIR}/fixture_data"
mkdir -p "${FIXTURE_DATA_DIR}"/{docs,logs,config}
echo "important document" > "${FIXTURE_DATA_DIR}/docs/report.txt"
echo "error entry" > "${FIXTURE_DATA_DIR}/logs/app.log"
echo "DB_HOST=localhost" > "${FIXTURE_DATA_DIR}/config/app.env"
}
setup() {
# Per-test setup: fresh backup target directory
export BACKUP_TARGET
BACKUP_TARGET="$(mktemp -d)"
export BACKUP_SOURCE="${FIXTURE_DATA_DIR}"
}
teardown() {
rm -rf "${BACKUP_TARGET}"
}
@test "backup creates archive with correct name pattern" {
run "${SUT}" --source "${BACKUP_SOURCE}" --target "${BACKUP_TARGET}"
assert_success
# Verify archive naming: backup-YYYY-MM-DD.tar.gz
run bash -c "ls '${BACKUP_TARGET}' | grep -E '^backup-[0-9]{4}-[0-9]{2}-[0-9]{2}\.tar\.gz$'"
assert_success
}
@test "backup preserves all source files in archive" {
run "${SUT}" --source "${BACKUP_SOURCE}" --target "${BACKUP_TARGET}"
assert_success
local archive
archive="$(ls "${BACKUP_TARGET}"/*.tar.gz | head -1)"
run tar -tzf "${archive}"
assert_output --partial "docs/report.txt"
assert_output --partial "logs/app.log"
assert_output --partial "config/app.env"
}
6. Mocking mit bats-mock und Stub-Funktionen
Das größte Hindernis beim Testen von Shell-Skripten sind externe Abhängigkeiten: Datenbankbefehle, Cloud-CLIs, Mail-Programme, systemd-Kommandos. Ein Test, der wirklich eine E-Mail sendet oder eine Datenbank anlegt, ist kein Unit-Test mehr, sondern ein Integrationstest mit unerwünschten Seiteneffekten. bats-mock löst dieses Problem mit einem Stub-Mechanismus, der externe Befehle durch kontrollierte Fakes ersetzt, die festgelegte Exit-Codes und Ausgaben liefern.
Die einfachere Alternative zu bats-mock sind Shell-Funktionen, die gleichnamige externe Befehle im Test-Scope überschreiben. Da Bash zuerst Funktionen vor External Commands sucht, wird durch mail() { echo "mock: $*"; } in der setup-Funktion jeder mail-Aufruf im getesteten Skript an die Mock-Funktion weitergeleitet. Diese Technik funktioniert zuverlässig für einfache Fälle. Für komplexere Szenarien – verschiedene Rückgaben je nach Aufrufnummer oder Prüfung, wie oft ein Befehl aufgerufen wurde – bietet bats-mock mit stub und unstub einen ausdrucksstärkeren Ansatz.
#!/usr/bin/env bats
# test/notify.bats — Tests with mocked external commands
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
load 'test_helper/bats-mock/stub'
SUT="${BATS_TEST_DIRNAME}/../bin/notify.sh"
setup() {
# Stub 'curl' to simulate HTTP API call without network access
stub curl \
'--silent --fail -X POST * : echo "mock curl: notification sent"; exit 0'
# Stub 'mail' for email notifications
stub mail \
'-s * * : echo "mock mail: subject=$2 recipient=$3"; exit 0'
}
teardown() {
unstub curl
unstub mail
}
@test "notify sends HTTP request with correct payload on failure" {
run "${SUT}" --event "deploy_failed" --environment "production" --notify http
assert_success
assert_output --partial "mock curl: notification sent"
}
@test "notify sends email when mail transport is selected" {
run "${SUT}" --event "backup_done" --environment "staging" --notify mail
assert_success
assert_output --partial "mock mail"
}
@test "notify exits 1 when unknown transport is specified" {
run "${SUT}" --event "test" --notify "unknown_transport"
assert_failure
assert_output --partial "Unknown notification transport"
}
7. setup, teardown und Testorganisation
Die Hooks setup und teardown sind das Herzstück sauberer BATS-Testorganisation. setup läuft vor jedem einzelnen Test und bereitet den Zustand vor, den der Test voraussetzt. teardown läuft nach jedem Test, auch wenn der Test fehlgeschlagen ist – das ist entscheidend, damit temporäre Ressourcen auch bei Fehlschlägen bereinigt werden. Ohne teardown häufen sich temporäre Dateien und Prozesse an, die spätere Tests beeinflussen können.
Für größere Testsuiten mit vielen Dateien empfiehlt sich eine gemeinsame test/helpers/-Bibliothek, die wiederkehrende Setup-Logik zusammenfasst. Diese Hilfsdatei wird in jede Testdatei per load 'helpers/common' eingebunden. Das BATS-Kommando skip markiert einzelne Tests als übersprungen, wenn bestimmte Voraussetzungen fehlen – beispielsweise wenn Docker nicht installiert ist oder ein bestimmter Port nicht erreichbar ist. BATS gibt übersprungene Tests in der Ausgabe deutlich an, ohne den Gesamtstatus auf Fehler zu setzen.
Mit dem Flag --filter lassen sich beim Aufruf von BATS gezielt einzelne Tests oder Testgruppen nach Name ausführen. Das ist in der Entwicklung nützlich, um während der Arbeit an einem Feature nur die relevanten Tests laufen zu lassen, ohne die gesamte Testsuite warten zu müssen. Für die CI-Pipeline hingegen sollte immer die vollständige Suite laufen, ergänzt um das Flag --timing, das die Laufzeit jedes Tests anzeigt und langsame Tests identifiziert.
8. BATS-Hilfsbibliotheken im Vergleich
Das BATS-Ökosystem bietet mehrere Hilfsbibliotheken für unterschiedliche Testanforderungen. Die Wahl der richtigen Kombination hängt von der Komplexität der zu testenden Skripte ab.
| Bibliothek | Zweck | Wichtigste Funktionen | Empfehlung |
|---|---|---|---|
| bats-core | Test-Runner | run, $status, $output, $lines[] |
Immer einbinden |
| bats-support | Ausgabe-Formatierung | fail, farbige Diff-Ausgabe bei Fehlern |
Immer einbinden |
| bats-assert | Assertions | assert_success/failure, assert_output, assert_line |
Immer einbinden |
| bats-mock | Command Stubs | stub, unstub, sequentielle Rückgaben |
Bei externen Abhängigkeiten |
| bats-file | Datei-Assertions | assert_file_exists, assert_dir_empty |
Bei dateioperation-intensiven Skripten |
Die Kombination aus bats-core, bats-support und bats-assert deckt den Großteil der Testanforderungen ab. bats-mock kommt hinzu, sobald externe Prozesse wie Cloud-CLIs, Mailer oder Datenbank-Clients im zu testenden Skript aufgerufen werden. bats-file ergänzt die Suite, wenn Skripte primär Dateisystemoperationen durchführen und der Test explizit prüfen soll, ob bestimmte Dateien angelegt, geändert oder gelöscht wurden.
9. BATS in CI-Pipelines integrieren
Die Integration von BATS in CI-Pipelines ist unkompliziert, weil BATS mit dem TAP-Format (Test Anything Protocol) und JUnit-XML zwei universelle Ausgabeformate unterstützt. TAP wird von nahezu allen CI-Systemen verstanden, JUnit-XML ist der Standard für Testergebnisse in GitHub Actions, GitLab CI und Jenkins. Mit dem Flag --formatter tap oder --formatter junit wählt man das gewünschte Format. Das JUnit-Format ermöglicht es, BATS-Testergebnisse direkt als Testreport in der CI-UI anzuzeigen und fehlgeschlagene Tests auf einen Blick zu erkennen.
Für GitHub Actions empfiehlt sich die Verwendung der offiziellen BATS-Action oder ein simpler run: ./test/bats/bin/bats test/-Step. Die Submodule müssen mit submodules: recursive im checkout-Step ausgecheckt werden. Für GitLab CI funktioniert dasselbe Muster im Script-Block eines Jobs. Wichtig: Den BATS-Step in eine eigene Job-Stage einordnen, die nach dem Linting (ShellCheck) aber vor dem Deploy läuft. So bildet die CI-Pipeline eine klare Qualitätspforte: kein Deploy ohne bestandene BATS-Tests.
# .github/workflows/shell-tests.yml — GitHub Actions workflow for BATS tests
name: Shell Script Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Run ShellCheck on all scripts
run: |
find bin/ -type f -name "*.sh" -print0 \
| xargs -0 shellcheck -S warning
bats-tests:
runs-on: ubuntu-latest
needs: shellcheck
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Run BATS test suite
run: |
mkdir -p reports
./test/bats/bin/bats \
--formatter junit \
--output reports/ \
test/*.bats
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: bats-test-results
path: reports/
- name: Publish Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: reports/*.xml
Mironsoft
Shell-Testing, CI/CD-Automatisierung und DevOps-Infrastruktur
Shell-Skripte mit BATS absichern?
Wir bauen BATS-Testsuiten für eure Shell-Skripte auf, integrieren ShellCheck und BATS in eure CI-Pipeline und stellen sicher, dass kritische Automatisierungsskripte durch Tests abgedeckt und refactoring-sicher sind.
Test-Aufbau
BATS-Testsuiten für bestehende Shell-Skripte aufbauen, Fixtures und Mocks einrichten
CI-Integration
BATS in GitHub Actions, GitLab CI und Jenkins als Qualitätspforte vor dem Deploy integrieren
Schulung
Team im Test-First-Ansatz für Shell-Skripte trainieren und BATS-Best-Practices etablieren
10. Zusammenfassung
Das Bash Automated Testing System bringt strukturierte, automatisierte Tests in die Shell-Welt und schließt damit eine der größten Lücken in modernen Automatisierungsinfrastrukturen. BATS-Tests dokumentieren das erwartete Verhalten von Shell-Skripten, fangen Regressionen automatisch ab und machen Refactoring planbar. Die Kombination aus bats-core, bats-assert und bats-mock deckt Unit-Tests, Assertions und das Isolieren externer Abhängigkeiten vollständig ab. Fixtures und die setup/teardown-Hooks sorgen für Test-Isolation und reproduzierbare Ergebnisse.
Die Integration von BATS in CI-Pipelines ist mit wenigen Zeilen YAML erledigt und liefert JUnit-XML-Berichte, die jedes gängige CI-System versteht. Der ROI zeigt sich schnell: Jede Stunde, die in BATS-Tests investiert wird, zahlt sich durch verhinderte Produktionsfehler und sichereres Refactoring mehrfach aus. Shell-Skripte ohne BATS-Tests sind kein Standard für produktionsreife Automatisierung mehr.
BATS für Bash-Tests in CI — Das Wichtigste auf einen Blick
Kernkonzept
@test "beschreibung" { run cmd; assert_success; } — jeder Test ist eine Funktion, run fängt Output und Exit-Code auf.
Isolation
setup und teardown laufen vor/nach jedem Test. mktemp -d + rm -rf garantiert saubere Testumgebung.
Mocking
Shell-Funktionen überschreiben externe Befehle im Test-Scope. bats-mock für komplexe Stub-Szenarien mit sequentiellen Rückgaben.
CI-Output
--formatter junit erzeugt XML-Reports für GitHub Actions, GitLab CI und Jenkins. ShellCheck + BATS als Qualitätspforte vor dem Deploy.