跳到主要内容

调试与测试

调试是编写可靠脚本的关键技能。Shell 脚本没有像高级语言那样的完善调试工具,但 Bash 提供了多种调试机制。本章介绍 Shell 脚本的调试技巧、错误处理和测试方法。

调试选项

set 命令选项

set 命令可以控制 Shell 的行为,对于调试非常有用。

选项含义
-e命令失败时立即退出
-u使用未定义变量时报错
-x打印执行的命令
-v打印读取的行
-o pipefail管道中任何命令失败时返回失败

脚本开头添加选项

#!/bin/bash
set -e # 遇错退出
set -u # 未定义变量报错
set -o pipefail # 管道失败检测
set -x # 打印执行的命令

# 或者合并写法
set -euxo pipefail

命令行调试

# 打印执行的命令
bash -x script.sh

# 打印读取的行
bash -v script.sh

# 同时使用多个选项
bash -xv script.sh

# 检查语法错误(不执行)
bash -n script.sh

调试特定代码段

#!/bin/bash

echo "Starting script"

# 开启调试
set -x
for i in 1 2 3; do
echo "Number: $i"
done
set +x

echo "Debug section ended"

trap 调试

捕获 DEBUG 信号

#!/bin/bash

# 在每条命令执行前打印信息
trap 'echo "Executing: $BASH_COMMAND at line $LINENO"' DEBUG

x=1
y=2
echo $((x + y))

捕获 ERR 信号

#!/bin/bash

# 命令失败时打印信息
trap 'echo "Error at line $LINENO: $BASH_COMMAND"' ERR

false # 这个命令会失败
echo "This won't print if set -e is enabled"

打印调用栈

#!/bin/bash

