# Bats Test Conventions

> WHAT: Conventions for bats tests in this repo. WHEN: User asks to add or edit a `.bats` test file anywhere under tests/. DO-NOT: Put tests under tests/bin/ instead of mirroring the source path; invent new helper names when common.bash already has one; rely on the host's real `git` / `curl` / `ssh` - stub them.

- Skill: `weikinhuang/bats-test-conventions` (Agent Skill)
- Install (CLI): `npx skillmds@latest add weikinhuang/bats-test-conventions`
- Raw SKILL.md: https://api.skillmd.com/api/skills/weikinhuang/bats-test-conventions/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: weikinhuang (https://skillmd.com/u/weikinhuang)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/weikinhuang/bats-test-conventions

---


# 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 `.bats` file for a script / plugin / phase-file change.
- Editing an existing `.bats` file 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.

```bash
@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):

```bash
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 makes
  `source_without_main` work.

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.

```bash
@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` / `curl` in CI.** Stub them.
- **Tests that `cd` into the repo root without restoring `$PWD`.** Use `$BATS_TEST_TMPDIR` as the working dir.
- **Sharing state between `@test` blocks.** Each `@test` gets a fresh `$BATS_TEST_TMPDIR`; if you need shared fixtures,
  create them in `setup_file` (see bats docs) or per-test in `setup`.
- **Skipping helper reuse - writing `mkdir -p "$HOME/.config" …` inline** when `setup_isolated_home` does 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.                                     |

