bats-testing
Testing with Bats
Overview
Bats is best when correctness depends on real shell behavior: exit codes, stdout/stderr, sourced functions, and external commands.
Core principle: test behavior at the shell boundary, not implementation details.
When to Use
Use this skill when you need to:
- Write e2e tests for CLI tools
- Test Bash libraries that are
sourced (not executed) - Use Bats as a shell-native REST API test runner with
curl+jq
Typical symptoms:
- "My script works manually but fails in CI"
- "I can test command output, but not sourced function behavior"
- "I need lightweight API tests from shell pipelines"
Project Setup
Recommended layout:
test/
helpers/
test_helper.bash
cli_*.bats
lib_*.bats
api_*.bats
test/helpers/test_helper.bash:
#!/usr/bin/env bash
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
setup_test_tmp() {
export TEST_TMPDIR="$(mktemp -d)"
}
teardown_test_tmp() {
rm -rf "$TEST_TMPDIR"
}
Pattern 1: CLI e2e Tests
Test real invocation and output contracts.
#!/usr/bin/env bats
load './helpers/test_helper.bash'
setup() {
setup_test_tmp
export HOME="$TEST_TMPDIR/home"
mkdir -p "$HOME"
}
teardown() {
teardown_test_tmp
}
@test "todoctl add persists item" {
run todoctl add "buy milk"
assert_success
assert_output --partial "added"
run todoctl list --json
assert_success
echo "$output" | jq -e 'any(.[]; .text == "buy milk")'
}
@test "todoctl add rejects empty text" {
run todoctl add ""
assert_failure
assert_output --partial "text is required"
}
Notes
- Always isolate runtime directories (
HOME, config/data dirs). - Assert both exit status and output.
- Include at least one negative-path test per command surface.
Pattern 2: Sourced Bash Libraries
For libs, distinguish:
- Process-level assertions (
run bash -c 'source ...') - In-shell state assertions (direct call, no
run)
run executes in a subshell. Side effects on variables do not persist to the test shell.
#!/usr/bin/env bats
load './helpers/test_helper.bash'
setup() {
# shellcheck disable=SC1091
source "${BATS_TEST_DIRNAME}/../lib/string_utils.sh"
}
@test "library can be sourced cleanly" {
run bash -c 'source "./lib/string_utils.sh"'
assert_success
assert_output ""
}
@test "trim returns normalized value" {
run trim " hello "
assert_success
assert_output "hello"
}
@test "function can mutate caller state (non-run path)" {
value=" hello world "
trim_in_place value # this function edits variable by name
[ "$value" = "hello world" ]
}
Notes
- Use
runfor output/status checks. - Use direct invocation for in-shell state mutation tests.
- Source once in
setupunless isolation requires per-test sourcing.
Pattern 3: REST API Testing with Bats
Use helpers so each test focuses on intent.
#!/usr/bin/env bats
load './helpers/test_helper.bash'
request_json() {
local method="$1"; shift
local url="$1"; shift
local body_file="$BATS_TEST_TMPDIR/response.json"
HTTP_STATUS="$({
curl -sS \
-X "$method" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-o "$body_file" \
-w '%{http_code}' \
"$url" "$@"
})"
HTTP_BODY="$(cat "$body_file")"
}
@test "GET /health is healthy" {
[ -n "${API_BASE_URL:-}" ] || skip "API_BASE_URL is required"
request_json GET "${API_BASE_URL%/}/health"
[ "$HTTP_STATUS" -eq 200 ]
echo "$HTTP_BODY" | jq -e '.status | IN("ok", "healthy", "up")'
}
@test "POST /users creates user" {
[ -n "${API_BASE_URL:-}" ] || skip "API_BASE_URL is required"
local email="bats.$RANDOM.$RANDOM@example.test"
request_json POST "${API_BASE_URL%/}/users" \
--data "$(jq -nc --arg email "$email" '{name:"Bats User", email:$email}')"
[ "$HTTP_STATUS" -eq 201 ]
echo "$HTTP_BODY" | jq -e --arg email "$email" '.email == $email and .id != null'
}
Notes
- Prefer
jqover regex for JSON assertions. - Generate unique test data to avoid collisions.
- For stateful APIs, add explicit cleanup calls or disposable environments.
Common Mistakes
- Parsing JSON with
greponly → brittle checks; usejq -e. - Only happy-path tests → add negative-path assertions for each command/endpoint.
- Using
runfor stateful sourced-function tests → side effects disappear (subshell). - Leaking local machine state (
HOME, config dirs) → isolate with temp dirs.
Quick Checklist
Before claiming tests are done:
- Exit code and output are both asserted
- At least one failure-path test exists
- Sourced-library tests include non-
runstate checks when relevant - API JSON assertions use
jq - Test state is isolated and reproducible
More from zenobi-us/dotfiles
leaflet-mapping
Use when creating interactive maps in Obsidian using LeafletJS plugin - covers real-world maps, image maps, markers from notes, overlays, GeoJSON, GPX tracks, and common issues with bounds/zoom levels
73skill-hunter
Find and download skills. Use when you need to discover existing skills from GitHub repositories and store them in the correct local skills category. Results in discovered skills being downloaded into the users dotfile repo.
68using-superpowers
Use when starting any conversation - establishes mandatory workflows for finding and using skills, including using Skill tool before announcing usage, following brainstorming before coding, and creating TodoWrite todos for checklists
67deep-researcher
Use when delegating research tasks requiring verified information from multiple authoritative sources - crawls and fact-checks beyond surface-level findings, providing evidence of verification process with confidence levels for each claim
66chrome-debug
Use when debugging web applications in chrome via the remote debugging protocol. Provides capabilities for inspecting DOM, executing JS, taking screenshots, and automating browser interactions.
64projectmanagement
Skills for managing projects, tracking progress, and suggesting next actions.
64