skills/shell-testing/SKILL.md
Write idiomatic BATS tests for bash and zsh shell scripts. Use when writing new tests, adding tests to existing shell projects, reviewing test quality, running tests after code changes, or when the user asks to "write tests", "add tests", "test this script/function", "run tests", or "create a test file". Also triggers on: .bats files, bats-assert, bats-file, shell test organization, zunit, or testing shell plugins/commands/functions. Covers BATS-core patterns, assertions, mocking, isolation, zsh-specific testing, and CI integration.
npx skillsauth add synapseradio/ai-skills shell-testingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
NEVER run the full test suite. Always run only the test files affected by the current changes.
lib/helpers.sh → tests/helpers.bats, lib/ui.sh → tests/ui.bats, cmd/theme/* → tests/theme.batsbats tests/helpers.bats tests/theme.batsbats tests/helpers.bats --filter "format_insert"bats tests/ --filter-tags "unit"When multiple source files changed, run only their corresponding test files. If unsure which tests cover a function, grep for the function name across tests/*.bats.
bats --shell zsh (BATS 1.8.0+): Run BATS tests under zsh for polyglot verification#!/usr/bin/env bats
load test_helper/bats-support/load
load test_helper/bats-assert/load
load test_helper/bats-file/load # Only if testing filesystem
setup() {
source "${BATS_TEST_DIRNAME}/../lib/module_under_test.sh"
}
# bats test_tags=unit
@test "function_name returns expected output for valid input" {
run function_name "valid_input"
assert_success
assert_output "expected"
}
# bats test_tags=unit
@test "function_name fails with clear error on bad input" {
run function_name "bad_input"
assert_failure
assert_output --partial "error"
}
These are non-negotiable requirements for all shell tests. Violating these rules creates side effects and pollutes the real environment.
# NEVER — writes to real framework directory
MANIFEST_DIR="${DOTTY_ROOT}/local"
BACKUP_DIR="${PROJECT_ROOT}/backups"
# ALWAYS — create parallel structure in test temp space
MANIFEST_DIR="${TEST_TMPDIR}/mock-local"
BACKUP_DIR="${TEST_TMPDIR}/mock-backups"
mkdir -p "$MANIFEST_DIR" "$BACKUP_DIR"
If production code needs to write to framework directories, add override env var:
# In production code (e.g., cmd/dotty-undo)
DOTTY_LOCAL="${DOTTY_LOCAL_OVERRIDE:-${DOTTY_ROOT}/local}"
# In tests
export DOTTY_LOCAL_OVERRIDE="${TEST_TMPDIR}/mock-local"
# Mock git submodule
cat << 'EOF' > "${TEST_TMPDIR}/git"
#!/usr/bin/env bash
if [[ "$1" == "submodule" ]]; then
echo "[mock] git submodule $*" >&2
exit 0
fi
exec /usr/bin/git "$@" # Pass through other commands
EOF
chmod +x "${TEST_TMPDIR}/git"
export PATH="${TEST_TMPDIR}:${PATH}"
# Mock package managers, installers, etc.
mock_brew() {
cat << 'EOF' > "${TEST_TMPDIR}/brew"
#!/usr/bin/env bash
echo "[mock] brew $*" >&2
exit 0
EOF
chmod +x "${TEST_TMPDIR}/brew"
}
Every command that could be called by tests must detect test mode:
# At the top of cmd/dotty-install, cmd/dotty-update, etc.
if [[ -n "${BATS_TEST_TMPDIR:-}" ]]; then
echo "[test mode] Skipping git submodule operations" >&2
else
git submodule sync --quiet --recursive vendor/dotbot
git submodule update --init --recursive vendor/dotbot
fi
Add to all test files before any tests run:
# Verify we're in test mode
if [[ -z "${BATS_TEST_TMPDIR:-}" ]]; then
echo "ERROR: Not running in BATS environment" >&2
exit 1
fi
# Verify dotfiles dir is not real (adapt to your project)
if [[ "${DOTTY_DOTFILES_DIR:-}" == "${HOME}/.dotfiles" ]]; then
echo "ERROR: DOTTY_DOTFILES_DIR points to real dotfiles" >&2
exit 1
fi
# NEVER — fails if tool missing
@test "tool is available" {
run command -v mytool
assert_success
}
# ALWAYS — skips gracefully
@test "feature works with mytool" {
if ! command -v mytool &>/dev/null; then
skip "mytool not installed (optional)"
fi
run use_mytool
assert_success
}
[ ]# YES
run my_func; assert_success; assert_output --partial "done"
# NO — cryptic failure messages
run my_func; [ "$status" -eq 0 ]; [[ "$output" == *"done"* ]]
run when testing exit codes or output; direct calls for side effects# run: capture status + output
run parse_config "$file"
assert_success
assert_output --partial "key=value"
# direct: need side effects in test scope
source lib/config.sh
load_config # sets $CONFIG_VALUE in current scope
[ "$CONFIG_VALUE" = "loaded" ]
Format: "<subject> <verb describing behavior> [when <condition>]"
@test "parse_config returns key-value pairs from valid file" { ... }
@test "parse_config fails with clear error on missing file" { ... }
$BATS_TEST_TMPDIR for temp files (auto-cleaned)@test "creates output file" {
run my_func --output "$BATS_TEST_TMPDIR/result.txt"
assert_success
assert_file_exist "$BATS_TEST_TMPDIR/result.txt"
}
rm -rf in teardownteardown() {
[ -n "$TEST_TMPDIR" ] && [ -d "$TEST_TMPDIR" ] && rm -rf "$TEST_TMPDIR"
}
# bats test_tags=unit
@test "isolated function test" { ... }
# bats test_tags=integration
@test "full workflow test" { ... }
Run: bats tests/ --filter-tags "unit" or bats tests/ --filter-tags "!integration"
run Semantics| Form | Behavior |
|------|----------|
| run cmd | Capture exit code + stdout+stderr |
| run -0 cmd | Assert exit 0 |
| run -1 cmd | Assert exit 1 |
| run ! cmd | Assert nonzero exit |
| run --separate-stderr cmd | Split stdout/stderr into $output/$stderr |
After run: $status, $output, ${lines[@]}, $stderr (if --separate-stderr), ${stderr_lines[@]}
assert_success # status == 0
assert_failure [code] # status != 0 (or == code)
assert_output "exact text" # exact match
assert_output --partial "substring" # contains
assert_output --regexp "^pattern$" # regex
assert_line "text" # any line equals
assert_line --index 0 "first line" # specific line
assert_line --partial "text" # any line contains
refute_output "text" # does NOT match
refute_line "text" # no line matches
assert_file_exist "$path" # bats-file
assert_dir_exist "$path" # bats-file
setup_file() → Once before all tests in file (expensive shared setup)
setup() → Before each test (source code, create per-test state)
@test → Runs in subshell (isolated)
teardown() → After each test, even on failure
teardown_file() → Once after all tests in file
See "Test Isolation Requirements (MANDATORY)" above for critical mocking rules.
Additional mocking patterns — prefer function overrides over bats-mock:
@test "handles API response" {
curl() { echo '{"status":"ok"}'; }
export -f curl
run my_api_caller
assert_output --partial "ok"
}
For CLI stubs, use PATH manipulation:
@test "uses external tool" {
mkdir -p "$BATS_TEST_TMPDIR/bin"
printf '#!/bin/bash\necho "stubbed"' > "$BATS_TEST_TMPDIR/bin/mytool"
chmod +x "$BATS_TEST_TMPDIR/bin/mytool"
run env PATH="$BATS_TEST_TMPDIR/bin:$PATH" my_script
assert_success
}
Polyglot code — test with BATS directly, optionally verify under zsh:
bats tests/my.bats # default (bash)
bats --shell zsh tests/my.bats # verify under zsh
Zsh-only features — invoke zsh explicitly or use zunit:
@test "zsh completion works" {
run zsh -c "source lib/completions.zsh; _my_completion"
assert_success
}
For zunit patterns, see references/zsh-testing.md.
One test file per source file. Shared helpers in test_helper.bash. Fixtures in fixtures/.
tests/
helpers.bats # tests for lib/helpers.sh
ui.bats # tests for lib/ui.sh
commands.bats # smoke tests for CLI commands
test_helper.bash # shared setup, custom assertions
fixtures/
sample_config.txt
tools
Open the possibility space through curiosity-driven questioning
tools
Interactive tutoring that adapts to your pace
data-ai
Trace justification chains to bedrock
tools
Follow reasoning step-by-step