跪拜 Guibai
← Back to the summary

Shell Testing Without a Framework: Assert, Mock, and Integrate

"Quick Mastery of Shell" — A Detailed Guide to Shell Script Testing

In the previous articles, we progressed from loops, strings, and arrays to error handling and modularization. Modularization gives code a clear organization, and error handling makes scripts reliable, but one final piece is missing — how do you ensure old functionality still works after you modify the code? That is the problem testing solves.

People who write shell scripts have polarized attitudes toward testing. One group never tests, believing a script is just a few lines and running it successfully is enough. The other group treats it like Python, readily adopting a full testing framework. Both extremes are inadvisable — shell script testing has its own logic.

In this article, we start by writing testable code, move on to writing our own assert framework and handling external dependencies, and finally cover integration testing and CI integration. The entire article uses the deployment tool from the previous article as a practical example to demonstrate how to add tests to a real project.

1. The Peculiarities of Shell Testing

Before discussing specific methods, let's first understand the unique aspects of shell script testing. These peculiarities determine that the approach to shell testing is completely different from Python/Go.

1.1 Side Effects Are the Norm

Python functions are pure by default — given an input, they return an output without touching the external world. Shell functions are different; many things that look like functions are essentially command invocations:

# This is not a "pure function"; it's a command chain with side effects
deploy_to_production() {
    ssh prod-server "systemctl restart myapp"   # remote command
    rm -rf /tmp/deploy_cache                     # delete files
    curl -X POST https://api.example.com/log    # call API
}

Such a function is very difficult to test directly — calling it once will genuinely connect to servers, delete files, and send requests. The core challenge of shell testing is isolating these side effects.

1.2 Global State Sharing

# Variables defined at the top of the script
ENV="production"
DB_HOST="localhost"

# A function in the middle references them
connect_db() {
    echo "Connecting to $DB_HOST"
}

This connect_db function looks like a pure function, but it actually depends on the file-scoped $DB_HOST. Calling it without setting this variable leads to completely unpredictable behavior.

Python classes have self for isolation, and Go closures have explicit captures, but in shell, everything is global. Testing shell functions requires explicit control over inputs and the environment.

1.3 State Is Hard to Clean Up

test_log_writes_to_file() {
    log "test message" /var/log/test.log
    cat /var/log/test.log
}

Running it the first time is fine, but the second time might fail because the file already exists; after the test, /var/log/test.log remains, polluting the system. Shell testing requires strict isolation between tests.

1.4 Exit Codes Are Black and White

In shell, success and failure are a single signal — exit code 0 or non-zero. This differs from Python's exceptions or Go's error; there is no partial success or specific error type. To distinguish between a file-not-found failure and a permission-denied failure, you must define your own exit codes or capture stderr.

Understanding these peculiarities gives us a mental foundation. Shell testing is not as simple as applying a testing framework; it must solve three problems: how to isolate side effects, how to control inputs, and how to express "expected failure."

2. Writing Testable Code

Code testability is not something to consider during the testing phase — it should be thought through during the design phase. This section covers how to write shell functions that are easy to test.

2.1 Functions as Units

The most common anti-pattern is script-style — all logic written at the top level without function encapsulation:

# Untestable approach
LOG_LEVEL=INFO
load_config() { ... }
config=$(load_config)
if [[ "$config" == "production" ]]; then
    ssh prod ...
fi

This code cannot test a specific logic segment of load_config in isolation — because it executes side effects. Refactor to a functional style:

# Testable approach
load_config() {
    local file=$1
    # ... only does "read + parse"
    echo "$parsed_config"
}

if [[ "$(load_config env.conf)" == "production" ]]; then
    ssh prod ...
fi

Wrap all code that does work into functions, each function doing one thing, and the top level only responsible for calling and orchestrating. This way, each function can be tested independently.

2.2 Accept Inputs Instead of Depending on Globals

# Hard to test: depends on global variables
deploy() {
    ssh "$DEPLOY_HOST" "systemctl restart myapp"
}

# Easy to test: explicit parameters
deploy() {
    local host=$1
    ssh "$host" "systemctl restart myapp"
}

