Unit-Tests fuer Bash-Funktionen schreiben und strukturieren
AI generated
$_
#!/
Bash · Unit-Tests · bats-core · Testing
Unit-Tests fuer Bash-Funktionen
reine Funktionen schreiben, strukturieren und dauerhaft absichern

Wer Bash-Skripte als monolithischen Block schreibt, kann sie kaum sinnvoll testen. Erst die Trennung in reine, isolierte Funktionen macht Unit-Tests fuer Bash ueberhaupt moeglich, und mit bats-core lassen sich Rueckgabewerte, stdout und Fehlerpfade genauso strukturiert pruefen wie in jeder anderen Programmiersprache.

19 Min. Lesezeit bats-core · Reine Funktionen · Assertions · Table-Driven Tests Bash 4.x · 5.x · Linux · macOS

1. Warum reine Funktionen die Grundlage testbarer Bash-Skripte sind

Die meisten Bash-Skripte werden als linearer Ablauf geschrieben: Variablen setzen, Befehle ausfuehren, Dateien schreiben, alles in einer Datei von oben nach unten. Genau diese Struktur macht Unit-Tests fuer Bash-Funktionen schwierig, weil sich einzelne Schritte nicht isoliert ausfuehren lassen, ohne Seiteneffekte wie Netzwerkzugriffe oder Dateisystemaenderungen auszuloesen. Eine reine Funktion hingegen liest ausschliesslich ihre Argumente, produziert eine Ausgabe oder einen Rueckgabewert und veraendert keinen globalen Zustand außerhalb ihres eigenen Scopes.

Der entscheidende Vorteil reiner Funktionen fuer Unit-Tests in Bash: Sie lassen sich beliebig oft mit unterschiedlichen Eingaben aufrufen, ohne dass vorher eine Datenbank aufgesetzt, ein Netzwerkdienst gestartet oder ein Dateisystem in einen bestimmten Zustand gebracht werden muss. Eine Funktion wie parse_version_string, die einen String entgegennimmt und eine normalisierte Version zurueckgibt, laesst sich in Millisekunden hunderte Male testen. Eine Funktion, die stattdessen direkt curl aufruft und das Ergebnis verarbeitet, verwischt Logik und Seiteneffekt und macht echte Unit-Tests praktisch unmoeglich.

Der Aufbau testbarer Bash-Funktionen beginnt daher nicht beim Schreiben der Tests, sondern beim Design des Skripts selbst. Wer von Anfang an reine Logik von Seiteneffekten trennt, kommt spaeter mit deutlich weniger Aufwand zu einer vollstaendigen Testsuite. Die folgenden Abschnitte zeigen, wie diese Trennung konkret aussieht und wie bats-core als etabliertes Test-Framework die Struktur fuer wartbare Bash-Unit-Tests liefert.


# lib/version.sh — pure functions, no side effects

# Pure function: takes a string, returns a normalized version, no I/O
normalize_version() {
  local raw="$1"
  # Strip leading "v" and trailing whitespace
  local cleaned="${raw#v}"
  cleaned="${cleaned%% }"
  echo "$cleaned"
}

# Pure function: compares two version strings, returns exit code only
version_is_greater() {
  local v1="$1"
  local v2="$2"
  [[ "$(printf '%s\n%s' "$v1" "$v2" | sort -V | tail -n1)" == "$v1" ]]
}

2. Bibliothek und Einstiegspunkt trennen

Fuer Unit-Tests in Bash ist die Trennung zwischen Bibliotheksdatei und ausfuehrbarem Einstiegspunkt entscheidend. Alle testbaren Funktionen wandern in eine Datei wie lib/functions.sh, die keine eigene Ausfuehrungslogik enthaelt, sondern nur Funktionsdefinitionen. Das eigentliche Skript, das diese Funktionen mit echten Argumenten aufruft und Seiteneffekte ausloest, bleibt in einer separaten Datei. Ein Testskript kann dann die Bibliothek per source einbinden, ohne das Hauptskript und seine Seiteneffekte jemals auszufuehren.

