bats-testing
Configures Bats-core (Bash Automated Testing System) for testing CLI tools, shell scripts, and Unix programs - `.bats` test files with `@test` blocks, `run` to capture command exit + output, `[ "$status" -eq 0 ]` and `[ "$output" = ... ]` assertions, `setup`/`teardown` hooks, `load` for shared helpers, parallel execution via `--jobs N`, TAP-compliant output for CI integration. Use whenever the unit-under-test is a shell script, CLI binary, or anything invokable from Bash.
Install with skills.sh (any agent)
npx skills add testland/qa --skill bats-testingbats-testing
Overview
Per bats-readthedocs (opens in new window):
"Bats (Bash Automated Testing System) is a TAP-compliant testing framework for Bash 3.2 or above."
Per bats-github (opens in new window):
"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.
When to use
How to use
Step 1 - Install
Per bgh (opens in new window):
# 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.
Step 2 - First test file
Per bgh (opens in new window) (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.
Step 3 - run for exit-code + output capture
The 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.
Step 4 - setup / teardown
Per bats (opens in new window): "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).
Step 5 - load shared helpers
Per bats (opens in new window): "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" ]
}Step 6 - bats-assert / bats-support
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).
Step 7 - skip
Per bats (opens in new window): "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).
Step 8 - Parallel execution
Per bats (opens in new window): "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.
Step 9 - Output formats and CI
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.
Worked example
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:
Anti-patterns
| 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/. |
Limitations
References
Bats output formats and CI integration
View source (opens in new window)Bats output formats and CI integration
Output-format flags and continuous-integration wiring for bats, split out of the main skill spine. See SKILL.md (opens in new window) for the core workflow.
Output formats
# Default (pretty)
bats test/
# TAP (CI-friendly, parseable)
bats --tap test/
# JUnit XML (for CI dashboards via TAP-to-JUnit converters)
bats --formatter junit test/ > junit.xml
# Verbose (show stdout/stderr of every command)
bats --verbose-run test/
GitHub Actions
jobs:
bats:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with: { submodules: recursive }
- run: |
sudo apt-get install -y parallel
- run: bats --jobs 4 --formatter junit test/ > junit.xml
- uses: actions/upload-artifact@v4
if: always()
with: { name: bats-junit, path: junit.xml }
submodules: recursive restores vendored bats-core / bats-support / bats-assert when they are pinned as git submodules; GNU parallel is required for --jobs.
Docker
For Docker-based CI, per bats (opens in new window): "Running Bats in Docker" using the official bats/bats:latest image:
- run: docker run --rm -v "$PWD:/code" bats/bats:latest test/
Related skills
cli-output-conventions
Conventions for designing AND testing CLI output so it stays parseable and assertable - exit-code policy (0 success, non-zero failure with stable codes per failure mode), `stdout` for primary data / `stderr` for messages, `--json` / `--plain` for machine-readable output, deterministic ordering and timestamps, `NO_COLOR` / TTY-aware color, `-q` / `--verbose` discipline, and stable `--help` / `--version`. Built on the [Command Line Interface Guidelines][clig]. Use when designing or testing a CLI's output - a new flag, command, or error message, writing CLI assertions, or fixing flaky tests caused by non-deterministic output; it is the assertion contract for `bats-testing` and tells `tui-snapshot-tester` what does NOT need a snapshot.
pester-cli-testing
Configures Pester v5 for testing PowerShell CLIs, scripts, and cmdlets - Describe/Context/It blocks, Should assertions, Mock for isolating external dependencies, BeforeAll/BeforeEach setup hooks, Invoke-Pester with PesterConfiguration for tags, code coverage, and NUnit/JUnit XML output in CI. Use when the unit-under-test is a PowerShell script, function, or CLI tool invoked from pwsh on Windows or cross-platform.
tui-snapshot-tester
Snapshot testing for terminal UI apps (TUIs) - captures rendered terminal frames as deterministic SVG / text snapshots, diffs them on every run, surfaces a reviewable HTML report on failure, and supports `--snapshot-update` to accept changes intentionally. Wraps `pytest-textual-snapshot` for Python Textual apps; provides equivalent recipes for Ratatui (Rust) `insta` snapshots, Charm Bracelet (Go) `teatest` golden files, and Ink (Node) `ink-testing-library`. Use for any TUI where layout regressions otherwise reach users via `screenshot looks wrong in terminal`.