The second approach is easier to test — passing different hosts tests different branches without changing global state. Develop the habit of passing all external dependencies as parameters when writing functions.

2.3 Concentrate Side Effects in One Place

If a function simultaneously computes something and does something, testing it is painful. Separate them:

# Hard to test: function mixes "computation" and "execution"
deploy_all() {
    local servers=$(list_servers)
    for s in $servers; do
        deploy $s
    done
}

# Easy to test: compute first, then execute, test separately
get_servers_to_deploy() {
    local servers=$(list_servers)
    echo "$servers"
}

deploy() {
    local host=$1
    ssh "$host" "systemctl restart myapp"
}

# Top-level orchestration (covered by integration tests)
for s in $(get_servers_to_deploy); do
    deploy $s
done

get_servers_to_deploy is pure computation (no side effects), and deploy is a command with side effects. Test them separately: get_servers_to_deploy for logic, deploy for integration behavior.

2.4 Early Return Instead of Nesting

# Hard to read and hard to test
process_file() {
    if [ -f "$1" ]; then
        if [ -r "$1" ]; then
            # ... actual logic
            return 0
        else
            return 1
        fi
    else
        return 1
    fi
}

# Easy to read and easy to test
process_file() {
    local file=$1
    [ -f "$file" ] || return 1
    [ -r "$file" ] || return 1
    # ... actual logic
    return 0
}

The second approach is called "early return." Each failure scenario can be tested in one line — for example, process_file "/nonexistent" tests the failure path for a non-existent file.

2.5 Pass Configuration Files via Environment Variables or Parameters

In the previous article, our load_config function accepted a filename as a parameter. This means during testing, you can pass any temporary file without polluting the real configuration.

# During testing
load_config "/tmp/test_config_$$"

$$ is the process ID, making each test case's file independent.

3. Writing Your Own Lightweight Assert Framework

The core of shell testing is determining whether actual behavior equals expected behavior. This judgment requires a set of "assertion" functions. Python has unittest.assertEqual; in shell, we write our own.

3.1 The Most Basic Assert Functions

# tests/lib_test.sh - Test utility library
[[ -n "${_LIB_TEST_LOADED:-}" ]] && return
_LIB_TEST_LOADED=1

# Test statistics
TESTS_RUN=0
TESTS_PASSED=0
TESTS_FAILED=0
FAILED_TESTS=()

# ============================================================
# Assertion Functions
# ============================================================

# Generic failure handler
_assert_fail() {
    local test_name=$1
    local message=$2
    local actual=$3
    local expected=$4

    echo "  ✗ $test_name" >&2
    echo "     Message: $message" >&2
    if [[ -n "$expected" ]]
    then
        echo "     Expected: $expected" >&2
        echo "     Actual: $actual" >&2
    fi
    FAILED_TESTS+=("$test_name")
    ((TESTS_FAILED++)) || true
}

# assert_eq <test_name> <actual_value> <expected_value> [message]
assert_eq() {
    local test_name=$1
    local actual=$2
    local expected=$3
    local message=${4:-}

    ((TESTS_RUN++)) || true

    if [[ "$actual" == "$expected" ]]
    then
        echo "  ✓ $test_name" >&2
        ((TESTS_PASSED++)) || true
    else
        _assert_fail "$test_name" "$message" "$actual" "$expected"
    fi
}

# assert_contains <test_name> <actual_value> <expected_substring> [message]
assert_contains() {
    local test_name=$1
    local actual=$2
    local expected=$3
    local message=${4:-}

    ((TESTS_RUN++)) || true

    if [[ "$actual" == *"$expected"* ]]
    then
        echo "  ✓ $test_name" >&2
        ((TESTS_PASSED++)) || true
    else
        _assert_fail "$test_name" "$message" "$actual" "Contains '$expected'"
    fi
}

# assert_success <test_name> <exit_code> [message]
assert_success() {
    local test_name=$1
    local actual=$2
    local message=${3:-}

    ((TESTS_RUN++)) || true

    if [[ "$actual" -eq 0 ]]
    then
        echo "  ✓ $test_name" >&2
        ((TESTS_PASSED++)) || true
    else
        _assert_fail "$test_name" "$message" "$actual" "0"
    fi
}

