Table of Contents
- Why Test Bash Scripts?
- Prerequisites
- Manual Testing: The First Line of Defense
- Static Analysis with ShellCheck
- Unit Testing with Frameworks
- Integration Testing: Validate the Full Workflow
- Debugging Techniques for Bash Scripts
- Input Validation: Sanitize User Inputs
- Testing Edge Cases and Error Scenarios
- Continuous Integration (CI) for Bash Scripts
- Conclusion
- References
Why Test Bash Scripts?
Bash scripts often handle critical tasks—backups, deployments, data processing, or system configuration. Without testing, even minor flaws can lead to:
- Silent Failures: Scripts that exit successfully but produce incorrect results (e.g., missing files, corrupted data).
- Security Risks: Vulnerabilities like command injection (e.g., unsanitized user input passed to
eval). - Downtime: Scripts that crash production systems due to unhandled errors (e.g., “permission denied” on a critical file).
- Maintainability Debt: Unreliable scripts become hard to update, as changes may break untested functionality.
Testing mitigates these risks by ensuring scripts behave as expected under diverse conditions.
Prerequisites
To follow this guide, you’ll need:
- A Unix-like environment (Linux, macOS, or WSL).
- Basic familiarity with bash scripting (variables, loops, functions).
- Tools (install via your package manager or source):
shellcheck: Static analysis tool (install withsudo apt install shellcheckorbrew install shellcheck).bats-core: Unit testing framework (install viagit clone https://github.com/bats-core/bats-core.git && cd bats-core && sudo ./install.sh /usr/local).shunit2: Alternative unit tester (download from shunit2 GitHub).
Manual Testing: The First Line of Defense
Before diving into automated tools, start with manual testing to validate basic functionality.
Key Manual Checks:
-
Run with Default Inputs: Execute the script with typical arguments to verify core behavior.
./backup_script.sh /data /backup # Example: Test a backup script with source/destination -
Check Exit Codes: A script should exit with
0(success) or non-zero (failure). Useecho $?to check:./script.sh; echo $? # Should return 0 on success, 1+ on failure -
Inspect Output: Verify logs, files, or printed messages match expectations.
./greet.sh "Alice" # Should print "Hello, Alice!" -
Enable Debug Mode: Use
bash -xto trace execution (shows each command before running):bash -x ./script.sh # Prints each step, variables, and arguments
Static Analysis with ShellCheck
Static analysis checks for bugs without running the script. ShellCheck is the gold standard for bash scripts—it flags syntax errors, antipatterns, and portability issues.
How to Use ShellCheck:
-
Basic Usage: Run
shellcheckon your script:shellcheck ./script.sh -
Sample Output:
For a script with an unquoted variable (risky for paths with spaces):# Flawed script (risky_variable.sh) file="my file.txt" cat $file # Unquoted variable: fails if filename has spacesShellCheck will flag this:
In risky_variable.sh line 2: cat $file ^-- SC2086: Double quote to prevent globbing and word splitting. -
Fix the Issue: Quote the variable:
cat "$file" # ShellCheck will now pass
Advanced ShellCheck:
- Ignore Specific Warnings: Use
# shellcheck disable=SC2086to suppress false positives. - Check for Portability: Use
--shell=shto ensure compatibility with POSIX sh (not just bash).
Unit Testing with Frameworks
Unit testing validates individual components (functions, subroutines) in isolation. Tools like bats-core and shunit2 automate this.
Bats-core: Bash Automated Testing System
Bats-core uses simple bash syntax to define test cases. Tests are written in .bats files and run via the bats command.
Example: Test a “Greet User” Function
Let’s test a script greet.sh with a function greet_user:
# greet.sh
greet_user() {
echo "Hello, $1!"
}
Step 1: Write Bats Tests
Create greet.bats:
#!/usr/bin/env bats
# Load the script to test
load ./greet.sh
@test "greet_user with name 'Alice' returns 'Hello, Alice!'" {
result=$(greet_user "Alice")
[ "$result" = "Hello, Alice!" ] # Assertion: Check if output matches
}
@test "greet_user with empty input returns 'Hello, !'" {
result=$(greet_user "")
[ "$result" = "Hello, !" ] # Test edge case: empty input
}
Step 2: Run Tests
bats greet.bats
Sample Output:
✓ greet_user with name 'Alice' returns 'Hello, Alice!'
✓ greet_user with empty input returns 'Hello, !'
2 tests, 0 failures
Key Bats Features:
@test "Description" { ... }: Defines a test case.- Assertions: Use bash’s
[ ](test) or[[ ]](extended test) for checks. setup()/teardown(): Optional functions to run before/after each test (e.g., create temp files).
shunit2: Another Popular Framework
shunit2 is inspired by JUnit and uses POSIX-compliant syntax. It’s lighter than Bats but requires more boilerplate.
Example: Test a “Sum” Function
Script math.sh:
sum() {
echo $(( $1 + $2 ))
}
Test File math_test.sh:
#!/bin/bash
# Load shunit2 (path may vary)
. /usr/local/bin/shunit2
# Test sum function
testSum() {
result=$(sum 2 3)
assertEquals "2 + 3 should be 5" 5 "$result"
}
testSumNegative() {
result=$(sum -1 1)
assertEquals "-1 + 1 should be 0" 0 "$result"
}
# Run tests
. shunit2
Run Tests:
bash math_test.sh
Output:
testSum
testSumNegative
Ran 2 tests.
OK
Integration Testing: Validate the Full Workflow
Unit tests focus on components; integration tests validate the script in its real environment—including dependencies, external tools, and system interactions.
What to Test in Integration:
- Dependencies: Does the script work with required tools (e.g.,
rsync,curl,jq)? - File Systems: Does it handle real files (e.g., large files, symlinks, read-only directories)?
- Network: Does it gracefully handle API downtime or slow connections?
- Permissions: Does it fail correctly when run as a non-root user?
Example: Integration Test for a Backup Script
Suppose backup.sh syncs /data to /backup using rsync. Test:
- Create a temporary
datadirectory with sample files. - Run
backup.sh /tmp/data /tmp/backup. - Verify
/tmp/backupcontains all files from/tmp/data. - Test with a read-only source directory to ensure the script errors gracefully.
Debugging Techniques for Bash Scripts
Debugging is part of testing—use these tools to diagnose failures:
1. Strict Mode with set Options
Add these at the top of your script to catch errors early:
#!/bin/bash
set -euo pipefail # Exit on error, undefined variable, or pipeline failure
-e: Exit immediately if any command fails.-u: Treat undefined variables as errors (avoids silent failures from typos).-o pipefail: Make a pipeline fail if any command in it fails (not just the last one).
2. Traps for Error Handling
Use trap to run code on errors (e.g., clean up temp files):
cleanup() {
rm -f /tmp/tempfile.txt
}
trap cleanup EXIT # Run cleanup on script exit (success or failure)
trap 'echo "Error at line $LINENO"; exit 1' ERR # Print line number on error
3. Advanced Debugging with bashdb
For complex scripts, use bashdb (a bash debugger with breakpoints, watch variables, and stack traces):
bashdb ./script.sh # Start debugger; use 'help' for commands
Input Validation: Sanitize User Inputs
Scripts often accept arguments, files, or user input. Validate inputs to prevent garbage in, garbage out (GIGO).
Key Input Checks:
-
Argument Count: Ensure the user provides required inputs.
if [ $# -ne 2 ]; then echo "Usage: $0 <source> <destination>" exit 1 fi -
File Existence: Check if a required file exists.
source="$1" if [ ! -f "$source" ]; then echo "Error: $source does not exist." exit 1 fi -
Permission Checks: Ensure read/write access.
if [ ! -r "$source" ]; then echo "Error: No read permission for $source." exit 1 fi -
Data Types: Validate numbers, emails, or patterns with regex.
age="$1" if ! [[ "$age" =~ ^[0-9]+$ ]]; then # Regex: digits only echo "Error: Age must be a number." exit 1 fi -
Options with
getopts: For scripts with flags (e.g.,-vfor verbose), usegetoptsto validate options:verbose=0 while getopts "v" opt; do case $opt in v) verbose=1 ;; \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;; esac done
Testing Edge Cases and Error Scenarios
Scripts often fail in unexpected scenarios. Test these edge cases:
Common Edge Cases:
- Empty Inputs: A file with zero bytes, or an empty argument.
- Special Characters: Filenames with spaces,
$,*, or!(e.g.,file$name.txt). - Large Data: A 10GB file for a processing script.
- Network Failures: Simulate API downtime with
nc -l 8080(start a dummy server that doesn’t respond). - Race Conditions: Scripts that modify shared files (test with parallel runs).
Example: Test Special Characters
For a script that renames files:
# rename.sh: Rename $1 to $2
mv "$1" "$2"
Test with a filename containing spaces:
touch "old file.txt"
./rename.sh "old file.txt" "new file.txt" # Should work if variables are quoted
ls "new file.txt" # Verify rename succeeded
Continuous Integration (CI) for Bash Scripts
Automate testing with CI/CD pipelines (e.g., GitHub Actions, GitLab CI) to run tests on every code change.
Example: GitHub Actions Workflow
Create .github/workflows/test.yml to run shellcheck and Bats tests on push:
name: Test Bash Scripts
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt update
sudo apt install -y shellcheck
git clone https://github.com/bats-core/bats-core.git
cd bats-core && sudo ./install.sh /usr/local
- name: Run shellcheck
run: shellcheck ./*.sh
- name: Run Bats tests
run: bats ./*.bats
This workflow ensures tests run automatically, blocking merges if issues are found.
Conclusion
Testing bash scripts isn’t optional—it’s a critical step to ensure reliability, security, and maintainability. Start with manual testing and static analysis (ShellCheck), then adopt unit testing (Bats/shunit2) and integration testing. Validate inputs, test edge cases, and automate with CI/CD. By following these practices, you’ll build scripts that survive real-world chaos.