Bats Test Conventions
Bats covers the shell side of this repo (scripts, plugins, phase files). TypeScript helpers under lib/node/ use vitest
instead - don't mix them. TESTING.md has the full helper catalog; this skill is the short version.
When this applies
- Adding a new
.batsfile for a script / plugin / phase-file change. - Editing an existing
.batsfile and wondering which helper to use. - Deciding whether a new test should be a subprocess run or a sourced unit test.
Skip this skill for *.spec.ts vitest specs - use the ts-vs-bats-router skill instead.
File path mirrors source path
The test file path is the source file path, with tests/ prepended and .bats appended:
| Source | Test |
|---|---|
dotenv/bin/foo |
tests/dotenv/bin/foo.bats |
dotenv/wsl/bin/foo |
tests/dotenv/wsl/bin/foo.bats |
dotenv/aliases.sh |
tests/dotenv/aliases.bats |
plugins/10-fzf.sh |
tests/plugins/10-fzf.bats |
dotenv/functions.sh |
tests/dotenv/functions.bats |
Do NOT put tests under tests/bin/ or tests/scripts/. Reviewers expect to navigate by parallel path.
@test naming - REQUIRED prefix
Every @test block starts with the script or feature name followed by : (colon + space) and a short description. This
is not optional - it's how test grep works in this repo.
@test "git-sync: prints help with -h" { ... }
@test "git-sync: restores stash on failure" { ... }
@test "10-fzf: skips when fzf binary is absent" { ... }
Derive the prefix from the source basename without extension:
| Source | @test prefix |
|---|---|
dotenv/bin/git-sync |
git-sync: |
dotenv/bin/genpasswd |
genpasswd: |
plugins/10-fzf.sh |
10-fzf: |
dotenv/functions.sh::foo() |
foo: |
That prefix makes test grep usable (./dev/test-bats-docker.sh -q | grep git-sync). Tests without the prefix pass CI
but will be flagged in review.
The two-helper starting set
Every test file sources tests/helpers/common.bash. In setup(), bats' load drops the .bash extension for you, so
you write load '../../helpers/common' - but when referring to the file itself in documentation or comments, always
call it tests/helpers/common.bash (the actual on-disk filename):
setup() {
load '../../helpers/common' # bats auto-appends .bash
setup_test_bin
SCRIPT="${REPO_ROOT}/dotenv/bin/foo"
}
The helpers you reach for first:
| Helper | What it does |
|---|---|
setup_test_bin |
Creates $BATS_TEST_TMPDIR/bin, prepends to $PATH. Subsequent stub_command writes land there. |
stub_command <name> |
Reads a heredoc from stdin, writes it as $MOCK_BIN/<name>, makes executable. Use to stub git/curl/etc. |
setup_isolated_home |
Points $HOME / $XDG_CONFIG_HOME at a temp dir, unsets system git config. Use when tests touch $HOME. |
prepend_path |
Shortcut for putting an arbitrary dir at the front of $PATH. |
source_without_main |
Sources a script WITHOUT running its main body, so unit-tested functions are accessible. |
Before adding a helper of your own, rg -n "^[a-z_]+ *()" tests/helpers/common.bash - odds are it exists.
Subprocess vs sourced
- Subprocess (
run bash "${SCRIPT}" …). Default for end-to-end tests: argparse, help output, error exits, external-command stubs. Tests match what users see when invoking the script. - Sourced via
source_without_main. Use when unit-testing internal helper functions. Skips the script's main body so you can call the helper directly with its own args. See TESTING.md for the marker-comment convention that makessource_without_mainwork.
Rule of thumb: test the public CLI behavior via subprocess; test internal logic via source. Cover both for non-trivial scripts.
Stubbing externals
Any external command a script runs is a liability. Stub it.
@test "git-sync: bails when no remote is set" {
stub_command git <<'EOF'
#!/usr/bin/env bash
case "$1" in
remote) exit 0 ;; # prints nothing, exits 0 → no remote
*) echo "unexpected git call: $*" >&2; exit 2 ;;
esac
EOF
run bash "${SCRIPT}"
assert_failure
assert_output --partial "no remote"
}
Never rely on the host's real git, curl, ssh, openssl, gh. Even in CI they produce different output across
versions.
Assertions
bats-assert is loaded by common.bash. Use the named helpers, not raw [[ ]]:
assert_success/assert_failure(exit-code checks).assert_output --partial "<text>"/assert_output "<exact>".assert_line "<text>"/refute_line "<text>".
Plain [ "$status" -eq 0 ] works but gives worse failure messages.
Running tests
| Goal | Command |
|---|---|
| Full suite in Docker (recommended) | ./dev/test-bats-docker.sh |
| Quiet mode | ./dev/test-bats-docker.sh -q |
| Single file | ./dev/test-bats-docker.sh tests/dotenv/bin/foo.bats |
| Whole subtree | ./dev/test-bats-docker.sh tests/plugins/ |
| Local run (needs host bats + helpers) | ./dev/test-bats.sh |
Prefer Docker - it pins the bats / bats-assert version and sidesteps host drift.
Anti-patterns
- Tests under
tests/bin/instead of mirroring the source path. Reviewers can't find them. - Relying on real
git/curlin CI. Stub them. - Tests that
cdinto the repo root without restoring$PWD. Use$BATS_TEST_TMPDIRas the working dir. - Sharing state between
@testblocks. Each@testgets a fresh$BATS_TEST_TMPDIR; if you need shared fixtures, create them insetup_file(see bats docs) or per-test insetup. - Skipping helper reuse - writing
mkdir -p "$HOME/.config" …inline whensetup_isolated_homedoes it. - Asserting on exact output when the script emits version-dependent strings. Use
--partial. - Inventing a new prefix style.
<script>:is the repo convention.
Quick reference
| Situation | Move |
|---|---|
New dotenv/bin/foo |
tests/dotenv/bin/foo.bats with help + unknown-arg tests minimum. |
New plugin plugins/30-foo.sh |
tests/plugins/30-foo.bats with the "tool absent → no-op" test. |
Testing a shell function from functions.sh |
source_without_main in setup, call the function directly, assert. |
Script calls git / curl |
stub_command git <<'EOF' … EOF with only the cases your test exercises. |
Script writes to $HOME |
setup_isolated_home before the run. |