Skill v1.0.1
currentAutomated scan100/100+1 new
version: "1.0.1" name: write-script-bats description: | Bats test file writing guidelines: file structure, test naming, setup conventions, and helpers. Auto-load when writing or reviewing .bats test files. metadata: maintainers: [bew]
Goal
Write well-structured Bats test files following consistent naming, setup, and assertion conventions.
NOTE: Load write-script-generic skill first — it defines the shared structure, naming conventions, and error-handling rules this skill builds on. NOTE: Function bodies inside @test blocks and helper functions follow write-script-bash conventions.
Rules
- Name test file
<script-name>.bats— same name as the script under test. - Always declare
bats_require_minimum_version 1.5.0after the header comment. - Use
SCRIPT_DIRandSCRIPT_PATHglobals to reference the tested script — never hardcode paths. setup()must contain only preparation shared by ALL tests.
Test-specific setup goes inside the @test block itself.
- Omit
setup()entirely if nothing is shared across all tests. - Use
setup_file()(runs once before all tests) for suite-wide isolation — e.g. stripping the
environment down to essential variables so tests don't leak from the caller's env. Do not use setup_file() for per-test preparation.
- Use
--keep-empty-linesonrunwhen the script under test produces meaningful blank lines.
Without it, bats strips trailing blank lines from $output.
- Test names must follow format
"topic: summary". - Write
helperstopic tests first in the file. - Use
run -Nto assert exit code inline (e.g.run -0,run -1). - When testing stderr output, use
--separate-stderrflag onrun.
Access stderr via $stderr, stdout via $output.
- Custom assertion helpers must be prefixed
assert_. - Test execution wrappers must be prefixed
run_. - Setup helpers (beyond the shared
setup()) must be prefixedsetup_. - Never build arg strings dynamically (e.g.
printf '%q ' "$@").
Pass "$@" through directly — it is always safe, including when empty.
- When told to add a check "in each test", place it in each
@testbody.
Never collapse it into a shared helper to avoid repetition — the explicitness is intentional.
Test topics (canonical list)
Use these topic prefixes in test names:
| Topic | Purpose | |
|---|---|---|
helpers | Tests for custom assert_* helpers | |
defaults | Default behavior without any flags | |
cli | Command-line argument and flag handling | |
error | Error handling and validation | |
usage | Help/usage message output | |
edge | Edge cases and unusual inputs | |
integration | Piping, chaining with other tools |
File header
# Test suite for `<script-name>` script## % Uses BATS testing system# docs: https://bats-core.readthedocs.io/# repo: https://github.com/bats-core/bats-core## Run tests with: `bats $this_file [--filter foobar]`
File structure
# [header comment]bats_require_minimum_version 1.5.0SCRIPT_DIR="$(dirname "$BATS_TEST_FILENAME")"SCRIPT_PATH="$SCRIPT_DIR/<script-name>"# Shared setup — omit entirely if nothing is shared across all testsfunction setup() {export SOME_VAR="for-all-tests"}# ------------------------------------------------------------------------------# Tests: helpers@test "helpers: assert_length works" { ... }# ------------------------------------------------------------------------------# Tests: defaults@test "defaults: generates expected output" { ... }
Test examples
@test "defaults: generates 32-char string" {run -0 "$SCRIPT_PATH"[[ "${#output}" -eq 32 ]]}@test "cli: accepts length as positional argument" {run -0 "$SCRIPT_PATH" 16[[ "${#output}" -eq 16 ]]}@test "error: rejects non-numeric length" {run -1 --separate-stderr "$SCRIPT_PATH" abc[[ "$stderr" == *"Error:"* ]]}
Section separators
Group tests by topic using # Tests: <topic> headers:
# ------------------------------------------------------------------------------# Tests: cli
setup() patterns
If the script expects to run inside a specific directory structure (e.g. a git repo), cd into it in setup(). Tests then call run -0 "$SCRIPT_PATH" directly — no path juggling, no wrapper needed.
function setup() {TEST_REPO="$BATS_TEST_TMPDIR/repo"mkdir -p "$TEST_REPO"cd "$TEST_REPO"git init -qgit config user.email "test@example.com"git config user.name "Test"}
run_script wrapper helpers
Define a wrapper only when it adds real logic — environment variables, input files, fixed flags, or other shared setup that every call needs. A purely pass-through wrapper is noise; don't define it.
When tests share non-trivial setup (e.g. creating an input file and setting an env var), define script-specific wrappers:
# Run the script expecting success; creates input file and sets env varfunction run_script() {setup_test_input_fileGEN_RANDOM_SOURCE_FILE="$TEST_INPUT_FILE" run -0 --separate-stderr "$SCRIPT_PATH" "$@"}# Run the script expecting failure; exposes error log written by the scriptfunction run_script_failed() {local error_log="$BATS_TEST_TMPDIR/error.log"run -1 --separate-stderr "$SCRIPT_PATH" --error-log "$error_log" "$@"error_log_content="$(cat "$error_log" 2>/dev/null || true)"}
These are script-specific — define them per .bats file, not as shared helpers.
JSON output testing
Always use jq to verify JSON output — never string-match raw JSON directly.
- For small payloads (~100 chars or fewer): normalize with
jq -c '.'and compare with an
exact compact string. Whitespace- and key-order-safe.
- For large or dynamic payloads: use field-level
jqqueries instead of full-output comparison.
Example (small payload, exact compact match):
@test "json: outputs empty array when no entries" {run -0 --separate-stderr "$SCRIPT_PATH" --json[[ "$(jq -c '.' <<< "$output")" == '[]' ]]}
Running tests
bats script.bats # all testsbats script.bats -f "topic" # tests matching pattern
References
- Bats docs: https://bats-core.readthedocs.io/en/stable/writing-tests.html