--- name: testing-bash-scripts description: >- Writes unit tests (for lib/*.sh-style helpers) or mocked end-to-end tests (for executable scripts) using bats, and runs task tests:bash/tests:coverage. Use whenever adding a new bash script anywhere in the repository, or when asked to test, mock, or audit test coverage for one. --- # Testing bash scripts ## What this skill does Writes a `.bats` test for a new bash script anywhere in the repository, using `bats` (bootstrapped by `task setup`) and this repo's shared mocking helper ([scripts/lib/testing.sh](../../../../scripts/lib/testing.sh)), then runs [`task tests:bash`](../../../../Taskfile.yaml) (the whole suite) and [`task tests:coverage`](../../../../Taskfile.yaml) (the audit that a test exists at all). Both run as part of `task check:all` in [checks.yaml](../../../../.github/workflows/checks.yaml). See [.agents/rules/quality/bash-script-testing.md](../../../rules/quality/bash-script-testing.md) for the underlying rule. ## Workflow ``` - [ ] 1. Decide which shape applies: a shared helper under a lib/*.sh-style directory gets unit tests (source it, call its functions directly); an executable script anywhere else gets a mocked end-to-end test (run it as a subprocess with `run`). - [ ] 2. Create /tests/.bats. - [ ] 3. At the top: `load '/lib/testing.sh'` for mock_setup/mock_command/mock_teardown, plus (unit tests only) `load '/.sh'`. - [ ] 4. For each external command the code under test invokes (gh, docker, uname, git, ...), call `mock_command ''` in the test that needs it, instead of letting the real binary run. - [ ] 5. Write one @test per meaningful behavior - the happy path, every real branch, and every error path - not just one smoke test. - [ ] 6. Run: task tests:bash (or `bats path/to/the.bats` directly while iterating, for a faster loop). - [ ] 7. Run: task tests:coverage - confirms the new script is no longer flagged as untested. There's no exceptions list: every script, with no exceptions, needs one. ``` ## Notes - Unit vs. mocked end-to-end is decided by what kind of file it is, not by choice: `scripts/lib/*.sh` files are already always `source`d, never executed directly, so they're naturally unit-testable with zero code change; executable scripts are already invoked as their own subprocess by a Taskfile task or CI, so they're naturally end-to-end-testable with `run` and zero code change either. Neither needs a `main()` guard or any other restructuring first. - `mock_command ''` writes an executable stub named `` ahead of the real command on `PATH` for the rest of that one test - the body runs as a real shell script, with `$@`/`$1`/etc. bound to whatever arguments the real command would have received, so a stub can branch on its arguments when a test needs different responses for different calls. - A passing end-to-end test that quietly depended on a real installed tool or a real network call isn't actually mocked - if a test would fail differently (or not run at all) on a machine without that tool installed or without network access, something wasn't mocked that should have been. - See [scripts/lib/tests/platform.bats](../../../../scripts/lib/tests/platform.bats) for a worked unit-test example (mocking `uname` to exercise every OS/arch branch) and [scripts/ci-gate/tests/wait-for-sibling-runs.bats](../../../../scripts/ci-gate/tests/wait-for-sibling-runs.bats) for a worked mocked end-to-end example (mocking `gh`). - If `task tests:coverage` flags a script, write it a test - there's no exceptions list to add it to instead. - For a test that needs a real command to be genuinely absent (not just hopefully-shadowed by pointing `PATH` at a directory guessed not to contain it - that guess can fail across OSes, e.g. Debian/Ubuntu's merged-usr layout makes bare `/bin` reach everything `/usr/bin` has), use `mock_isolate_path ...` (also in `scripts/lib/testing.sh`): it symlinks each named tool from the real `PATH` into the mock bin directory, then restricts `PATH` to *only* that directory.