Skip to content

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-enough jq for the tests that exercise a genuine JSON merge.
  • jq-shim.pl — the Perl stand-in jq.bash installs when no jq is 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

  • run runs the function in a subshell. Anything the function writes to a variable (an emitted-event trace, AGENTS_MD, …) is lost when it returns. Use run to assert on $status/$output; call the function directly (redirecting output to a file, then assert_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_fail takes 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 right ddev calls 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).