Przejdź do treści

🧪 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

  1. Unit Tests: Individual functions and modules
  2. Integration Tests: Component interactions
  3. Acceptance Tests: End-to-end scenarios
  4. 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