Testing
Testing
The installer is verified by five Docker-wrapped gates, each pinned to an exact tool version and each run identically in CI:
| Command | What it does | CI job |
|---|---|---|
bin/test |
Runs the Bats suite | bats |
bin/lint |
Runs ShellCheck over the generated script + standalone scripts | shellcheck |
bin/spell |
Runs cspell over the repo, the way Drupal CI does | cspell |
bin/generate |
Regenerates drupalaibp from src/ and checks it is complete |
bashly_verify |
bin/docs |
Regenerates the CLI reference (docs/cli/) and man pages (man/) from src/bashly.yml |
bashly_verify |
Nothing needs installing to run them — each wrapper pulls the pinned image and runs the tool inside Docker.
Spellcheck
Drupal CI fails the pipeline on a single unknown word, anywhere — prose, a
comment in src/, AGENTS.md. bin/spell runs that same check locally and
reports the same hits, so run it before committing:
bin/spell # the whole repo, like CI
bin/spell AGENTS.md # just these paths
A hit is fixed one of two ways. Reword it if it's a typo — or a British
spelling, because the dictionary is en-US and defence, colour, behaviour
are all rejected. Or add it to .cspell-project-words.txt (one word per
line) if it's a real term: a package, a flag, a person, a CLI name.
Note that CI doesn't run cspell against the committed .cspell.json — the Drupal
GitLab template expands that file first (adding core's dictionaries and this
project's word list). bin/spell reproduces the expansion into a generated,
gitignored .cspell-local.json, and fetches core's dictionaries once into
.cache/cspell/. It also skips the paths git ignores, because a CI checkout
never has them.
The installer itself is never run end-to-end, by you or by CI. A real run would install Docker, DDEV and Drupal on the machine executing it. All live smoke testing is done by the maintainer, by hand. Automated verification stops at static checks and the Bats suite.
Running the suite
bin/test # everything (Docker, nothing to install)
bin/test tests/bats/lib/core # one directory
bin/test tests/bats/lib/core/json.bats # one file
BATS=local bin/test # use a bats already on your PATH
The Bats version is pinned in .bats-version and must match BATS_VERSION in
the bats job in .gitlab-ci.yml; CI fails loudly if the two disagree. To
upgrade Bats, bump both.
How the suite is organised
One .bats file per source file, mirroring the tree. The test file's path
under tests/bats/ is the source file's path under src/, with the extension
swapped:
src/lib/core/json.sh -> tests/bats/lib/core/json.bats
src/lib/steps/step_restart_ddev.sh -> tests/bats/lib/steps/step_restart_ddev.bats
src/lib/extras/extra_dtk.sh -> tests/bats/lib/extras/extra_dtk.bats
src/setup_site_command.sh -> tests/bats/commands/setup_site_command.bats
A test sources the file under test and only what it genuinely depends on —
never the whole installer — then asserts its behaviour with the outside world
stubbed out. There is no "integration" tier: drupalaibp is a concatenation of
these files, so covering each one covers the script.
Shared machinery lives in tests/bats/helpers/:
oli.bash— the helper every test loads (setup, sourcing, stubs, assertions).jq.bash— a real-enoughjqfor the tests that exercise a genuine JSON merge.jq-shim.pl— the Perl stand-injq.bashinstalls when nojqis present.
Hermeticity: the rule that shapes every test
A test may not touch DDEV, Docker, the network, or the user's machine. Every
external command the installer shells out to (ddev, docker, git, gh,
npx, curl, sudo, brew, package managers…) is replaced by a recorder that
logs the call instead of running it. $HOME and the working directory are
per-test temp dirs.
This is enforced, not merely asked for. oli_setup puts a shim directory first
on PATH in which every dangerous command is a script that aborts with
HERMETICITY VIOLATION and exit code 99. Forgetting to stub something fails the
test with a message telling you exactly what to stub — it never silently runs the
real thing against the machine running the suite.
Writing a test
A typical file:
#!/usr/bin/env bats
# src/lib/steps/step_restart_ddev.sh — the ONE restart that applies the config's
# ddev_addons. Fatal if it fails.
setup() {
load ../../helpers/oli # path is relative to THIS .bats file
oli_setup # temp workdir + call log; cd's into it
oli_core # colors + step/ok/warn/die
oli_source lib/steps/step_restart_ddev.sh
oli_stub ddev # record `ddev …`, never run it
DDEV_ADDONS=() # the globals this function reads
}
@test "no config add-ons means no restart at all" {
run step_restart_ddev
[ "$status" -eq 0 ]
refute_called "ddev restart"
}
@test "config add-ons trigger exactly one restart" {
DDEV_ADDONS=(ddev/ddev-redis)
run step_restart_ddev
[ "$status" -eq 0 ]
assert_call_count "ddev restart" 1
}
Start the file with a comment naming the source file it covers and the behaviour that matters — a reader should learn why the function exists from the test.
Because the installer's functions communicate through globals (bash dynamic
scope), a test's setup() declares the globals the function reads. Set them to
their empty/default value there and override them per test.
The helper API (tests/bats/helpers/oli.bash)
| Helper | Purpose |
|---|---|
oli_setup |
Per-test temp workdir + call log, cds into it, arms the hermeticity guard. Call first. |
oli_source <path…> |
Source a file by its path under src/ (e.g. lib/core/json.sh). |
oli_core |
Shorthand for colors.sh + helpers.sh (step/ok/warn/die). |
oli_events |
Source the event registry and reset it to empty (on/emit). |
oli_jq (helpers/jq) |
Ensure a working jq exists (real one, or the Perl shim). |
oli_stub <cmd> [rc] |
Replace a command with a recorder that returns rc (default 0). |
oli_stub_fail <cmd> <glob> |
Recorder that fails only for calls whose args match the glob (spaces are fine: '*composer require*'). |
oli_absent <cmd…> |
Make command -v <cmd> fail — how the installer decides it must install DDEV/Docker/gh. |
oli_calls |
Everything the stubs were asked to do, one call per line. |
assert_called <substr> |
A recorded call contains this substring. |
refute_called <substr> |
No recorded call does. |
assert_call_count <substr> <n> |
Exactly n recorded calls contain it. |
assert_eq <expected> <actual> |
Readable equality for plain values. |
assert_file_contains <file> <substr> |
The file exists and contains the substring. |
All the assert_*/refute_* failures print the full call log, so a failing test
tells you what the function actually did.
Things that bite
runruns the function in a subshell. Anything the function writes to a variable (an emitted-event trace,AGENTS_MD, …) is lost when it returns. Userunto assert on$status/$output; call the function directly (redirecting output to a file, thenassert_file_contains) when you need to assert on a global it changed. Files it writes survive either way — they land in the temp workdir.- Prompts. Menus are functions (
select_one/select_many), so a test that needs a choice stubs the menu and sets what it "returns" (MENU_INDEX/MENU_SELECTED) rather than feeding keystrokes to a TTY. - Stub globs need no escaping.
oli_stub_failtakes the pattern by name, so write it naturally, spaces and all. - Don't assert on colour codes unless the colour is the behaviour.
Adding to src/ means adding to tests/
A new file under src/ must arrive with its .bats file in the same change,
and a behaviour change must arrive with the test that pins it. This is a review
expectation, not a suggestion: the suite is the only automated proof the
installer still works, because nothing ever runs it for real.
Give the new file's tests the same shape as the source file's contract:
- a step (
src/lib/steps/) — assert it self-skips on its gate (IS_DRUPAL,USE_CONFIG,GH_ENABLED…), issues the rightddevcalls in the right order, emits its semantic event, and behaves as documented when a command fails (warn-and-continue vs. fatal); - an extra (
src/lib/extras/) — assert the meta line (Name|Description[|default]), what it installs, and that a failure is best-effort; - a prompt (
src/lib/shared/prompts/) — assert the--yolo/unattended path takes a default silently, that config mode doesn't prompt, and that a CLI flag wins over the prompt; - a pure function (
src/lib/core/) — assert the values, including the ugly inputs (empty, missing, malformed).