test-shell

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Shell Testing with BATS

基于BATS的Shell脚本测试

BATS (Bash Automated Testing System) for testing shell scripts and libraries.
BATS(Bash自动化测试系统)用于测试Shell脚本和函数库。

Run Commands

运行命令

bash
undefined
bash
undefined

Run all tests recursively

递归运行所有测试

bats --recursive tests/bats/
bats --recursive tests/bats/

Run specific test file

运行指定测试文件

bats tests/bats/unit/lib/test_log.bats
bats tests/bats/unit/lib/test_log.bats

Run with TAP output (for CI)

以TAP格式输出结果(适用于CI环境)

bats --tap tests/bats/
bats --tap tests/bats/

Run with timing info

运行测试并显示耗时信息

bats --timing tests/bats/
bats --timing tests/bats/

Filter tests by name pattern

按名称模式筛选测试用例

bats --filter "validate_semver" tests/bats/
bats --filter "validate_semver" tests/bats/

Run in parallel

并行运行测试

bats --jobs 4 tests/bats/
undefined
bats --jobs 4 tests/bats/
undefined

Directory Structure

目录结构

text
tests/
├── bats/
│   ├── unit/
│   │   └── lib/
│   │       ├── test_log.bats
│   │       ├── test_fs.bats
│   │       ├── github/
│   │       │   └── test_output.bats
│   │       └── release/
│   │           └── test_version.bats
│   └── integration/
│       └── test_actions.bats
├── helpers/
│   ├── common.bash      # Shared utilities
│   ├── mocks.bash       # Command mocking
│   └── github_env.bash  # GitHub Actions simulation
└── fixtures/
    └── json/            # Test data files
text
tests/
├── bats/
│   ├── unit/
│   │   └── lib/
│   │       ├── test_log.bats
│   │       ├── test_fs.bats
│   │       ├── github/
│   │       │   └── test_output.bats
│   │       └── release/
│   │           └── test_version.bats
│   └── integration/
│       └── test_actions.bats
├── helpers/
│   ├── common.bash      # 共享工具函数
│   ├── mocks.bash       # 命令模拟工具
│   └── github_env.bash  # GitHub Actions环境模拟
└── fixtures/
    └── json/            # 测试数据文件

Test File Template

测试文件模板

bash
#!/usr/bin/env bats
bash
#!/usr/bin/env bats

SPDX-License-Identifier: MIT

SPDX-License-Identifier: MIT

Purpose: Tests for scripts/ci/lib/example.sh

用途:测试scripts/ci/lib/example.sh脚本

load "../helpers/common" load "../helpers/mocks"
setup() { setup_temp_dir export LIB_DIR }
teardown() { teardown_temp_dir }
load "../helpers/common" load "../helpers/mocks"
setup() { setup_temp_dir export LIB_DIR }
teardown() { teardown_temp_dir }

=============================================================================

=============================================================================

Function group description

函数组描述

=============================================================================

=============================================================================

@test "function_name: does expected thing" { run bash -c 'source "$LIB_DIR/example.sh" && function_name "arg"' assert_success assert_output "expected output" }
@test "function_name: handles edge case" { run bash -c 'source "$LIB_DIR/example.sh" && function_name ""' assert_failure assert_output --partial "error message" }
undefined
@test "function_name: 实现预期功能" { run bash -c 'source "$LIB_DIR/example.sh" && function_name "arg"' assert_success assert_output "预期输出" }
@test "function_name: 处理边界情况" { run bash -c 'source "$LIB_DIR/example.sh" && function_name ""' assert_failure assert_output --partial "错误提示" }
undefined

Assertions (bats-assert)

断言(bats-assert)

bash
undefined
bash
undefined

Exit code assertions

退出码断言

assert_success # Exit code 0 assert_failure # Exit code != 0
assert_success # 断言退出码为0 assert_failure # 断言退出码不为0

Output assertions

输出断言

assert_output "exact match" assert_output --partial "substring" assert_output --regexp "pattern.*here" refute_output # Output is empty refute_output --partial "not present"
assert_output "精确匹配内容" assert_output --partial "包含子字符串" assert_output --regexp "正则模式.*匹配" refute_output # 断言输出为空 refute_output --partial "不包含指定内容"

Line assertions (for multiline output)

