🧪 Testing Shell Scripts Patterns
Robust testing is essential for maintaining reliable shell scripts. This pattern establishes systematic approaches for unit testing, integration testing, and test-driven development of shell scripts.
🎯 Core Principles
Test Pyramid for Shell Scripts
Structure tests from unit to integration level.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 | # Unit Tests: Individual functions
# tests/unit/test_math.sh
test_addition() {
result=$(math_add 2 3)
assert_equal "$result" "5"
}
# Integration Tests: Combined functionality
# tests/integration/test_backup_workflow.sh
test_full_backup_cycle() {
setup_test_environment
run_backup_script
verify_backup_created
verify_backup_integrity
cleanup_test_data
}
# Acceptance Tests: End-to-end scenarios
# tests/acceptance/test_deployment.sh
test_production_deployment() {
deploy_to_test_environment
verify_services_running
verify_health_checks_pass
rollback_if_needed
}
|
Test Isolation
Ensure tests don't interfere with each other.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46 | # Test fixture pattern
setup_test() {
# Create isolated test environment
TEST_TEMP_DIR=$(mktemp -d)
TEST_HOME="$TEST_TEMP_DIR/home"
mkdir -p "$TEST_HOME"
# Save original environment
ORIGINAL_HOME="$HOME"
ORIGINAL_PATH="$PATH"
# Set test environment
export HOME="$TEST_HOME"
export TEST_MODE="true"
}
teardown_test() {
# Restore original environment
export HOME="$ORIGINAL_HOME"
export PATH="$ORIGINAL_PATH"
# Clean up test data
rm -rf "$TEST_TEMP_DIR"
}
# Test wrapper with automatic setup/teardown
test_wrapper() {
local test_name="$1"
local test_function="$2"
echo "Running test: $test_name"
setup_test
local result=0
# Run test function
if ! $test_function; then
echo "FAIL: $test_name"
result=1
else
echo "PASS: $test_name"
fi
teardown_test
return $result
}
|
🔧 Testing Framework
Assertion Library
Create reusable assertion functions.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79 | # lib/testing/assertions.sh
# Basic assertions
assert_equal() {
local actual="$1"
local expected="$2"
local message="${3:-Values not equal}"
if [ "$actual" != "$expected" ]; then
echo "ASSERTION FAILED: $message" >&2
echo " Expected: $expected" >&2
echo " Actual: $actual" >&2
return 1
fi
}
assert_not_equal() {
local actual="$1"
local expected="$2"
local message="${3:-Values should not be equal}"
if [ "$actual" = "$expected" ]; then
echo "ASSERTION FAILED: $message" >&2
echo " Value: $actual" >&2
return 1
fi
}
assert_true() {
local condition="$1"
local message="${2:-Condition should be true}"
if ! $condition; then
echo "ASSERTION FAILED: $message" >&2
return 1
fi
}
assert_false() {
local condition="$1"
local message="${2:-Condition should be false}"
if $condition; then
echo "ASSERTION FAILED: $message" >&2
return 1
fi
}
# File assertions
assert_file_exists() {
local file="$1"
local message="${2:-File should exist: $file}"
if [ ! -f "$file" ]; then
echo "ASSERTION FAILED: $message" >&2
return 1
fi
}
assert_file_not_exists() {
local file="$1"
local message="${2:-File should not exist: $file}"
if [ -f "$file" ]; then
echo "ASSERTION FAILED: $message" >&2
return 1
fi
}
assert_file_contains() {
local file="$1"
local pattern="$2"
local message="${3:-File should contain pattern: $pattern}"
if ! grep -q "$pattern" "$file" 2>/dev/null; then
echo "ASSERTION FAILED: $message" >&2
return 1
fi
}
|
Test Runner
Simple but effective test execution framework.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60 | # lib/testing/runner.sh
# Test registry
TEST_FUNCTIONS=()
# Register a test function
register_test() {
local test_function="$1"
TEST_FUNCTIONS+=("$test_function")
}
# Run all registered tests
run_tests() {
local passed=0
local failed=0
echo "Running ${#TEST_FUNCTIONS[@]} tests..."
echo "========================================"
for test_function in "${TEST_FUNCTIONS[@]}"; do
if $test_function; then
passed=$((passed + 1))
else
failed=$((failed + 1))
fi
done
echo "========================================"
echo "Tests passed: $passed"
echo "Tests failed: $failed"
echo "Total tests: ${#TEST_FUNCTIONS[@]}"
return $failed
}
# Test case decorator
test_case() {
local test_name="$1"
local test_function="$2"
# Create wrapper function
eval "
${test_name}_wrapper() {
setup_test 2>/dev/null || true
local result=0
if ! $test_function; then
echo \"FAIL: $test_name\" >&2
result=1
else
echo \"PASS: $test_name\"
fi
teardown_test 2>/dev/null || true
return \$result
}
"
register_test "${test_name}_wrapper"
}
|
📋 Test Organization
Directory Structure
Organize tests for clarity and maintainability.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 | tests/
├── unit/
│ ├── test_string_utils.sh
│ ├── test_file_ops.sh
│ └── test_validation.sh
├── integration/
│ ├── test_backup_process.sh
│ ├── test_config_loading.sh
│ └── test_network_ops.sh
├── acceptance/
│ ├── test_installation.sh
│ └── test_upgrade.sh
├── fixtures/
│ ├── sample_config.yaml
│ ├── test_data/
│ └── mock_services/
└── helpers/
├── test_setup.sh
└── test_assertions.sh
|
Unit Test Examples
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 | # tests/unit/test_string_utils.sh
# Source the library being tested
source lib/string_utils.sh
source lib/testing/assertions.sh
test_string_length() {
local result
result=$(string_length "hello")
assert_equal "$result" "5"
result=$(string_length "")
assert_equal "$result" "0"
}
test_string_contains() {
assert_true "string_contains 'hello world' 'world'"
assert_false "string_contains 'hello world' 'goodbye'"
}
test_trim_whitespace() {
local result
result=$(trim_whitespace " hello ")
assert_equal "$result" "hello"
result=$(trim_whitespace " ")
assert_equal "$result" ""
}
# Register tests
source lib/testing/runner.sh
test_case "string_length" test_string_length
test_case "string_contains" test_string_contains
test_case "trim_whitespace" test_trim_whitespace
|
🛠️ Advanced Testing Techniques
Mock Objects
Replace external dependencies with controlled mocks.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56 | # lib/testing/mocks.sh
# Mock command execution
MOCK_COMMANDS=()
mock_command() {
local command_name="$1"
local mock_function="$2"
# Create mock function
eval "
$command_name() {
$mock_function \"\$@\"
}
"
MOCK_COMMANDS+=("$command_name")
}
# Reset mocks
reset_mocks() {
for mock_cmd in "${MOCK_COMMANDS[@]}"; do
unset -f "$mock_cmd" 2>/dev/null || true
done
MOCK_COMMANDS=()
}
# Common mocks
mock_curl_success() {
echo "Mock curl response"
return 0
}
mock_curl_failure() {
echo "Connection failed" >&2
return 1
}
# Usage in tests
test_network_download_success() {
mock_command "curl" mock_curl_success
result=$(download_file "http://example.com/file")
assert_equal "$result" "Mock curl response"
reset_mocks
}
test_network_download_failure() {
mock_command "curl" mock_curl_failure
result=$(download_file "http://example.com/file" 2>&1)
assert_equal "$result" "Connection failed"
reset_mocks
}
|
Test Data Management
Handle test data systematically.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51 | # lib/testing/fixtures.sh
# Fixture management
FIXTURE_DIRS=()
create_fixture_dir() {
local fixture_name="$1"
local fixture_dir="${TEST_TEMP_DIR}/fixtures/$fixture_name"
mkdir -p "$fixture_dir"
FIXTURE_DIRS+=("$fixture_dir")
echo "$fixture_dir"
}
load_fixture() {
local fixture_name="$1"
local fixture_dir
fixture_dir=$(create_fixture_dir "$fixture_name")
# Copy fixture files
if [ -d "tests/fixtures/$fixture_name" ]; then
cp -r "tests/fixtures/$fixture_name"/* "$fixture_dir/"
fi
echo "$fixture_dir"
}
# Test data generators
generate_test_file() {
local filename="$1"
local size="${2:-1024}"
dd if=/dev/zero of="$filename" bs=1 count="$size" 2>/dev/null
}
generate_config_file() {
local filename="$1"
cat > "$filename" << EOF
# Test configuration
database:
host: localhost
port: 5432
name: testdb
cache:
redis:
host: localhost
port: 6379
EOF
}
|
🎨 Test Execution Strategies
Continuous Integration
Automated test execution setup.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 | # Makefile for test execution
.PHONY: test test-unit test-integration coverage clean
test: test-unit test-integration
test-unit:
@echo "Running unit tests..."
@find tests/unit -name "test_*.sh" -exec {} \;
test-integration:
@echo "Running integration tests..."
@find tests/integration -name "test_*.sh" -exec {} \;
coverage:
@echo "Generating test coverage report..."
@./scripts/coverage.sh
clean:
@rm -rf test-reports/ coverage-reports/
|
Test Reporting
Generate structured test reports.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45 | # lib/testing/reporting.sh
TEST_REPORT_FILE="test-reports/$(date +%Y%m%d_%H%M%S).xml"
initialize_test_report() {
mkdir -p "$(dirname "$TEST_REPORT_FILE")"
cat > "$TEST_REPORT_FILE" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
EOF
}
finalize_test_report() {
cat >> "$TEST_REPORT_FILE" << EOF
</testsuites>
EOF
}
report_test_result() {
local test_name="$1"
local result="$2" # PASS or FAIL
local duration="${3:-0}"
local status
if [ "$result" = "PASS" ]; then
status="0"
else
status="1"
fi
cat >> "$TEST_REPORT_FILE" << EOF
<testcase name="$test_name" time="$duration">
EOF
if [ "$result" = "FAIL" ]; then
cat >> "$TEST_REPORT_FILE" << EOF
<failure message="Test failed"/>
EOF
fi
cat >> "$TEST_REPORT_FILE" << EOF
</testcase>
EOF
}
|
🧾 Summary Best Practices
Testing Principles
✅ Write tests before implementation (TDD)
✅ Isolate tests from each other and system state
✅ Use descriptive test names that explain behavior
✅ Mock external dependencies for unit tests
✅ Test edge cases and error conditions
Test Organization
- Unit Tests: Individual functions and modules
- Integration Tests: Component interactions
- Acceptance Tests: End-to-end scenarios
- Regression Tests: Bug fixes and edge cases
Quality Guidelines
✅ Maintain high test coverage (>80% for critical code)
✅ Keep tests fast and reliable
✅ Use consistent naming conventions
✅ Document test setup and expectations
✅ Automate test execution in CI/CD pipeline
🧠 Complete Test Template
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41 | #!/bin/bash
# tests/unit/test_example.sh - Test template
# Setup
source lib/core/functions.sh
source lib/testing/assertions.sh
source lib/testing/runner.sh
# Test functions
test_basic_functionality() {
local result
result=$(some_function "input")
assert_equal "$result" "expected_output"
}
test_error_handling() {
local result
result=$(some_function "invalid_input" 2>&1)
assert_equal "$result" "Error: Invalid input"
}
test_edge_cases() {
# Empty input
local result
result=$(some_function "")
assert_equal "$result" "default_output"
# Special characters
result=$(some_function "test&special")
assert_equal "$result" "expected_special_output"
}
# Register tests
test_case "basic_functionality" test_basic_functionality
test_case "error_handling" test_error_handling
test_case "edge_cases" test_edge_cases
# Run tests if executed directly
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
run_tests
fi
|
🧾 See Also