# assert_failure <test_name> <exit_code> [message]
assert_failure() {
    local test_name=$1
    local actual=$2
    local message=${3:-}

    ((TESTS_RUN++)) || true

    if [[ "$actual" -ne 0 ]]
    then
        echo "  ✓ $test_name" >&2
        ((TESTS_PASSED++)) || true
    else
        _assert_fail "$test_name" "$message" "$actual" "Non-zero"
    fi
}

# assert_true <test_name> <actual_value> [message]
assert_true() {
    local test_name=$1
    local actual=$2
    local message=${3:-}

    ((TESTS_RUN++)) || true

    if [[ "$actual" == "true" || "$actual" == "0" ]]
    then
        echo "  ✓ $test_name" >&2
        ((TESTS_PASSED++)) || true
    else
        _assert_fail "$test_name" "$message" "$actual" "true"
    fi
}

# assert_file_exists <test_name> <file_path> [message]
assert_file_exists() {
    local test_name=$1
    local file=$2
    local message=${3:-}

    ((TESTS_RUN++)) || true

    if [[ -f "$file" ]]
    then
        echo "  ✓ $test_name" >&2
        ((TESTS_PASSED++)) || true
    else
        _assert_fail "$test_name" "$message" "File does not exist: $file" "File exists"
    fi
}

# ============================================================
# Test Summary
# ============================================================
print_summary() {
    echo "" >&2
    echo "========================================" >&2
    echo "Test Results" >&2
    echo "========================================" >&2
    echo "Run: $TESTS_RUN, Passed: $TESTS_PASSED, Failed: $TESTS_FAILED" >&2

    if [[ $TESTS_FAILED -gt 0 ]]
    then
        echo "" >&2
        echo "Failed Tests:" >&2
        for t in "${FAILED_TESTS[@]}"
        do
            echo "  - $t" >&2
        done
        return 1
    fi

    echo "All passed!" >&2
    return 0
}

This assert library covers 90% of assertion needs in shell testing:

All error messages are output to stderr (>&2), not affecting stdout for capturing test results. All test functions accumulate test results in global variables (this is a compromise of shell testing — shared state is necessary).

3.2 A Complete Test Case

# tests/test_log.sh - Log library tests

source "$(dirname "${BASH_SOURCE[0]}")/../lib/lib_log.sh"
source "$(dirname "${BASH_SOURCE[0]}")/lib_test.sh"

# Test 1: log_info output format is correct
test_log_info_format() {
    local output
    output=$(LOG_LEVEL=INFO log_info "test message" 2>&1)
    assert_contains "log_info contains timestamp" "$output" "["
    assert_contains "log_info contains level" "$output" "[INFO]"
    assert_contains "log_info contains message" "$output" "test message"
}

# Test 2: Log level filtering
test_log_level_filter() {
    # DEBUG level should not output under default LOG_LEVEL=INFO
    local output
    output=$(LOG_LEVEL=INFO log_debug "debug message" 2>&1)
    assert_eq "debug does not output when LOG_LEVEL=INFO" "$output" ""

    # But should output when LOG_LEVEL=DEBUG
    output=$(LOG_LEVEL=DEBUG log_debug "debug message" 2>&1)
    assert_contains "debug outputs when LOG_LEVEL=DEBUG" "$output" "debug message"
}

# Test 3: Log output to file
test_log_to_file() {
    local tmpfile="/tmp/test_log_$$"
    LOG_LEVEL=INFO LOG_FILE="$tmpfile" log_info "to file" 2>/dev/null

    assert_file_exists "Log file is created" "$tmpfile"

    local content
    content=$(cat "$tmpfile")
    assert_contains "File contains message" "$content" "to file"

    rm -f "$tmpfile"
}

# ============================================================
# Test Entry Point
# ============================================================
echo "Running test_log.sh ..." >&2

test_log_info_format
test_log_level_filter
test_log_to_file

