#!/usr/bin/env bash # ============================================================================ # 单元测试运行器(零依赖,纯 bash) # # 用法: # ./tests/run.sh 运行全部用例 # ./tests/run.sh cache 仅运行匹配名字的用例文件(如 cache、filename) # ./tests/run.sh -v 显示每个用例名称(默认只显示失败与汇总) # # 设计: # - 按 src/modules.list 顺序加载全部模块(跳过 lib/entry.sh,避免触发 main) # - 每个用例文件在独立子 shell 中运行:全局状态自动隔离、互不污染 # - 用例文件可定义可选 setup()(每个测试前调用)与 teardown()(测试后调用) # - 断言使用 tests/lib/assert.sh,失败返回非 0,不影响其余用例继续执行 # ============================================================================ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" VERBOSE=false FILTER="" for a in "$@"; do case "$a" in -v) VERBOSE=true ;; *) FILTER="$a" ;; esac done # 加载全部模块(bashly 源码树): # - src/lib/main.sh 常量与全局变量最先 # - src/lib/*.sh 纯函数定义库 # - 跳过 src/root_command.sh(bashly 命令文件为顶层代码,仅由 bashly 包装执行) # 注意:必须在顶层加载(不能包在函数里)——模块中的 declare -A 在函数内 # 会变成函数局部变量,函数返回后关联数组全部丢失。 # shellcheck source=/dev/null source "$ROOT/src/lib/main.sh" while IFS= read -r -d '' m; do # main.sh 已加载(readonly 常量不能重复 source) [[ "$(basename "$m")" == "main.sh" ]] && continue # shellcheck source=/dev/null source "$m" done < <(find "$ROOT/src/lib" -name '*.sh' -print0 | sort -z) # 断言库 # shellcheck source=/dev/null source "$ROOT/tests/lib/assert.sh" # 测试环境约定:_log 仅作日志输出(不参与业务逻辑),覆盖为 no-op 保持测试输出干净; # 如需断言日志行为,可在具体用例中临时重新定义。 _log() { return 0; } pass=0 fail=0 failed_names=() run_case_file() { local case_file="$1" local case_name case_name="$(basename "$case_file" .sh)" # 子 shell:全局状态隔离(每个用例文件一组干净状态) ( set +e # shellcheck source=/dev/null source "$case_file" local fn for fn in $(compgen -A function | grep '^test_' | sort); do if declare -F setup >/dev/null; then setup; fi # 注意:不能在 if 条件中运行 ( set -e; "$fn" )——bash 的条件上下文会 # 抑制子 shell 内的 errexit(多断言测试会漏报)。先执行再取退出码。 # stdout 吞掉(_log 等噪声),stderr 透传(断言失败诊断可见) ( set -e "$fn" ) >/dev/null rc=$? if ((rc == 0)); then printf 'PASS\t%s\t%s\n' "$case_name" "$fn" else printf 'FAIL\t%s\t%s\n' "$case_name" "$fn" fi if declare -F teardown >/dev/null; then teardown; fi done ) } while IFS= read -r -d '' case_file; do if [[ -n "$FILTER" ]] && [[ "$(basename "$case_file" .sh)" != *"$FILTER"* ]]; then continue fi [[ $VERBOSE == true ]] && echo "── $case_file" while IFS=$'\t' read -r kind case_name fn; do if [[ "$kind" == "PASS" ]]; then pass=$((pass + 1)) [[ $VERBOSE == true ]] && echo " ✅ $case_name/$fn" elif [[ "$kind" == "FAIL" ]]; then fail=$((fail + 1)) failed_names+=("$case_name/$fn") echo " ❌ $case_name/$fn" fi done < <(run_case_file "$case_file") done < <(find "$ROOT/tests/cases" -name '*.sh' -print0 | sort -z) echo "" echo "════════════════════════════════════" echo "结果:$pass 通过,$fail 失败" if ((fail > 0)); then printf '失败用例:\n' for n in "${failed_names[@]}"; do echo " - $n" done exit 1 fi