curl, ssh und Co. kontrolliert simulieren statt echte Systeme anzufassen
Ein Bash-Skript, das curl, ssh oder mysqldump aufruft, laesst sich nicht sinnvoll testen, ohne echte Netzwerkverbindungen oder Datenbanken bei jedem Testlauf anzufassen. Externe Kommandos mocken loest genau dieses Problem: das PATH-Override-Pattern und gezielte Funktionsueberschreibung machen Bash-Tests deterministisch, schnell und unabhaengig von echter Infrastruktur.
Inhaltsverzeichnis
- 1. Warum externe Kommandos in Tests zum Problem werden
- 2. Das PATH-Override-Pattern: Stub-Skripte vor die echten Kommandos stellen
- 3. Funktionen ueberschreiben statt PATH manipulieren
- 4. Aufrufe protokollieren und Argumente pruefen
- 5. Rueckgabewerte und Exit-Codes von Mocks steuern
- 6. Mocking mit bats-mock strukturieren
- 7. Zeit, Zufall und Netzwerk deterministisch machen
- 8. Grenzen des Mockens: wann ein Integrationstest noetig ist
- 9. Mocking-Techniken im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum externe Kommandos in Tests zum Problem werden
Bash-Skripte sind selten in sich geschlossen. Sie rufen curl fuer HTTP-Requests, ssh fuer entfernte Befehle, mysqldump fuer Datenbank-Backups oder docker fuer Container-Operationen auf. Genau diese externen Abhaengigkeiten machen Kommandos mocken in Bash notwendig: Ohne Mocking wuerde jeder Testlauf eine echte HTTP-Verbindung aufbauen, eine echte SSH-Sitzung starten oder eine echte Datenbank anfassen, mit allen Konsequenzen fuer Geschwindigkeit, Zuverlaessigkeit und Nebenwirkungen.
Das Problem hat mehrere Dimensionen. Erstens die Geschwindigkeit: Ein echter curl-Aufruf gegen einen entfernten Server dauert Sekunden, ein gemocktes Kommando antwortet in Millisekunden. Zweitens die Zuverlaessigkeit: Ein Testlauf sollte nicht fehlschlagen, nur weil ein externer Dienst gerade nicht erreichbar ist. Drittens die Determinismus-Frage: Ein Test, der von der aktuellen Systemzeit, einer Zufallszahl oder dem tatsaechlichen Zustand einer Datenbank abhaengt, liefert bei jedem Lauf ein anderes Ergebnis, was Testautomatisierung praktisch unmoeglich macht.
Die Loesung fuer all diese Probleme ist dieselbe: externe Kommandos durch kontrollierte Attrappen ersetzen, die vorhersehbares Verhalten liefern, ohne die echte Aussenwelt anzufassen. In Bash gibt es dafuer zwei etablierte Techniken, das PATH-Override-Pattern und das direkte Ueberschreiben von Funktionsnamen, die in den naechsten Abschnitten im Detail vorgestellt werden.
2. Das PATH-Override-Pattern: Stub-Skripte vor die echten Kommandos stellen
Die grundlegende Technik zum Kommandos mocken in Bash nutzt die Reihenfolge, in der die Shell Befehle im PATH sucht. Wird ein Verzeichnis mit einem gleichnamigen Stub-Skript vor die regulaeren Systempfade gestellt, findet Bash den Stub zuerst und ruft nie das echte Kommando auf. Das funktioniert, weil Bash PATH von links nach rechts durchsucht und beim ersten Treffer stoppt, unabhaengig davon, ob spaeter im PATH noch das echte Kommando existiert.
Fuer diese Technik legt man ein Testverzeichnis mit ausfuehrbaren Dateien gleichen Namens wie die zu mockenden Kommandos an, zum Beispiel test/mocks/curl. Vor dem eigentlichen Testlauf wird dieses Verzeichnis mit PATH="test/mocks:$PATH" temporaer vorangestellt. Der grosse Vorteil dieses Ansatzes gegenueber Funktionsueberschreibung: Er funktioniert auch dann, wenn das zu testende Skript das Kommando nicht als Bash-Builtin, sondern ueber exec, in einer Subshell oder aus einem anderen Prozess heraus aufruft, da PATH systemweit fuer jeden neuen Prozess gilt.
#!/usr/bin/env bash
# test/mocks/curl — stub replacing the real curl binary
# Records every invocation and returns a canned response
echo "$*" >> "${MOCK_LOG:-/tmp/mock-curl.log}"
case "$1 $2" in
*"api.mironsoft.de/status"*)
echo '{"status":"ok"}'
exit 0
;;
*"api.mironsoft.de/deploy"*)
echo '{"error":"unauthorized"}'
exit 22
;;
*)
echo "mock curl: unhandled arguments: $*" >&2
exit 1
;;
esac
#!/usr/bin/env bash
# Running the test with the mock ahead of the real curl in PATH
set -euo pipefail
readonly TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PATH="${TEST_DIR}/mocks:${PATH}"
export MOCK_LOG="$(mktemp)"
# The script under test calls "curl", but resolves to our mock first
./check_deployment_status.sh
grep -q "api.mironsoft.de/status" "$MOCK_LOG" && echo "Mock was called correctly"
Ein wichtiges Detail beim PATH-Override: Der Stub muss ausfuehrbar sein (chmod +x) und darf keine Dateiendung tragen, da das zu testende Skript den Befehl als curl aufruft, nicht als curl.sh. Ausserdem sollte PATH nach dem Testlauf zuverlaessig zurueckgesetzt werden, am besten ueber eine Subshell oder einen trap, damit nachfolgende Tests nicht versehentlich denselben Mock verwenden.
3. Funktionen ueberschreiben statt PATH manipulieren
Eine leichtgewichtigere Alternative zum PATH-Override ist das direkte Ueberschreiben von Funktionsnamen innerhalb der Testumgebung. Definiert man vor dem Sourcen des zu testenden Skripts eine Bash-Funktion mit demselben Namen wie ein externes Kommando, etwa curl(), ruft Bash bei jedem Aufruf von curl innerhalb desselben Shell-Prozesses diese Funktion auf statt des echten Binaries. Funktionen haben in Bash Vorrang vor Kommandos im PATH, solange kein vollqualifizierter Pfad wie /usr/bin/curl verwendet wird.
Dieser Ansatz zum Kommandos mocken ist besonders praktisch fuer schnelle, isolierte Unit-Tests einzelner Funktionen, da kein separates Skript-File und keine PATH-Manipulation noetig sind, alles findet innerhalb derselben Testdatei statt. Der Nachteil: Funktions-Mocks wirken nur innerhalb desselben Bash-Prozesses. Ruft das zu testende Skript ein externes Kommando aus einer Subshell mit $(...) auf, in der die Funktion nicht exportiert wurde, greift der Mock unter Umstaenden nicht.
#!/usr/bin/env bats
load "test_helper/bats-assert/load"
setup() {
load "../lib/deployment.sh"
# Override curl with a mock function for this test file
curl() {
echo "MOCK: curl called with args: $*" >&2
if [[ "$*" == *"api.mironsoft.de/status"* ]]; then
echo '{"status":"ok"}'
return 0
fi
return 1
}
export -f curl
}
@test "check_deployment_status parses mocked curl response correctly" {
run check_deployment_status
assert_success
assert_output --partial "Deployment is healthy"
}
@test "check_deployment_status handles curl failure gracefully" {
curl() { return 7; }
export -f curl
run check_deployment_status
assert_failure
assert_output --partial "Could not reach API"
}
Der Befehl export -f ist entscheidend: Ohne ihn ist die Funktion nur im aktuellen Shell-Kontext sichtbar, nicht aber in Subshells, die durch Kommando-Substitution oder Hintergrundprozesse entstehen. Wird eine Funktion mit export -f exportiert, erben auch Subshells und aus dem Skript gestartete Kindprozesse die Ueberschreibung, solange es sich um Bash-Kindprozesse handelt, nicht um vollstaendig neue Programme, die den PATH selbst neu aufloesen.
4. Aufrufe protokollieren und Argumente pruefen
Ein Mock, der nur eine feste Antwort liefert, testet lediglich, ob das Skript mit einer bestimmten Antwort umgehen kann. Genauso wichtig ist oft die umgekehrte Pruefung: Wurde das externe Kommando ueberhaupt mit den richtigen Argumenten aufgerufen? Diese Pruefung erfordert, dass der Mock jeden Aufruf protokolliert, typischerweise in eine Logdatei, die der Test anschliessend auswertet.
Ein solides Protokollierungs-Pattern schreibt bei jedem Mock-Aufruf eine Zeile mit allen Argumenten in eine Datei, deren Pfad ueber eine Umgebungsvariable konfigurierbar ist. Der Test kann danach mit grep oder direktem Zeilenvergleich pruefen, ob das externe Kommando mit den erwarteten Parametern aufgerufen wurde, und sogar die Reihenfolge mehrerer Aufrufe verifizieren, etwa dass zuerst ein Backup erstellt und erst danach die Datenbank geleert wurde.
#!/usr/bin/env bats
load "test_helper/bats-assert/load"
setup() {
load "../lib/backup.sh"
CALL_LOG="$(mktemp)"
mysqldump() {
echo "mysqldump $*" >> "$CALL_LOG"
echo "-- mock dump output"
}
export -f mysqldump
export CALL_LOG
}
teardown() {
rm -f "$CALL_LOG"
}
@test "backup_database calls mysqldump with correct database name" {
run backup_database "shop_production"
assert_success
run grep -c "mysqldump.*shop_production" "$CALL_LOG"
assert_output "1"
}
@test "backup_database calls mysqldump exactly once" {
run backup_database "shop_production"
run wc -l < "$CALL_LOG"
assert_output "1"
}
@test "backup_database passes the --single-transaction flag" {
run backup_database "shop_production"
run grep -c -- "--single-transaction" "$CALL_LOG"
assert_output "1"
}
Dieses Muster verwandelt einen einfachen Mock in einen sogenannten Spy: Er simuliert nicht nur das externe Verhalten, sondern zeichnet auch auf, wie er verwendet wurde. Diese Aufzeichnung ist besonders wertvoll bei Refactorings: Wenn ein Test pruefen soll, dass mysqldump weiterhin mit denselben kritischen Flags aufgerufen wird, faellt eine versehentliche Aenderung dieser Flags sofort auf, ohne dass die eigentliche Datenbankfunktion angefasst werden muesste.
5. Rueckgabewerte und Exit-Codes von Mocks steuern
Die eigentliche Staerke von Mocks liegt darin, Fehlerszenarien zu simulieren, die mit einem echten Kommando kaum reproduzierbar waeren: ein Netzwerktimeout, ein abgelaufenes Zertifikat, ein voller Datenspeicher oder ein temporaerer Serverfehler mit HTTP-Status 503. Ein Mock, der bei Bedarf einen bestimmten Exit-Code oder eine bestimmte Fehlerausgabe zurueckgibt, macht solche Szenarien in Sekunden reproduzierbar testbar, ohne die entsprechende Fehlerbedingung in einer echten Umgebung erst kuenstlich herbeifuehren zu muessen.
Um verschiedene Szenarien in derselben Testsuite abzudecken, konfiguriert man den Mock ueber eine Umgebungsvariable, die das gewuenschte Verhalten steuert. So kann ein und derselbe Mock je nach Testfall Erfolg, einen bestimmten Fehlercode oder eine verzoegerte Antwort simulieren, ohne fuer jedes Szenario ein eigenes Stub-Skript pflegen zu muessen.
#!/usr/bin/env bash
# test/mocks/curl — mock with configurable failure modes via env var
echo "$*" >> "${MOCK_LOG:-/dev/null}"
case "${MOCK_CURL_BEHAVIOR:-success}" in
success)
echo '{"status":"ok"}'
exit 0
;;
timeout)
echo "curl: (28) Operation timed out" >&2
exit 28
;;
unauthorized)
echo '{"error":"invalid token"}'
exit 22
;;
server_error)
echo '{"error":"internal server error"}'
exit 22
;;
*)
echo "Unknown MOCK_CURL_BEHAVIOR: ${MOCK_CURL_BEHAVIOR}" >&2
exit 1
;;
esac
#!/usr/bin/env bats
load "test_helper/bats-assert/load"
setup() {
export PATH="$(pwd)/test/mocks:${PATH}"
}
@test "deploy retries on timeout and eventually fails" {
export MOCK_CURL_BEHAVIOR="timeout"
run deploy_to_production
assert_failure
assert_output --partial "Deployment failed after 3 retries"
}
@test "deploy fails fast on unauthorized without retrying" {
export MOCK_CURL_BEHAVIOR="unauthorized"
run deploy_to_production
assert_failure
assert_output --partial "Authentication failed"
refute_output --partial "retry"
}
Dieses Muster zeigt einen zentralen Vorteil von Mocking gegenueber echten Integrationstests: Man kann gezielt pruefen, ob die Retry-Logik bei einem Timeout tatsaechlich mehrfach versucht, waehrend sie bei einem Autorisierungsfehler sofort abbricht, ohne unnoetige Versuche zu verschwenden. Diese Unterscheidung im Fehlerverhalten waere mit einem echten Server kaum zuverlaessig zu erzwingen.
6. Mocking mit bats-mock strukturieren
Fuer groessere Testsuiten mit vielen gemockten Kommandos wird das manuelle Schreiben von Stub-Skripten schnell repetitiv. Die Bibliothek bats-mock baut auf dem PATH-Override-Pattern auf, kapselt aber die Erstellung, Konfiguration und Auswertung von Mocks in wiederverwendbaren Helper-Funktionen. Statt jeden Mock manuell als Datei anzulegen, generiert bats-mock Stub-Skripte zur Laufzeit und bietet Assertions wie assert_called_with, um Aufrufe direkt zu pruefen.
Der Vorteil von bats-mock gegenueber selbstgeschriebenen Stub-Skripten liegt in der Konsistenz ueber eine wachsende Testsuite hinweg: Jeder Mock folgt derselben Struktur, jede Assertion nutzt dieselbe Syntax, und neue Teammitglieder muessen kein projektspezifisches Mocking-Pattern erst verstehen, sondern koennen sich auf eine dokumentierte, etablierte Bibliothek verlassen.
#!/usr/bin/env bats
load "test_helper/bats-mock/load"
load "test_helper/bats-assert/load"
setup() {
load "../lib/backup.sh"
mock_aws="$(mock_create)"
# Redirect calls to "aws" toward our generated mock stub
ln -sf "$mock_aws" "$(dirname "$mock_aws")/aws"
export PATH="$(dirname "$mock_aws"):${PATH}"
}
@test "upload_backup calls aws s3 cp with the correct bucket path" {
mock_set_output "$mock_aws" "upload: ok" 0
run upload_backup "/tmp/backup.sql.gz" "production-backups"
assert_success
assert_equal "$(mock_get_call_num "$mock_aws")" 1
assert_equal "$(mock_get_call_args "$mock_aws" 1)" \
"s3 cp /tmp/backup.sql.gz s3://production-backups/"
}
@test "upload_backup surfaces an aws error correctly" {
mock_set_output "$mock_aws" "Access Denied" 1
run upload_backup "/tmp/backup.sql.gz" "production-backups"
assert_failure
assert_output --partial "Access Denied"
}
Ein Detail, das bats-mock von manuellen Stub-Skripten unterscheidet: Die generierten Mocks zeichnen automatisch die Anzahl der Aufrufe und alle Argumente in strukturierter Form auf, sodass Assertions wie mock_get_call_args direkt auf bestimmte Aufrufe zugreifen koennen, ohne selbst geschriebene Log-Parsing-Logik. Fuer Projekte mit mehr als einer Handvoll gemockter Kommandos amortisiert sich dieser initiale Lernaufwand schnell.
7. Zeit, Zufall und Netzwerk deterministisch machen
Neben externen Kommandos gibt es weitere Quellen von Nichtdeterminismus, die Bash-Tests unzuverlaessig machen: die aktuelle Systemzeit ueber date, Zufallszahlen ueber $RANDOM oder /dev/urandom, und generierte eindeutige IDs ueber uuidgen oder mktemp. Fuer verlaessliche Tests muessen auch diese Quellen kontrolliert werden, sonst erzeugt jeder Testlauf leicht unterschiedliche Ergebnisse, was Assertions auf exakte Werte unmoeglich macht.
Das Bash-Pattern dafuer ist identisch zum Mocken externer Kommandos: date() als Funktion ueberschreiben, die immer denselben festen Zeitstempel zurueckgibt, oder $RANDOM in Testumgebungen durch einen festen Seed reproduzierbar machen. Fuer Netzwerkoperationen, die nicht ueber ein einzelnes Kommando wie curl laufen, sondern direkt ueber TCP-Sockets, hilft ein lokaler Test-Server, der auf einem festen Port simulierte Antworten liefert, statt gegen einen echten externen Dienst zu testen.
#!/usr/bin/env bats
load "test_helper/bats-assert/load"
setup() {
load "../lib/release.sh"
# Freeze time for deterministic release tag generation
date() {
echo "2026-07-30T14:00:00Z"
}
export -f date
# Deterministic "random" ID for reproducible test assertions
generate_release_id() {
echo "test-fixed-id-0001"
}
export -f generate_release_id
}
@test "create_release_tag produces a deterministic, predictable tag" {
run create_release_tag
assert_success
assert_output "release-2026-07-30-test-fixed-id-0001"
}
Diese Technik hat einen weiteren Vorteil: Tests, die auf einer festen Zeit basieren, decken auch Randfaelle rund um Zeitzonen und Datumsgrenzen zuverlaessig ab, etwa das Verhalten eines Skripts am 31. Dezember um 23:59 Uhr, ein Fall, der ohne kontrollierte Zeit nur einmal im Jahr zufaellig getestet wuerde, waehrend er mit einem gemockten date jederzeit reproduzierbar bleibt.
8. Grenzen des Mockens: wann ein Integrationstest noetig ist
So nuetzlich Mocks sind, sie testen niemals das tatsaechliche Verhalten des echten externen Kommandos, sondern nur die Annahmen des Testautors darueber. Wenn sich das Ausgabeformat von curl, das Verhalten einer API oder die Argumentstruktur eines Tools wie aws aendert, bleibt ein Mock-Test weiterhin gruen, obwohl das echte Skript in Produktion bricht. Dieses Risiko wird als "Mock-Drift" bezeichnet: Die Attrappe entfernt sich schleichend vom echten Verhalten, ohne dass es jemand bemerkt.
Deshalb sollten gemockte Kommandos niemals die einzige Testebene bleiben. Eine kleine Anzahl von Integrationstests, die tatsaechlich gegen eine Staging-Umgebung, einen echten Test-Server oder eine lokale Docker-Instanz laufen, deckt genau die Faelle ab, die ein Mock per Definition nicht pruefen kann: ob die Annahmen ueber das externe Verhalten tatsaechlich noch stimmen. Die Faustregel: viele schnelle Mock-Tests fuer die Logik des eigenen Skripts, wenige, aber regelmaessig laufende Integrationstests fuer die Kompatibilitaet mit der echten Aussenwelt.
9. Mocking-Techniken im Vergleich
Je nach Anwendungsfall eignen sich unterschiedliche Mocking-Techniken besser oder schlechter, abhaengig von der Testgeschwindigkeit, dem Setup-Aufwand und der Genauigkeit der Simulation.
| Technik | Setup-Aufwand | Wirkungsbereich | Am besten geeignet fuer |
|---|---|---|---|
| Funktion ueberschreiben | Sehr gering | Nur aktueller Shell-Prozess | Schnelle Unit-Tests einzelner Funktionen |
| PATH-Override | Mittel | Auch Subshells und Kindprozesse | Skripte mit exec oder externen Prozessen |
| bats-mock | Mittel (einmalig) | Auch Subshells und Kindprozesse | Grosse Testsuiten mit vielen Mocks |
| Lokaler Test-Server | Hoch | Realistisches Netzwerkverhalten | HTTP-APIs mit komplexem Verhalten |
| Echter Integrationstest | Hoch, langsam | Volle Realitaet | Regelmaessige Kompatibilitaetspruefung |
In der Praxis kombiniert man diese Techniken je nach Testebene: Funktions-Mocks fuer schnelle, isolierte Unit-Tests, PATH-Override oder bats-mock fuer realistischere Simulation ganzer Skriptablaeufe, und eine kleine Anzahl echter Integrationstests, die regelmaessig, aber seltener laufen, um Mock-Drift rechtzeitig zu erkennen, bevor er in Produktion sichtbar wird.
Mironsoft
Shell-Automatisierung, Testing und Deployment-Infrastruktur
Bash-Skripte mit externen Abhaengigkeiten zuverlaessig testen?
Wir bauen Mocking-Strategien fuer eure Bash-Skripte auf, von einfachen Funktions-Mocks bis zu strukturierten bats-mock-Testsuiten, ergaenzt um gezielte Integrationstests gegen echte Systeme.
Mocking-Setup
PATH-Override und Funktions-Mocks fuer curl, ssh, aws und mehr
Testsuite-Ausbau
bats-mock-Integration fuer wachsende, wartbare Bash-Testsuiten
Mock-Drift-Pruefung
Ergaenzende Integrationstests gegen echte Staging-Systeme
10. Zusammenfassung
Externe Kommandos mocken ist die Grundvoraussetzung, um Bash-Skripte mit Abhaengigkeiten zu curl, ssh, aws oder Datenbank-Tools schnell und deterministisch zu testen. Das PATH-Override-Pattern stellt Stub-Skripte vor die echten Kommandos, funktioniert auch in Subshells und Kindprozessen und ist damit robuster als reine Funktionsueberschreibung, die nur im aktuellen Shell-Prozess wirkt. Aufrufprotokollierung verwandelt einfache Mocks in Spies, die pruefen koennen, ob und wie ein externes Kommando aufgerufen wurde.
bats-mock strukturiert das Mocking fuer wachsende Testsuiten, waehrend die kontrollierte Simulation von Zeit und Zufall zusaetzlichen Nichtdeterminismus eliminiert. Wichtig bleibt, dass gemockte Kommandos niemals die einzige Testebene sind: Eine kleine Anzahl echter Integrationstests gegen tatsaechliche Systeme faengt Mock-Drift ab, bevor veraltete Annahmen ueber externes Verhalten in Produktion zu echten Fehlern fuehren.
Externe Kommandos in Bash-Tests mocken — Das Wichtigste auf einen Blick
PATH-Override
Stub-Skript vor die echten Systempfade stellen, funktioniert auch in Subshells und Kindprozessen.
Funktions-Mocks
Schnell und leichtgewichtig fuer isolierte Unit-Tests, mit export -f fuer Subshell-Sichtbarkeit.
Aufrufprotokollierung
Mock als Spy erweitern, der Argumente und Anzahl der Aufrufe fuer Assertions aufzeichnet.
Grenzen kennen
Mock-Drift durch regelmaessige echte Integrationstests gegen tatsaechliche Systeme abfangen.