print_summary

Running the tests:

$ bash tests/test_log.sh
Running test_log.sh ...
  ✓ log_info contains timestamp
  ✓ log_info contains level
  ✓ log_info contains message
  ✓ debug does not output when LOG_LEVEL=INFO
  ✓ debug outputs when LOG_LEVEL=DEBUG
  ✓ Log file is created
  ✓ File contains message
========================================
Test Results
========================================
Run: 7, Passed: 7, Failed: 0
All passed!

Compared to Python's pytest, this testing framework is much uglier — no auto-discovery, no parameterization, no fixtures. But it works, and it's sufficient for daily shell testing.

3.3 Test Wrapper

If you want a specific assert failure to immediately stop the entire test function, you can add a wrapper:

# Test function wrapper
run_test() {
    local test_func=$1

    echo "" >&2
    echo "[$test_func]" >&2

    # Reset failure count before each test (local)
    local failed_before=$TESTS_FAILED

    "$test_func"

    local failed_after=$TESTS_FAILED
    if [[ $failed_after -gt $failed_before ]]
    then
        echo "  Test $test_func has failures" >&2
    fi
}

Or use set +e to wrap a single assert block:

test_something() {
    set +e
    assert_eq "case 1" "a" "b"   # This failure does not exit
    assert_eq "case 2" "c" "d"   # This continues to test
    set -e
}

Shell test control flow is much more troublesome than Python's, but as long as you write small functions and focused assertions, the complexity won't explode.

4. Handling External Dependencies

External dependencies of shell functions mainly fall into three categories: file system, network, and external commands. This section covers how to fake these dependencies to make tests run fast and stable.

4.1 Isolating the File System with Temporary Directories

When tests involve files, always use temporary directories; never pollute the real file system:

setup_tmpdir() {
    TEST_TMPDIR=$(mktemp -d /tmp/shell_test.XXXXXX)
    trap "rm -rf '$TEST_TMPDIR'" EXIT
}

# Call at the beginning of each test function
test_something() {
    setup_tmpdir
    # Create files, operate, assert within $TEST_TMPDIR
    touch "$TEST_TMPDIR/test_file"
    assert_file_exists "File is created" "$TEST_TMPDIR/test_file"
}

mktemp -d creates an independent directory, $$ guarantees uniqueness, and trap "rm -rf" EXIT cleans up. This is standard hygiene for shell testing.

4.2 Isolating External Commands with PATH

When tests use commands like curl or ssh, you cannot let the tests actually send requests. The simplest method is to replace PATH:

# tests/test_deploy.sh

source "$(dirname "${BASH_SOURCE[0]}")/../lib/lib_common.sh"
source "$(dirname "${BASH_SOURCE[0]}")/lib_test.sh"

# Create mock tool directory
MOCK_BIN=$(mktemp -d)

# Create a fake curl (doesn't actually send requests, only logs calls)
cat > "$MOCK_BIN/curl" <<'EOF'
#!/bin/bash
echo "MOCK curl called: $*" >> /tmp/mock_calls.log
echo "Mock response"
EOF
chmod +x "$MOCK_BIN/curl"

# Add mock directory to the front of PATH
export PATH="$MOCK_BIN:$PATH"

# When calling curl in the test, the mock version is actually executed
test_deploy_calls_api() {
    rm -f /tmp/mock_calls.log
    deploy_to_api "https://example.com" "data"
    assert_file_exists "mock log is created" /tmp/mock_calls.log
    assert_contains "curl was called" "$(cat /tmp/mock_calls.log)" "curl"
}

test_deploy_calls_api

PATH="$MOCK_BIN:$PATH" makes the curl command in the script actually execute the mock script we wrote. This is the core technique of shell testing — replacing any external command by modifying environment variables.

4.3 Function-Level Mocking

If the function under test calls other shell functions, you can override them with empty functions of the same name:

# tests/test_deploy.sh

# Real function: would connect to the server
deploy_to_server() {
    local host=$1
    ssh "$host" "systemctl restart myapp"
}

