bats-testing
SKILL.md
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
Weekly Installs
32
Repository
zenobi-us/dotfilesGitHub Stars
39
First Seen
Feb 17, 2026
Security Audits
Installed on
codex32
opencode31
cursor31
github-copilot30
kimi-cli30
gemini-cli30