Use whenever the code under test is a shell script, a CLI binary or anything run from Bash: argument parsing, flags, exit codes and output. Sets up Bats-core with @test blocks, run, status and output assertions, setup/teardown, shared helpers and TAP output for CI.
86%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
The canonical home for this skill is testland/bats-testing
Per bats-readthedocs:
"Bats (Bash Automated Testing System) is a TAP-compliant testing framework for Bash 3.2 or above."
Per bats-github:
"Bats is a TAP-compliant testing framework for Bash 3.2 or above. It provides a simple way to verify that the UNIX programs you write behave as expected."
Bats works "with any Unix program," not just Bash - it captures stdout / stderr / exit code of any executable.
kubectl plugins, git
aliases) behave as expected.bats (npm / Homebrew / git submodule); pin via submodule for CI determinism (Step 1).test/<feature>.bats starting with #!/usr/bin/env bats and one @test block per behavior (Step 2).run, then assert on $status, $output, and $lines[] (Step 3).setup / teardown with mktemp -d so each test is isolated and parallel-safe (Step 4).load shared helpers plus bats-support / bats-assert for diff-style assertions (Steps 5-6).skip; run the suite via bats test/, adding --jobs N for parallelism (Steps 7-8).Per bgh:
# npm
npm install -g bats
# Homebrew
brew install bats-core
# Git submodule (vendored, deterministic for CI)
git submodule add https://github.com/bats-core/bats-core.git test/bats
git submodule add https://github.com/bats-core/bats-support.git test/test_helper/bats-support
git submodule add https://github.com/bats-core/bats-assert.git test/test_helper/bats-assertThe git-submodule approach pins the Bats version per repo - recommended for CI determinism.
Per bgh (verbatim example):
#!/usr/bin/env bats
@test "addition using bc" {
result="$(echo 2+2 | bc)"
[ "$result" -eq 4 ]
}
@test "addition using dc" {
result="$(echo 2 2+p | dc)"
[ "$result" -eq 4 ]
}Save as test/math.bats; run bats test/math.bats.
run for exit-code + output captureThe run helper invokes a command and captures $status,
$output, and $lines[]:
@test "myscript --help exits 0 and mentions usage" {
run ./myscript --help
[ "$status" -eq 0 ]
[[ "$output" == *"Usage:"* ]]
}
@test "myscript with no args exits 1" {
run ./myscript
[ "$status" -eq 1 ]
[ "$output" = "error: missing required argument" ]
}
@test "myscript output line count" {
run ./myscript list
[ "$status" -eq 0 ]
[ "${#lines[@]}" -eq 3 ]
[ "${lines[0]}" = "alice" ]
}Without run, a non-zero exit code aborts the test before
assertions execute.
setup / teardownPer bats: "setup and teardown: Pre- and post-test hooks."
setup() {
TEST_DIR="$(mktemp -d)"
cp fixtures/sample.txt "$TEST_DIR/"
}
teardown() {
rm -rf "$TEST_DIR"
}
@test "myscript processes the sample file" {
run ./myscript "$TEST_DIR/sample.txt"
[ "$status" -eq 0 ]
}setup_file / teardown_file run once per file (vs once per
test). Use for expensive setup (Docker container start,
database init).
load shared helpersPer bats: "load: Share common code."
# test/test_helper.bash
make_temp_repo() {
local d
d="$(mktemp -d)"
(cd "$d" && git init -q)
echo "$d"
}# test/integration.bats
load test_helper
@test "git status in fresh repo is clean" {
local d
d="$(make_temp_repo)"
cd "$d"
run git status --porcelain
[ "$status" -eq 0 ]
[ -z "$output" ]
}Better assertions than raw [ ]:
load test_helper/bats-support/load
load test_helper/bats-assert/load
@test "myscript produces expected JSON" {
run ./myscript --json
assert_success
assert_output --partial '"version":'
refute_output --partial 'ERROR'
}
@test "myscript fails on bad input" {
run ./myscript --bad-flag
assert_failure 2
assert_line --index 0 "Unknown flag: --bad-flag"
}assert_success / assert_failure / assert_output /
assert_line give clear diff-style failure messages (vs [ ]'s
silent fail).
skipPer bats: "skip: Easily skip tests."
@test "macOS-only behavior" {
if [[ "$(uname -s)" != "Darwin" ]]; then
skip "macOS-only test"
fi
run ./myscript --use-keychain
[ "$status" -eq 0 ]
}
@test "needs docker daemon" {
if ! command -v docker >/dev/null; then
skip "docker not installed"
fi
if ! docker info >/dev/null 2>&1; then
skip "docker daemon not running"
fi
run ./deploy-script --dry-run
[ "$status" -eq 0 ]
}skip reports the reason in test output (vs commenting out, which
hides the fact that coverage dropped).
Per bats: "Parallel Execution."
# Install GNU parallel first (apt install parallel / brew install parallel)
bats --jobs 4 test/Tests must be independent - share no global filesystem state, no
shared ports. Use setup/teardown with mktemp -d to isolate.
bats emits pretty output by default, TAP or JUnit XML for CI, and runs in
the official bats/bats Docker image. The output-format flags, a GitHub
Actions job, and the Docker one-liner are in
references/ci-integration.md.
You maintain release.sh, a shell script that prints usage on --help and
exits non-zero when called with no arguments. Cover both paths with Bats:
Vendor Bats as a submodule (Step 1), then create test/release.bats:
#!/usr/bin/env bats
load test_helper/bats-support/load
load test_helper/bats-assert/load
@test "release.sh --help exits 0 and prints usage" {
run ./release.sh --help
assert_success
assert_output --partial "Usage:"
}
@test "release.sh with no args exits non-zero" {
run ./release.sh
assert_failure
assert_output --partial "missing required argument"
}Run it: bats test/release.bats. Bats reports:
release.bats
✓ release.sh --help exits 0 and prints usage
✓ release.sh with no args exits non-zero
2 tests, 0 failuresIn CI, swap to bats --formatter junit test/ > junit.xml and upload the
report (see references/ci-integration.md).
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Calling commands directly without run | Non-zero exit aborts the test before any assertion runs. | Wrap with run; check $status. |
Tests sharing /tmp / fixed ports | Parallel execution breaks intermittently. | Per-test mktemp -d in setup (Step 4). |
Using [ ] for everything | Silent failures; no diff message. | bats-assert (Step 6). |
| Commenting out skipped tests | Hides coverage loss; no signal. | skip with reason (Step 7). |
| Asserting full multiline output | Brittle - any whitespace change breaks tests. | Use assert_output --partial or assert_line --index N. |
One mega .bats file | Slow; hard to grep failures. | Per-feature files in test/. |
run forks a process -
expensive vs library-level tests in the language.run strips trailing newlines; some
binary output is not faithfully captured.@test / run / setup / teardown / load / skip,
parallel execution, Docker.tui-snapshot-tester -
TUI snapshot tests (visual layer; bats covers exit code +
text output).cli-output-conventions - what to assert on (stable formats, exit codes, stderr).testland/qa
aa16752
Canonical home
since Jul 23, 2026
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.