# Override during testing: does nothing, only logs parameters
deploy_to_server() {
    echo "MOCK deploy_to_server: $1" >> /tmp/mock_calls.log
    return 0
}

# Now calling deploy_to_server executes the mock version
test_deploy() {
    rm -f /tmp/mock_calls.log
    main_deploy "dev"
    assert_contains "deploy_to_server was called" "$(cat /tmp/mock_calls.log)" "deploy_to_server"
}

But there is a problem: the overridden function must first be sourced or defined. If the original function is defined in the script under test, the test file must first source that script, then redefine the function with the same name.

4.4 Handling Time Dependencies

Many scripts depend on date, sleep, or cron timing. How to control this during testing?

Approach 1: Accept a "time function" parameter.

# Bad approach
is_expired() {
    local expiry=$1
    [[ $(date +%s) -gt $expiry ]]
}

# Good approach
is_expired() {
    local expiry=$1
    local now=${2:-$(date +%s)}   # Default to real time, but can be passed in
    [[ "$now" -gt "$expiry" ]]
}

# Test
test_is_expired() {
    # Simulate current time as 2024-01-01
    local fake_now=$(date -d "2024-01-01" +%s)
    local expiry=$(date -d "2024-12-31" +%s)

    assert_false "Not expired" "$(is_expired "$expiry" "$fake_now" && echo true || echo false)"
}

Approach 2: Override the date command with an environment variable.

# Provide a mock date
cat > "$MOCK_BIN/date" <<'EOF'
#!/bin/bash
# Fixed return of 2024-01-01 during testing
case "$*" in
    "+%s")
        echo "1704067200"  # 2024-01-01 00:00:00 UTC
        ;;
    *)
        /usr/bin/date "$@"
        ;;
esac
EOF
chmod +x "$MOCK_BIN/date"

Approach 3: Store time as a variable in global scope.

CURRENT_TIME=${CURRENT_TIME:-$(date +%s)}
is_expired() {
    local expiry=$1
    [[ "$CURRENT_TIME" -gt "$expiry" ]]
}

During testing:

CURRENT_TIME="1704067200" is_expired "..."

The three approaches suit different scenarios. The first is the cleanest (function explicitly accepts a time parameter), the third is the simplest (suitable for small scripts), and the second is the most flexible (suitable for scenarios with complex time dependencies).

4.5 Handling Network and Database

Network and database are the hardest to fully mock in shell testing — they have protocols, state, and side effects. Common practices:

Network (curl, ssh, scp): Use PATH mocking (Section 4.2). Or use nc to start a local fake service.

Database (mysql, psql): Similarly, PATH mocking to simulate returned results.

Third-party APIs: Use PATH mocking to replace curl. For complex interfaces, use python -m http.server to start a local fake service.

Practical advice: In business scripts, wrap external API calls into functions:

# lib_api.sh
api_call() {
    local endpoint=$1
    curl -s "https://api.example.com/$endpoint"
}

# During testing
api_call() {
    echo '{"status":"ok","data":[]}'   # mock return
}

A single encapsulation point makes mocking much simpler — you only need to override one function to pretend an API was called.

5. Test Organization

As test cases increase, how to organize them becomes a problem. This section covers a small but complete test directory structure.

5.1 Standard tests Directory

project/
├── bin/
├── lib/
├── src/
└── tests/
    ├── lib_test.sh              # Test utility library
    ├── test_log.sh              # Log library tests
    ├── test_config.sh           # Config library tests
    ├── test_deploy.sh           # Deploy business tests
    ├── test_integration.sh      # Integration tests
    ├── fixtures/                # Test data
    │   ├── config_valid.conf
    │   └── config_invalid.conf
    └── run_all_tests.sh         # Test entry point

The test_*.sh naming makes test auto-discovery simple — any test_*.sh file is a test suite.

5.2 Test Entry Point: run_all_tests.sh

#!/bin/bash
# tests/run_all_tests.sh - Run all tests

set -euo pipefail

SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)

# Load test utilities
source "$SCRIPT_DIR/lib_test.sh"