print_stack() {
echo "Call stack:"
local i
for ((i=${#FUNCNAME[@]}-1; i>=0; i--)); do
echo " ${FUNCNAME[$i]}() at ${BASH_SOURCE[$i]}:${BASH_LINENO[$i-1]}"
done
}

func_c() {
print_stack
}

func_b() {
func_c
}

func_a() {
func_b
}

func_a

错误处理

检查命令结果

#!/bin/bash

# 方式一:使用 if
if ! command; then
echo "Command failed"
exit 1
fi

# 方式二:使用 && 和 ||
command && echo "Success" || echo "Failed"

# 方式三:检查 $?
command
if [[ $? -ne 0 ]]; then
echo "Command failed"
exit 1
fi

自定义错误处理函数

#!/bin/bash

error_exit() {
echo "Error: $1" >&2
exit "${2:-1}"
}

# 使用
[[ -f "$file" ]] || error_exit "File not found: $file" 2

完整的错误处理示例

#!/bin/bash
set -euo pipefail

# 错误处理函数
on_error() {
local exit_code=$?
local line_no=$1
local command=$2

echo "Error on line $line_no: '$command'"
echo "Exit code: $exit_code"

# 清理工作
cleanup

exit $exit_code
}

cleanup() {
echo "Cleaning up..."
rm -f "$temp_file"
}

# 设置 trap
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
trap cleanup EXIT

# 创建临时文件
temp_file="/tmp/script_$$"
touch "$temp_file"

# 主逻辑
echo "Working..."

日志记录

简单日志函数

#!/bin/bash

LOG_FILE="script.log"

log() {
local level=$1
shift
local message="$*"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')

echo "[$timestamp] [$level] $message" | tee -a "$LOG_FILE"
}

log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }

# 使用
log_info "Starting script"
log_warn "This is a warning"
log_error "This is an error"

带颜色的日志

#!/bin/bash

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

log_info() {
echo -e "${GREEN}[INFO]${NC} $*"
}

log_warn() {
echo -e "${YELLOW}[WARN]${NC} $*"
}

log_error() {
echo -e "${RED}[ERROR]${NC} $*"
}

log_debug() {
echo -e "${BLUE}[DEBUG]${NC} $*"
}

ShellCheck

ShellCheck 是一个 Shell 脚本静态分析工具,可以检测常见的错误和不良实践。

安装

# Ubuntu/Debian
sudo apt install shellcheck

# macOS
brew install shellcheck

# Windows (via scoop)
scoop install shellcheck

使用

# 检查脚本
shellcheck script.sh

# 指定 Shell 类型
shellcheck -s bash script.sh

# 输出格式
shellcheck -f gcc script.sh # GCC 风格
shellcheck -f json script.sh # JSON 格式

# 忽略特定警告
shellcheck -e SC2086 script.sh

常见警告

# SC2086: 双引号变量以防止词分割
echo $var # 警告
echo "$var" # 正确

# SC2039: 在 POSIX sh 中不可用
array=(a b c) # 在 sh 中不可用

# SC2164: cd 失败后使用 || exit
cd /tmp # 警告
cd /tmp || exit # 正确

# SC2181: 直接检查退出状态
command
if [ $? -eq 0 ]; then ... fi # 冗余
if command; then ... fi # 推荐

# SC2010: 不要通过 ls | grep 过滤
ls | grep ".txt" # 警告
ls *.txt # 正确

单元测试

Shell 脚本也可以进行单元测试,常用的测试框架有 shunit2 和 bats。

使用 shunit2

#!/bin/bash

# 被测试的函数
add() {
echo $(($1 + $2))
}

is_even() {
local num=$1
(( num % 2 == 0 ))
}

# 测试函数(以 test 开头)
testAdd() {
result=$(add 2 3)
assertEquals "2 + 3 should be 5" 5 "$result"

result=$(add 0 0)
assertEquals "0 + 0 should be 0" 0 "$result"
}

testIsEven() {
is_even 4
assertTrue "4 is even" $?

is_even 3
assertFalse "3 is not even" $?
}

# 加载 shunit2
. shunit2

使用 bats

#!/usr/bin/env bats

# 被测试的函数
add() {
echo $(($1 + $2))
}

@test "add 2 and 3" {
result=$(add 2 3)
[ "$result" -eq 5 ]
}

@test "add 0 and 0" {
result=$(add 0 0)
[ "$result" -eq 0 ]
}

@test "add negative numbers" {
result=$(add -1 -2)
[ "$result" -eq -3 ]
}

简单的手动测试

#!/bin/bash

# 被测试的函数
trim() {
local var="$1"
var="${var#"${var%%[![:space:]]*}"}"
var="${var%"${var##*[![:space:]]}"}"
echo "$var"
}

# 测试函数
test_trim() {
local input expected actual

# 测试 1
input=" hello "
expected="hello"
actual=$(trim "$input")
if [[ "$actual" == "$expected" ]]; then
echo "PASS: trim('$input') = '$actual'"
else
echo "FAIL: trim('$input') = '$actual', expected '$expected'"
return 1
fi

# 测试 2
input="world"
expected="world"
actual=$(trim "$input")
if [[ "$actual" == "$expected" ]]; then
echo "PASS: trim('$input') = '$actual'"
else
echo "FAIL: trim('$input') = '$actual', expected '$expected'"
return 1
fi

# 测试 3
input=""
expected=""
actual=$(trim "$input")
if [[ "$actual" == "$expected" ]]; then
echo "PASS: trim('$input') = '$actual'"
else
echo "FAIL: trim('$input') = '$actual', expected '$expected'"
return 1
fi
}

# 运行测试
test_trim

常见错误与解决

变量未加引号

# 错误
file="my document.txt"
ls $file # 词分割:ls my document.txt

# 正确
ls "$file"

忘记 shebang

# 错误:没有 shebang,使用当前 Shell 执行
echo "Hello"

# 正确
#!/bin/bash
echo "Hello"

条件判断错误

# 错误:使用 = 比较数字
if [ $a = $b ]; then ... fi

# 正确:使用 -eq 比较数字
if [ $a -eq $b ]; then ... fi

# 或者使用 (( ))
if (( a == b )); then ... fi

路径中的空格

# 错误
path="/path/with spaces/file.txt"
cat $path

# 正确
cat "$path"

子 Shell 中的变量修改

# 错误:管道在子 Shell 中执行
echo "hello" | read var
echo $var # 输出为空

# 正确:使用进程替换
read var <<< "hello"
echo $var # 输出:hello

# 或者使用 lastpipe
shopt -s lastpipe
echo "hello" | read var
echo $var

循环中的变量作用域

# 错误:循环变量在循环外仍存在
for i in 1 2 3; do
echo $i
done
echo $i # 输出:3

# 正确:使用子 Shell 或清理变量
(
for i in 1 2 3; do
echo $i
done
)
echo $i # 输出为空

调试技巧总结

快速定位问题

#!/bin/bash

# 1. 开启严格模式
set -euo pipefail

# 2. 添加调试输出
debug() {
if [[ "${DEBUG:-}" == "1" ]]; then
echo "[DEBUG] $*" >&2
fi
}

# 3. 使用 trap 捕获错误
trap 'echo "Error at line $LINENO"; exit 1' ERR

# 4. 关键步骤添加日志
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}

# 主逻辑
log "Starting..."
debug "Debug mode enabled"

使用 assert 函数

#!/bin/bash

assert() {
local condition=$1
local message=${2:-"Assertion failed"}

if ! eval "$condition"; then
echo "Assertion failed: $message" >&2
echo "Condition: $condition" >&2
exit 1
fi
}

# 使用
x=5
assert "[[ $x -eq 5 ]]" "x should be 5"
assert "(( x > 0 ))" "x should be positive"

性能分析

#!/bin/bash

# 使用 time 命令
time ./script.sh

# 使用 date 计算耗时
start=$(date +%s)

# ... 代码 ...

end=$(date +%s)
echo "Elapsed: $((end - start)) seconds"

# 使用 Bash 内置变量
SECONDS=0

# ... 代码 ...

echo "Elapsed: $SECONDS seconds"

小结

本章介绍了 Shell 脚本的调试和测试方法:

  • 调试选项set -euxo pipefailbash -xbash -n
  • trap 调试:捕获 DEBUG、ERR 信号
  • 错误处理:检查命令结果、自定义错误处理
  • 日志记录:日志函数、带颜色的输出
  • ShellCheck:静态分析工具
  • 单元测试:shunit2、bats
  • 常见错误:变量引号、条件判断、作用域问题

良好的调试习惯和完善的错误处理是编写可靠脚本的基础。使用 ShellCheck 进行静态分析,使用测试框架进行单元测试,可以大大提高脚本的质量。