Shell Testing
Decision: Bats vs Plain Assertions
- •Bats for anything with >3 test cases, fixtures, or mocking needs
- •Plain bash assertions for smoke tests or single-function validation
- •Always use
bats-core(not legacybats) -- it supportssetup_file/teardown_file, parallel execution
Bats Patterns That Matter
Mocking External Commands via PATH
Preferred over function mocking -- works across subshells and child processes:
bash
setup() {
STUBS_DIR="$TMPDIR/stubs"; mkdir -p "$STUBS_DIR"
export PATH="$STUBS_DIR:$PATH"
}
create_stub() {
local cmd="$1" output="$2" code="${3:-0}"
printf '#!/bin/bash\necho "%s"\nexit %s\n' "$output" "$code" > "$STUBS_DIR/$cmd"
chmod +x "$STUBS_DIR/$cmd"
}
Why: export -f mocking breaks across run boundaries and subshells. PATH stubs are reliable.
setup_file vs setup
- •
setup_file/teardown_file-- expensive one-time work (DB fixtures, compilation) - •
setup/teardown-- per-test isolation (temp dirs, env vars)
Always create temp dirs in setup, never reuse across tests.
Multi-line Output
bash
@test "parse multi-line" {
run my_command
[ "${lines[0]}" = "header" ]
[ "${lines[2]}" = "data row" ]
}
$output is the full string; ${lines[@]} is the array. Use lines for structured output validation.
Fixture Strategy
- •Copy fixtures to temp dir before mutating -- never modify originals
- •Use
$BATS_TEST_DIRNAMEto locate fixture dir relative to test file - •For generated fixtures, create helper functions, not inline heredocs
ShellCheck Opinions
Recommended .shellcheckrc
code
shell=bash enable=avoid-nullary-conditions,require-variable-braces disable=SC1091 external-sources=true
- •SC1091 (not following sourced files): almost always a false positive in projects with dynamic sourcing -- disable globally
- •SC2086 (unquoted variables): never disable globally; fix each instance or suppress inline with comment explaining why
- •SC2015 (
&& ||chains): suppress only in one-liners where intent is obvious
Suppression Rules
- •Always add a comment explaining why when using
# shellcheck disable=SCXXXX - •Suppress at the narrowest scope (line > function > file)
- •Never disable SC2086 project-wide -- it catches real bugs
Target Shell Mismatch
Most common source of false positives. If your scripts use bash features, ensure:
- •Shebang is
#!/usr/bin/env bash(not#!/bin/sh) - •
.shellcheckrchasshell=bash - •CI runs with
--shell=bashflag
CI Integration
Preferred Setup
yaml
# GitHub Actions -- run both together
- name: Shell quality
run: |
shellcheck --format=gcc **/*.sh
bats tests/*.bats --jobs 4
- •Use
--format=gccin CI for parseable output - •Use
--jobs Nfor parallel Bats execution - •Fail the build on any shellcheck warning (no
--severityfiltering) - •Run shellcheck before bats -- catch static issues before spending time on integration tests
Pre-commit Hook
Lint only changed files:
bash
git diff --cached --name-only --diff-filter=d | grep '\.sh$' | xargs -r shellcheck
Non-Obvious Gotchas
- •
runcaptures both stdout and stderr in$output-- userun --separate-stderr(bats-core 1.5+) if you need to distinguish - •
[ "$status" -eq 0 ]afterrunchecks the command's exit code, not the test's - •ShellCheck
--check-sourcedfollowssource/.directives but requires files to exist at analysis time - •
bats --filter "pattern"runs only matching test names -- use for focused debugging - •Parallel bats (
--jobs) shares nothing between files butsetup_fileresources persist within a file