# Find all test files
test_files=("$SCRIPT_DIR"/test_*.sh)

if [[ ${#test_files[@]} -eq 0 ]]
then
    echo "No test files found" >&2
    exit 1
fi

echo "Found ${#test_files[@]} test files" >&2
echo "" >&2

# Cumulative statistics
TOTAL_RUN=0
TOTAL_PASSED=0
TOTAL_FAILED=0

# Run each test file
for test_file in "${test_files[@]}"
do
    if [[ "$(basename "$test_file")" = "lib_test.sh" || "$(basename "$test_file")" = "run_all_tests.sh" ]]
    then
        continue
    fi

    echo "Running $(basename "$test_file") ..." >&2
    echo "----------------------------------------" >&2

    # Reset counters
    TESTS_RUN=0
    TESTS_PASSED=0
    TESTS_FAILED=0
    FAILED_TESTS=()

    # Run tests
    if bash "$test_file"
    then
        :  # Test file itself succeeded
    else
        :  # print_summary will return non-zero
    fi

    TOTAL_RUN=$((TOTAL_RUN + TESTS_RUN))
    TOTAL_PASSED=$((TOTAL_PASSED + TESTS_PASSED))
    TOTAL_FAILED=$((TOTAL_FAILED + TESTS_FAILED))
done

# Print summary
echo "" >&2
echo "========================================" >&2
echo "Total Test Results" >&2
echo "========================================" >&2
echo "Run: $TOTAL_RUN, Passed: $TOTAL_PASSED, Failed: $TOTAL_FAILED" >&2

if [[ $TOTAL_FAILED -gt 0 ]]
then
    exit 1
fi
exit 0

Running all tests:

$ bash tests/run_all_tests.sh
Found 4 test files

Running test_log.sh ...
----------------------------------------
  ✓ log_info contains timestamp
  ...

========================================
Total Test Results
========================================
Run: 28, Passed: 27, Failed: 1

Any test failure causes the entire script to exit non-zero — exactly the signal a CI system needs.

5.3 Fixtures: Test Data

Complex tests need test fixtures — pre-prepared input data:

# tests/fixtures/config_valid.conf
DB_HOST=localhost
DB_PORT=3306
DB_USER=admin
DB_PASSWORD=secret
# tests/test_config.sh

test_load_valid_config() {
    source "$(dirname "${BASH_SOURCE[0]}")/../lib/lib_config.sh"

    local config="$SCRIPT_DIR/fixtures/config_valid.conf"
    load_config "$config"

    assert_eq "DB_HOST loaded correctly" "$DB_HOST" "localhost"
    assert_eq "DB_PORT loaded correctly" "$DB_PORT" "3306"
    assert_eq "DB_USER loaded correctly" "$DB_USER" "admin"
}

test_load_missing_config() {
    source "$(dirname "${BASH_SOURCE[0]}")/../lib/lib_config.sh"

    local missing="/tmp/nonexistent_$$"
    local exit_code=0
    load_config "$missing" || exit_code=$?

    assert_failure "Loading non-existent config should fail" "$exit_code"
}

Fixture files are only placed in tests/fixtures/, completely separating production code and test code.

5.4 setUp / tearDown Pattern

Python's unittest has setUp and tearDown that execute before and after each test. In shell, you can simulate this with wrapper functions:

# tests/lib_test.sh

# Test counter
TEST_COUNT=0

# Initialization before each test
setUp() {
    TEST_COUNT=$((TEST_COUNT + 1))
    TEST_TMPDIR=$(mktemp -d /tmp/shell_test.XXXXXX)
    export TEST_TMPDIR
}

# Cleanup after each test
tearDown() {
    if [[ -n "$TEST_TMPDIR" && -d "$TEST_TMPDIR" ]]
    then
        rm -rf "$TEST_TMPDIR"
    fi
}

# Test wrapper
run_test() {
    local test_func=$1
    setUp
    "$test_func"
    local exit_code=$?
    tearDown
    return $exit_code
}

Usage:

test_log_writes_to_file() {
    local logfile="$TEST_TMPDIR/test.log"
    LOG_FILE="$logfile" log_info "hello"

    assert_file_exists "Log file is created" "$logfile"
}

# Register test
run_test test_log_writes_to_file

run_test automatically handles setUp and tearDown, so each test runs in an independent temporary directory.

6. Integration Testing

Unit tests verify whether functions are correct; integration tests verify whether the combination can run. This section covers several typical integration testing scenarios.

6.1 End-to-End Script Testing

# tests/test_integration.sh

source "$(dirname "${BASH_SOURCE[0]}")/lib_test.sh"
source "$(dirname "${BASH_SOURCE[0]}")/../lib/lib_common.sh"

PROJECT_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
SCRIPT="$PROJECT_ROOT/bin/myscript"

test_help_command() {
    local output
    output=$("$SCRIPT" --help 2>&1)
    local exit_code=$?

    assert_success "--help exit code is 0" "$exit_code"
    assert_contains "Help message mentions usage" "$output" "Usage"
}

test_missing_args() {
    local output
    output=$("$SCRIPT" 2>&1)
    local exit_code=$?

    assert_failure "No arguments should fail" "$exit_code"
    assert_contains "Error message mentions usage" "$output" "Usage"
}

test_version_flag() {
    local output
    output=$("$SCRIPT" --version 2>&1)

    assert_contains "Version number is output" "$output" "1.0.0"
}

test_help_command
test_missing_args
test_version_flag

print_summary

Integration tests do not mock; they directly call the script. They verify that the script can run as a whole, catching edge cases that unit tests miss.

6.2 Running Full Workflows in a Temporary Environment

Integration tests for deployment scripts can be designed like this: use a temporary directory as the production environment, run the complete deployment workflow, and verify the final state.

# tests/test_deploy_integration.sh

setup_test_env() {
    # Create a simulated "remote server" directory
    export FAKE_REMOTE=$(mktemp -d)
    mkdir -p "$FAKE_REMOTE/app"
    echo "v1.0.0" > "$FAKE_REMOTE/app/VERSION"

    # Replace SSH with local cp
    cat > "$FAKE_REMOTE/ssh" <<EOF
#!/bin/bash
# mock ssh: first argument is "host", ignore it, execute the following commands
shift
eval "$*"
EOF
    chmod +x "$FAKE_REMOTE/ssh"
    export PATH="$FAKE_REMOTE:$PATH"
}

test_full_deploy_workflow() {
    setup_test_env

    local config="$TEST_TMPDIR/deploy.conf"
    cat > "$config" <<EOF
DEPLOY_HOST=fake-host
DEPLOY_USER=tester
APP_VERSION=v1.2.0
EOF

    # Call the deployment script
    "$PROJECT_ROOT/bin/deploy" deploy dev

    # Verify the final state
    local version
    version=$(cat "$FAKE_REMOTE/app/VERSION")
    assert_eq "Version is updated" "$version" "v1.2.0"
}

Integration tests are slower than unit tests, but they verify that real-world scenarios can run. During daily development, you can run only unit tests; run integration tests before release.

6.3 Running Tests in CI

The final step is connecting tests to a CI system. GitHub Actions example:

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Run shell tests
        run: bash tests/run_all_tests.sh
      - name: Check exit code
        run: exit 0

When bash tests/run_all_tests.sh exits non-zero, the CI job turns red. Any PR merged into the main branch must pass the tests.

7. Summary

Shell script testing is different from other languages — it lacks an out-of-the-box framework like pytest, but the core ideas are consistent: write testable code, express expectations with asserts, isolate side effects, organize tests, and integrate with CI. This article covered the complete process:

The most important insight is: shell testing is not as simple as applying a framework; it must solve the specific problems of side effect isolation, state control, and exit code expression. Once you understand these, your own assert library, your own mocking scheme, and your own test organization — all fall into place naturally.


The examples in this article were tested in GNU bash 4.3+ environments. mktemp -d is available on all mainstream systems. PATH mocking works on all POSIX systems. The CI integration example uses GitHub Actions; configurations for other CI systems (GitLab CI, Jenkins) are largely similar.