Das Bash-Pattern dafuer am Dateiende jedes Hauptskripts: [[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@". Diese Zeile fuehrt die main-Funktion nur aus, wenn das Skript direkt aufgerufen wird, nicht wenn es per source eingebunden wird. Damit kann dieselbe Datei sowohl als eigenstaendiges Skript laufen als auch von einer Testsuite eingebunden werden, ohne dass main versehentlich ausgefuehrt wird und echte Seiteneffekte ausloest.


#!/usr/bin/env bash
# deploy.sh — entry point, sources the testable library
set -euo pipefail

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/functions.sh"

main() {
  local target_version="$1"
  local normalized
  normalized="$(normalize_version "$target_version")"
  echo "Deploying version: $normalized"
  # ... actual deployment side effects happen only here
}

# Only run main() when executed directly, not when sourced by a test
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@"

Dieses Muster ist die Grundvoraussetzung fuer Unit-Tests fuer Bash-Funktionen ueberhaupt: Ohne diese Trennung wuerde jedes Sourcen des Skripts in einem Testlauf sofort das komplette Deployment ausloesen, inklusive aller Netzwerkaufrufe und Dateisystemaenderungen. Mit der Trennung bleibt lib/functions.sh vollstaendig frei von Seiteneffekten und kann gefahrlos in jeder Testsuite eingebunden werden.

3. Testfaelle mit bats-core strukturieren

bats-core ist das etablierte Test-Framework fuer Bash-Unit-Tests und bringt eine an andere Testframeworks angelehnte Syntax mit: Jeder Testfall beginnt mit dem Schluesselwort @test, gefolgt von einer beschreibenden Zeichenkette und einem Codeblock. Innerhalb dieses Blocks fuehrt run die zu testende Funktion aus und faengt sowohl den Exit-Code als auch die komplette Ausgabe in den Variablen $status und $output ab, ohne dass das Testskript selbst abbricht, falls die Funktion einen Fehler zurueckgibt.

Die Struktur einer bats-core-Testdatei folgt einem klaren Muster: setup() laeuft vor jedem einzelnen Testfall und laedt typischerweise die zu testende Bibliothek per source. teardown() laeuft nach jedem Testfall und raeumt temporaere Ressourcen auf. Diese Struktur sorgt dafuer, dass jeder Testfall isoliert und unabhaengig von der Ausfuehrungsreihenfolge der anderen Tests laeuft, ein zentrales Prinzip fuer verlaessliche Unit-Tests in Bash.


#!/usr/bin/env bats
# test/version.bats

setup() {
  load "../lib/functions.sh"
  export LIB_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
}

@test "normalize_version strips leading v prefix" {
  run normalize_version "v2.4.1"
  [ "$status" -eq 0 ]
  [ "$output" = "2.4.1" ]
}

@test "normalize_version handles version without v prefix" {
  run normalize_version "2.4.1"
  [ "$status" -eq 0 ]
  [ "$output" = "2.4.1" ]
}

@test "version_is_greater returns success for a higher version" {
  run version_is_greater "2.5.0" "2.4.1"
  [ "$status" -eq 0 ]
}

@test "version_is_greater returns failure for a lower version" {
  run version_is_greater "2.3.0" "2.4.1"
  [ "$status" -eq 1 ]
}

Ein wichtiges Detail: run startet die aufgerufene Funktion in einer Subshell, was bedeutet, dass Aenderungen an Umgebungsvariablen innerhalb der getesteten Funktion den Testkontext nicht beeinflussen. Das ist fuer Unit-Tests fuer Bash-Funktionen meist erwuenscht, kann aber ueberraschen, wenn ein Test bewusst prueft, ob eine Funktion eine Variable im Aufrufer-Scope veraendert, etwa ueber eine Namenreferenz mit local -n.

4. Assertions: Rueckgabewerte, stdout und stderr pruefen

Ein solider Satz an Assertions ist das Rueckgrat jeder Testsuite. Fuer Unit-Tests in Bash mit bats-core sind drei Dinge relevant: der Exit-Code in $status, die kombinierte stdout- und stderr-Ausgabe in $output, und bei Bedarf einzelne Zeilen der Ausgabe im Array ${lines[@]}. Reine Vergleiche mit doppelten eckigen Klammern reichen fuer die meisten Faelle aus, aber die Bibliothek bats-assert ergaenzt lesbarere Assertion-Funktionen wie assert_output und assert_failure.

Ein haeufiger Fehler bei Unit-Tests fuer Bash-Funktionen: stdout und stderr werden standardmaessig zusammen in $output zusammengefuehrt. Wenn eine Funktion sowohl regulaere Ausgabe als auch Fehlermeldungen produziert, muessen diese fuer praezise Assertions getrennt erfasst werden, etwa indem die Funktion in der Testumgebung mit expliziter Umleitung aufgerufen wird oder indem bats im Modus mit separaten Streams konfiguriert wird.


#!/usr/bin/env bats
load "test_helper/bats-assert/load"
load "test_helper/bats-support/load"

setup() {
  load "../lib/functions.sh"
}

@test "sanitize_filename replaces unsafe characters" {
  run sanitize_filename "my file (v2).txt"
  assert_success
  assert_output "my_file__v2_.txt"
}

@test "sanitize_filename fails on empty input with clear error" {
  run sanitize_filename ""
  assert_failure
  assert_output --partial "filename must not be empty"
}

@test "parse_csv_line splits into exactly three fields" {
  run parse_csv_line "alice,42,berlin"
  assert_success
  assert_equal "${#lines[@]}" 3
  assert_equal "${lines[1]}" "42"
}

Die Kombination aus assert_success/assert_failure fuer den Exit-Code und assert_output/assert_output --partial fuer den Inhalt deckt die meisten Testfaelle ab. Fuer numerische Vergleiche liefert assert_equal klarere Fehlermeldungen als die rohe [[ ]]-Syntax, was besonders bei Table-Driven Tests mit vielen Testfaellen den Ueberblick erleichtert, wenn tatsaechlich einer fehlschlaegt.

5. Table-Driven Tests fuer viele Eingabe-Kombinationen

Sobald eine Funktion mehr als zwei oder drei Testfaelle rechtfertigt, lohnt sich ein Table-Driven-Ansatz: Eine Liste von Eingabe-Erwartungs-Paaren wird in einer Schleife durchlaufen, statt fuer jede Kombination einen eigenen @test-Block zu schreiben. Das reduziert Code-Duplikation drastisch und macht neue Testfaelle zu einer einzeiligen Ergaenzung statt eines kompletten neuen Testblocks.

In bats-core realisiert man Table-Driven Tests am saubersten mit einem assoziativen Array oder einer Reihe von durch Trennzeichen separierten Strings, ueber die eine Schleife innerhalb eines einzigen @test-Blocks iteriert. Schlaegt einer der Faelle fehl, gibt bats-core den konkreten Eingabewert in der Fehlermeldung aus, sodass sofort klar ist, welche Kombination das Problem verursacht hat, ohne durch dutzende einzelne Testfunktionen suchen zu muessen.


#!/usr/bin/env bats
load "test_helper/bats-assert/load"

setup() {
  load "../lib/functions.sh"
}

@test "normalize_version handles many input formats correctly" {
  local -a cases=(
    "v2.4.1|2.4.1"
    "2.4.1|2.4.1"
    "V2.4.1|V2.4.1"
    "v10.0.0|10.0.0"
    " v1.0.0 |1.0.0"
  )

  for case in "${cases[@]}"; do
    local input="${case%%|*}"
    local expected="${case##*|}"

    run normalize_version "$input"
    assert_success
    assert_equal "$output" "$expected" \
      "Failed for input: '$input' (expected '$expected', got '$output')"
  done
}

Ein wichtiger Hinweis zum letzten Testfall in der Tabelle: V2.4.1 mit grossem V wird bewusst nicht normalisiert, weil normalize_version nur ein kleines v als Praefix entfernt. Genau solche Grenzfaelle in einer Tabelle sichtbar zu machen, ist der eigentliche Wert von Table-Driven Tests: Man sieht auf einen Blick, welches Verhalten fuer welche Eingabe erwartet wird, statt es aus verstreuten Einzeltests zusammenzusuchen.

6. Edge Cases: leere Eingaben und Sonderzeichen

Die wertvollsten Unit-Tests fuer Bash-Funktionen sind selten die, die den Normalfall pruefen, sondern die, die Grenzfaelle abdecken: eine leere Zeichenkette, ein String mit Leerzeichen, Anfuehrungszeichen oder Sonderzeichen wie Backslashes, negative Zahlen bei numerischen Funktionen, oder ein Array mit genau null Elementen. Bash-spezifische Quoting-Probleme treten fast ausschliesslich bei diesen Grenzfaellen zutage, waehrend der Normalfall meist auch bei fehlerhaftem Quoting zufaellig funktioniert.

Ein besonders wichtiger Edge Case fuer Bash-Unit-Tests: Eingaben, die wie Shell-Metazeichen aussehen, etwa ein Dateiname mit einem eingebetteten $(...) oder Backtick. Eine Funktion, die intern eval verwendet oder Variablen unquotiert weiterreicht, kann bei solchen Eingaben zu ungewollter Kommando-Ausfuehrung fuehren. Ein Testfall, der genau diesen Eingabewert prueft, deckt Sicherheitsluecken auf, die im taeglichen Gebrauch mit harmlosen Eingaben nie sichtbar wuerden.


#!/usr/bin/env bats
load "test_helper/bats-assert/load"

setup() {
  load "../lib/functions.sh"
}

@test "sanitize_filename rejects empty input" {
  run sanitize_filename ""
  assert_failure
}

@test "sanitize_filename handles a string with only special characters" {
  run sanitize_filename "!!!???"
  assert_success
  assert_output "______"
}

@test "sanitize_filename does not execute embedded command substitution" {
  run sanitize_filename '$(rm -rf /tmp/should-not-run)'
  assert_success
  refute_output --partial "should-not-run"
  # The literal string is sanitized, never evaluated as a command
}

@test "sum_array handles an empty array without error" {
  local -a empty=()
  run sum_numbers "${empty[@]}"
  assert_success
  assert_output "0"
}

Der dritte Testfall im Beispiel ist besonders lehrreich: Er stellt sicher, dass eine potenziell gefaehrliche Eingabe niemals als Befehl interpretiert wird, sondern ausschliesslich als Zeichenkette behandelt wird. Solche Tests sollten fester Bestandteil jeder Testsuite fuer Funktionen sein, die Benutzereingaben oder externe Daten verarbeiten, unabhaengig davon, wie unwahrscheinlich ein Angriff im konkreten Anwendungsfall erscheint.

7. Testabdeckung von Fehlerpfaden und Exit-Codes

Ein oft vernachlaessigter Bereich bei Unit-Tests in Bash ist die gezielte Pruefung von Fehlerpfaden. Es reicht nicht, nur den Erfolgsfall zu testen, wenn eine Funktion mehrere unterschiedliche Fehlerursachen mit unterschiedlichen Exit-Codes signalisiert. Eine Funktion, die Datei nicht gefunden mit Exit-Code 2 und ungueltiges Format mit Exit-Code 3 meldet, sollte fuer beide Faelle einen eigenen Testfall haben, der genau den erwarteten Code prueft, nicht nur einen generischen Fehlschlag.

Fuer Funktionen, die auf externe Ressourcen wie Dateien zugreifen, hilft BATS_TMPDIR, das bats-core automatisch fuer jeden Testlauf bereitstellt. Damit lassen sich Fehlerpfade wie fehlende Dateien oder Berechtigungsprobleme reproduzierbar erzeugen, ohne das echte Dateisystem des Testrechners zu beeinflussen. Nach jedem Test sollte teardown() diese temporaeren Dateien wieder entfernen, damit Testlaeufe sich nicht gegenseitig durch Ueberreste vorheriger Faelle beeinflussen.


#!/usr/bin/env bats
load "test_helper/bats-assert/load"

setup() {
  load "../lib/functions.sh"
  TEST_DIR="$(mktemp -d "${BATS_TMPDIR}/config-test.XXXXXX")"
}

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

@test "load_config returns exit code 2 when file is missing" {
  run load_config "$TEST_DIR/does-not-exist.conf"
  assert_equal "$status" 2
  assert_output --partial "config file not found"
}

@test "load_config returns exit code 3 on invalid format" {
  echo "this is not valid config syntax" > "$TEST_DIR/broken.conf"
  run load_config "$TEST_DIR/broken.conf"
  assert_equal "$status" 3
  assert_output --partial "invalid config format"
}

@test "load_config succeeds and returns exit code 0 on valid file" {
  echo "key=value" > "$TEST_DIR/valid.conf"
  run load_config "$TEST_DIR/valid.conf"
  assert_success
}

Diese Art von Test macht Exit-Codes zu einer verlaesslichen API zwischen Funktion und Aufrufer, statt zu einem zufaelligen Nebenprodukt. Wenn spaeter ein Aufrufer anhand des Exit-Codes zwischen unterschiedlichen Fehlerbehandlungsstrategien unterscheidet, stellt die Testsuite sicher, dass sich diese Codes bei zukuenftigen Aenderungen der Funktion nicht unbemerkt verschieben.

8. Unit-Tests lokal und in Pre-Commit-Hooks integrieren

Damit Unit-Tests fuer Bash-Funktionen tatsaechlich einen Nutzen entfalten, muessen sie regelmaessig ausgefuehrt werden, idealerweise vor jedem Commit. Ein Pre-Commit-Hook mit bats test/ als Aufruf verhindert, dass fehlerhafter Code ueberhaupt ins Repository gelangt. Da bats-Tests typischerweise in Millisekunden bis wenige Sekunden laufen, ist der Zusatzaufwand pro Commit meist vernachlaessigbar, selbst bei einer wachsenden Testsuite mit hunderten Testfaellen.

Fuer die lokale Entwicklung empfiehlt sich zusaetzlich ein Watch-Modus, der Tests automatisch bei jeder Dateiaenderung neu ausfuehrt, etwa ueber entr in Kombination mit find lib/ test/ -name "*.sh" -o -name "*.bats" | entr bats test/. Das verkuerzt den Feedback-Zyklus beim Schreiben neuer Funktionen erheblich, da Testergebnisse sofort sichtbar sind, ohne den Testlauf manuell anzustossen.


#!/usr/bin/env bash
# .git/hooks/pre-commit — run bats tests before every commit
set -euo pipefail

echo "Running Bash unit tests before commit..."

if ! bats test/ 2>&1; then
  echo "[ERROR] Unit tests failed. Commit aborted." >&2
  exit 1
fi

echo "All unit tests passed."
exit 0

In CI-Pipelines wird derselbe bats test/-Aufruf ergaenzt um eine TAP-konforme Ausgabe (bats --tap test/), die sich direkt in gaengige CI-Systeme wie GitLab CI und GitHub Actions integrieren laesst und dort als strukturierte Testergebnisse angezeigt wird, statt nur als rohe Konsolenausgabe.

9. Unit-Tests im Vergleich zu Integrationstests fuer Bash

Unit-Tests fuer reine Funktionen und Integrationstests fuer komplette Skripte erfuellen unterschiedliche Zwecke und sollten sich gegenseitig ergaenzen, nicht ersetzen.

Kriterium Unit-Tests (reine Funktionen) Integrationstests (ganzes Skript)
Laufzeit Millisekunden pro Test Sekunden bis Minuten pro Test
Isolation Vollstaendig, keine Seiteneffekte Benoetigt echte Umgebung oder Docker
Fehlerlokalisierung Sehr praezise, eine Funktion Grob, ganzes Skript betroffen
Setup-Aufwand Gering, kein externer Zustand Hoch, Mocks oder echte Dienste noetig
Deckt ab Logikfehler in einzelnen Funktionen Zusammenspiel und Seiteneffekte

In der Praxis bilden Unit-Tests fuer reine Funktionen die breite, schnelle Testbasis, waehrend eine kleinere Anzahl von Integrationstests das Zusammenspiel der Funktionen mit echten Seiteneffekten wie Dateisystem und Netzwerk absichert. Diese Testpyramide, viele schnelle Unit-Tests unten, wenige langsamere Integrationstests oben, ist auch in der Bash-Entwicklung das bewaehrte Verhaeltnis fuer eine wartbare, schnell laufende Testsuite.

Mironsoft

Shell-Automatisierung, Testing und Deployment-Infrastruktur

Bash-Skripte ohne verlaessliche Tests im Einsatz?

Wir zerlegen bestehende Bash-Skripte in testbare Funktionen, bauen eine strukturierte bats-core-Testsuite auf und integrieren sie in eure Pre-Commit-Hooks und CI-Pipeline.

Refactoring

Monolithische Skripte in testbare, reine Funktionen zerlegen

Testsuite-Aufbau

bats-core-Testsuite mit Table-Driven Tests und Edge-Case-Abdeckung

CI-Integration

Pre-Commit-Hooks und TAP-Ausgabe fuer GitLab CI und GitHub Actions

10. Zusammenfassung

Unit-Tests fuer Bash-Funktionen beginnen mit einer bewussten Design-Entscheidung: reine Logik von Seiteneffekten zu trennen, sodass Funktionen isoliert und wiederholt aufgerufen werden koennen. Die Trennung von Bibliothek und ausfuehrbarem Einstiegspunkt mit dem BASH_SOURCE-Muster erlaubt es, Testsuiten aufzubauen, ohne echte Seiteneffekte auszuloesen. bats-core liefert mit @test, run und $status/$output die Struktur, um Rueckgabewerte und Ausgaben systematisch zu pruefen.

Table-Driven Tests reduzieren Duplikation bei vielen aehnlichen Testfaellen, waehrend gezielte Edge-Case-Tests fuer leere Eingaben, Sonderzeichen und Fehlerpfade genau die Stellen absichern, an denen Bash-Skripte in der Praxis am haeufigsten brechen. Integriert in Pre-Commit-Hooks und CI-Pipelines werden diese Unit-Tests fuer Bash zu einem dauerhaften Sicherheitsnetz, das Regressionen verhindert, lange bevor sie in Produktion sichtbar werden.

Unit-Tests fuer Bash-Funktionen — Das Wichtigste auf einen Blick

Reine Funktionen

Logik ohne Seiteneffekte in eine separate Bibliotheksdatei auslagern, das ist die Voraussetzung fuer echte Unit-Tests.

bats-core

@test, run, $status und $output strukturieren jeden Testfall nach einheitlichem Muster.

Table-Driven Tests

Viele Eingabe-Erwartungs-Paare in einer Schleife statt in dutzenden einzelnen Testfunktionen.

Edge Cases

Leere Eingaben, Sonderzeichen und Fehlerpfade gezielt testen, dort brechen Bash-Skripte am haeufigsten.

11. FAQ: Unit-Tests fuer Bash-Funktionen

1Was macht eine Funktion testbar?
Reinheit: nur Argumente lesen, Ergebnis zurueckgeben, keinen globalen Zustand oder externe Ressourcen veraendern.
2Logik von Seiteneffekten trennen?
Reine Funktionen in eine separate Bibliotheksdatei auslagern, per source einbinden.
3Was ist bats-core?
Test-Framework mit @test-Blocken, run, setup()/teardown() fuer isolierte Bash-Testfaelle.
4Exit-Code und Ausgabe pruefen?
Mit run, danach $status und $output verfuegbar, ohne Testabbruch bei Fehler.
5Was sind Table-Driven Tests?
Viele Eingabe-Erwartungs-Paare in einer Schleife statt in einzelnen Testblocken.
6Welche Edge Cases immer testen?
Leere Eingaben, Sonderzeichen, negative Zahlen, leere Arrays.
7Verschiedene Exit-Codes testen?
Exit-Codes sind eine API. Pro Fehlerursache ein Test sichert diese API dauerhaft ab.
8Bats in Pre-Commit-Hooks?
Git-Hook ruft bats test/ auf, bricht Commit bei Fehlschlag ab.
9Unit- vs. Integrationstests?
Unit isoliert und schnell, Integration prueft Zusammenspiel mit echten Seiteneffekten.
10Bestehendes Skript komplett umschreiben?
Nicht noetig, schrittweise Logik extrahieren reicht meist aus.