行断言(针对多行输出)

assert_line "exact line" assert_line --partial "substring in any line" assert_line --index 0 "first line"
assert_line "精确匹配行内容" assert_line --partial "任意行包含子字符串" assert_line --index 0 "第一行内容"

Equality

相等性断言

assert_equal "$expected" "$actual"
undefined
assert_equal "$expected" "$actual"
undefined

File Assertions (bats-file)

文件断言(bats-file)

bash
assert_file_exists "/path/to/file"
assert_file_not_exists "/path/to/file"
assert_dir_exists "/path/to/dir"
assert_file_contains "/path/to/file" "expected content"
bash
assert_file_exists "/path/to/file"
assert_file_not_exists "/path/to/file"
assert_dir_exists "/path/to/dir"
assert_file_contains "/path/to/file" "预期文件内容"

Setup and Teardown

初始化与清理操作

bash
undefined
bash
undefined

Per-test setup/teardown

每个测试用例的初始化/清理

setup() { setup_temp_dir # Create $BATS_TEST_TMPDIR export LIB_DIR # Make available to subshells }
teardown() { teardown_temp_dir # Clean up temp files }
setup() { setup_temp_dir # 创建临时目录$BATS_TEST_TMPDIR export LIB_DIR # 让子shell可以访问该变量 }
teardown() { teardown_temp_dir # 清理临时文件 }

Per-file setup/teardown (run once)

每个测试文件的初始化/清理(仅运行一次)

setup_file() { # Heavy one-time setup (e.g., start services) }
teardown_file() { # One-time cleanup }
undefined
setup_file() { # 耗时较长的一次性初始化操作(如启动服务) }
teardown_file() { # 一次性清理操作 }
undefined

Mocking Commands

命令模拟

bash
undefined
bash
undefined

In mocks.bash helper:

在mocks.bash辅助工具中:

mock_command() { local cmd="$1" local output="$2" local exit_code="${3:-0}"
local mock_dir="${BATS_TEST_TMPDIR}/mocks"
mkdir -p "$mock_dir"

cat > "${mock_dir}/${cmd}" <<MOCK
#!/usr/bin/env bash echo "$output" exit $exit_code MOCK chmod +x "${mock_dir}/${cmd}" export PATH="${mock_dir}:$PATH" }
mock_command() { local cmd="$1" local output="$2" local exit_code="${3:-0}"
local mock_dir="${BATS_TEST_TMPDIR}/mocks"
mkdir -p "$mock_dir"

cat > "${mock_dir}/${cmd}" <<MOCK
#!/usr/bin/env bash echo "$output" exit $exit_code MOCK chmod +x "${mock_dir}/${cmd}" export PATH="${mock_dir}:$PATH" }

Usage in test:

在测试用例中使用:

@test "handles curl failure" { mock_command "curl" "Connection refused" 1 run my_download_function "http://example.com" assert_failure }
undefined
@test "处理curl请求失败场景" { mock_command "curl" "Connection refused" 1 run my_download_function "http://example.com" assert_failure }
undefined

Testing with Bash 4+ Features

针对Bash 4+特性的测试

macOS ships with Bash 3.2. For tests requiring Bash 4+ features:
bash
undefined
macOS默认预装Bash 3.2。对于需要Bash 4+特性的测试:
bash
undefined

In common.bash:

在common.bash中:

bash4_available() { [[ "${BASH4_AVAILABLE:-0}" -eq 1 ]] }
bash4_available() { [[ "${BASH4_AVAILABLE:-0}" -eq 1 ]] }

In tests:

在测试用例中:

@test "feature requiring bash 4+" { if ! bash4_available; then skip "requires bash 4+" fi # ... test code using ${var,,} or associative arrays }
undefined
@test "需要Bash 4+的功能" { if ! bash4_available; then skip "需要Bash 4+版本" fi # ... 使用${var,,}或关联数组的测试代码 }
undefined

GitHub Actions Environment Simulation

GitHub Actions环境模拟

bash
undefined
bash
undefined

In github_env.bash helper:

在github_env.bash辅助工具中:

setup_github_env() { export GITHUB_OUTPUT="${BATS_TEST_TMPDIR}/github_output" export GITHUB_ENV="${BATS_TEST_TMPDIR}/github_env" export GITHUB_STEP_SUMMARY="${BATS_TEST_TMPDIR}/step_summary" touch "$GITHUB_OUTPUT" "$GITHUB_ENV" "$GITHUB_STEP_SUMMARY" }
setup_github_env() { export GITHUB_OUTPUT="${BATS_TEST_TMPDIR}/github_output" export GITHUB_ENV="${BATS_TEST_TMPDIR}/github_env" export GITHUB_STEP_SUMMARY="${BATS_TEST_TMPDIR}/step_summary" touch "$GITHUB_OUTPUT" "$GITHUB_ENV" "$GITHUB_STEP_SUMMARY" }

Assert GitHub output was set:

断言GitHub输出已设置:

get_github_output() { local key="$1" grep "^${key}=" "$GITHUB_OUTPUT" | cut -d= -f2- }
@test "sets github output" { setup_github_env run bash -c 'source "$LIB_DIR/github/output.sh" && set_github_output "key" "value"' assert_success assert_equal "value" "$(get_github_output "key")" }
undefined
get_github_output() { local key="$1" grep "^${key}=" "$GITHUB_OUTPUT" | cut -d= -f2- }
@test "设置GitHub输出参数" { setup_github_env run bash -c 'source "$LIB_DIR/github/output.sh" && set_github_output "key" "value"' assert_success assert_equal "value" "$(get_github_output "key")" }
undefined

Coverage with kcov

基于kcov的代码覆盖率统计

bash
undefined
bash
undefined

Run with coverage

运行测试并生成覆盖率报告

kcov --include-path=scripts/ci/lib coverage-report bats tests/bats/
kcov --include-path=scripts/ci/lib coverage-report bats tests/bats/

CI workflow configuration

CI工作流配置

coverage: true coverage-threshold: 80 upload-coverage: true
undefined
coverage: true coverage-threshold: 80 upload-coverage: true
undefined

Best Practices

最佳实践

  1. One assertion focus per test - Test one behavior, use descriptive names
  2. Use setup/teardown - Never leave temp files behind
  3. Isolate tests - Each test should work independently
  4. Mock external commands - Don't depend on network/external state
  5. Group related tests - Use comment headers to organize
  6. Test edge cases - Empty strings, missing files, invalid input
  7. Keep tests fast - Avoid sleep, minimize I/O
  1. 每个测试聚焦单一断言 - 测试单一行为,使用描述性名称
  2. 使用初始化/清理操作 - 绝不遗留临时文件
  3. 测试用例隔离 - 每个测试应独立运行
  4. 模拟外部命令 - 不依赖网络或外部状态
  5. 相关测试分组 - 使用注释标题组织测试用例
  6. 测试边界情况 - 空字符串、缺失文件、无效输入等场景
  7. 保持测试高效 - 避免使用sleep,尽量减少IO操作

Common Patterns

常见模式

Testing Exit Codes

测试退出码

bash
@test "die: exits with code 1" {
    run bash -c 'source "$LIB_DIR/log.sh" && die "error"'
    assert_failure
    assert_equal "1" "$status"
}
bash
@test "die函数:以退出码1终止" {
    run bash -c 'source "$LIB_DIR/log.sh" && die "error"'
    assert_failure
    assert_equal "1" "$status"
}

Testing stderr vs stdout

测试stderr与stdout

bash
@test "logs to stderr" {
    run bash -c 'source "$LIB_DIR/log.sh" && log_error "msg" 2>&1'
    assert_output --partial "ERROR"
}
bash
@test "错误日志输出到stderr" {
    run bash -c 'source "$LIB_DIR/log.sh" && log_error "msg" 2>&1'
    assert_output --partial "ERROR"
}

Testing Function Exports

测试函数导出

bash
@test "function is exported" {
    source "$LIB_DIR/example.sh"
    run bash -c "declare -F my_function"
    assert_success
}
bash
@test "函数已导出" {
    source "$LIB_DIR/example.sh"
    run bash -c "declare -F my_function"
    assert_success
}

Skipping Tests

跳过测试用例

bash
@test "requires specific tool" {
    command -v special_tool >/dev/null || skip "special_tool not installed"
    # ... test code
}
bash
@test "依赖特定工具的测试" {
    command -v special_tool >/dev/null || skip "未安装special_tool"
    # ... 测试代码
}