BATS · Shell-Testing · CI/CD · Bash
BATS für Bash-Tests in CI einsetzen
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.

15 Min. Lesezeit bats-core · bats-assert · bats-mock · Fixtures · CI-Integration Bash 4.x · 5.x · GitHub Actions · GitLab CI

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.

11. FAQ: BATS für Bash-Tests in CI einsetzen

1Was ist BATS?
BATS (Bash Automated Testing System) ist ein Test-Framework für Shell-Skripte. Tests werden mit @test definiert, run führt Befehle aus, $status und $output enthalten Ergebnis und Ausgabe.
2Wie installiere ich BATS?
Als Git-Submodul: git submodule add https://github.com/bats-core/bats-core.git test/bats. Damit nutzen Entwickler und CI dieselbe Version ohne externe Laufzeitabhängigkeit.
3Wie mocke ich externe Befehle?
Shell-Funktion gleichen Namens im setup-Hook definieren – Bash findet Funktionen vor externen Befehlen. Für komplexere Szenarien: bats-mock mit stub/unstub.
4setup vs. setup_file?
setup läuft vor jedem einzelnen Test. setup_file einmalig vor allen Tests der Datei. setup_file für teure Ressourcen, setup für leichtgewichtige Per-Test-Isolation.
5BATS in GitHub Actions?
checkout mit submodules: recursive, dann bats --formatter junit --output reports/ test/*.bats. JUnit-XML-Reports als Artefakt hochladen und als PR-Annotations anzeigen.
6Was sind BATS-Fixtures?
Unveränderliche Testdaten in test/fixtures/. $BATS_TEST_DIRNAME zeigt auf das Testverzeichnis für portable Fixture-Pfade. In setup ins mktemp-Verzeichnis kopieren.
7Skripte mit root-Rechten testen?
[[ $EUID -ne 0 ]] && skip 'Root required' überspringt den Test ohne Fehler. Besser: root-Operationen in separate Funktionen auslagern und mocken.
8Umgebungsvariablen in Tests?
Im setup-Hook exportieren: export DB_HOST=localhost. Im teardown mit unset bereinigen. Für Tests mit fehlenden Variablen: im Test unset aufrufen, dann assert_failure prüfen.
9assert_output --partial?
Prüft Substring-Match. assert_output 'text' für exakte Übereinstimmung. assert_output --regexp 'pattern' für Regex. refute_output prüft, dass etwas nicht in der Ausgabe steht.
10Wie viele Tests braucht ein Skript?
Mindestens ein Test pro Entscheidungspfad und je ein Test für Fehlerfälle. Kritische Skripte (Backup, Deployment) sollten 100% ihrer Entscheidungspfade abgedeckt haben.