<!--
  canonical-url: https://www.avonture.be/fr/blog/ollama-test-generator
  published:     2026-08-03
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# ai-test : générer les tests unitaires manquants depuis votre terminal avec un LLM local

> Transformez votre modèle Ollama local en générateur de tests unitaires à la demande. Une fonction zsh qui lit un fichier Bash, PHP ou Python, détecte si des tests existent déjà, affiche uniquement les cas Bats/Pest/Pytest manquants pour une couverture complète, propose de les enregistrer là où le framework les attend, et lance la suite dans un container Docker jetable.

---
![ai-test : générer les tests unitaires manquants depuis votre terminal avec un LLM local](/img/v2/ai_test.webp)

**TL;DR**

`ai-test` transforme votre modèle Ollama local en générateur de tests unitaires à la demande. Pointez-le sur un fichier Bash, PHP ou Python, relisez les tests générés, enregistrez-les si vous voulez, puis lancez-les immédiatement dans Docker — sans jamais quitter votre terminal. Il gère Bats (Bash), Pest (PHP) et Pytest (Python).

Cet article ouvre la série **[Ollama daily use](/series/ollama-daily-use)** : de petits outils qui font d'un LLM local un véritable compagnon de terminal, et non un onglet de navigateur de plus.

Arrêtons de faire semblant : on sait tous que les tests unitaires sont nécessaires, et presque personne n'aime les écrire. Il y a toujours une excuse — un délai serré, un script Bash qui ne devait être que temporaire, l'ennui profond du mocking des dépendances. On se promet de les ajouter plus tard, et *plus tard*, c'est le moment où la dette technique explose en production.

`ai-test` fait le sale boulot à votre place. Pointez-le sur un fichier, récupérez une suite Bats, Pest ou Pytest, lisez-la, enregistrez-la, exécutez-la — tout depuis le terminal, tout sur votre machine.

**Quick Jump:** [Ce que ai-test fait pour vous](#what-ai-test-does-for-you) | [Installation](#installing-it)

## Ce que `ai-test` fait pour vous {#what-ai-test-does-for-you}

Prenons `greet.sh`, un petit script qui demande un prénom et une langue. Une commande, et une suite Bats complète revient — réduite à trois tests ci-dessous, l'exécution réelle en a produit plus de quinze :

```bash
$ ai-test greet.sh

→ No test file found for 'greet' — generating a full Bats suite.

setup() {
  source "$BATS_TEST_DIRNAME/../greet.sh"
}

@test "happy path: greets in English by default" {
  run greet "Christophe"
  [ "$status" -eq 0 ]
  [ "$output" = "Hello Christophe!" ]
}

@test "happy path: greets in French" {
  run greet "Christophe" "fr"
  [ "$status" -eq 0 ]
  [ "$output" = "Bonjour Christophe !" ]
}

@test "happy path: greets in Dutch" {
  run greet "Christophe" "nl"
  [ "$status" -eq 0 ]
  [ "$output" = "Hallo Christophe!" ]
}

→ Save this suite as tests/greet.bats? [y/N] y
→ Written to tests/greet.bats
→ Run the suite now, in Docker? [y/N] y

1..3
ok 1 happy path: greets in English by default
ok 2 happy path: greets in French
ok 3 happy path: greets in Dutch
```

Voilà tout l'argument : une commande, une suite lisible en une minute, deux confirmations, et une exécution verte qui prouve qu'elle n'est pas décorative. Rien n'a été installé pour lancer ces tests, et rien n'a quitté la machine.

## Pourquoi ça marche {#why-it-works}

Quatre idées, et aucune ne vous demande encore de lire une ligne de zsh :

* **L'extension décide de tout** — le framework, la convention de nommage, et l'endroit où le fichier atterrit.
* **Le prompt transporte la structure de votre projet.** Le modèle ne voit jamais un chemin, seulement un bloc de texte ; `ai-test` calcule d'abord la destination, puis dicte comment la suite doit charger le code.
* **Il lit les tests que vous avez déjà** et ne demande que les manques, ajoutés au fichier existant.
* **Il valide dans un container jetable**, pour qu'une suite qui *a l'air* correcte doive prouver qu'elle *passe*.

## Installation {#installing-it}

Deux fichiers à déposer dans `~/.zsh/fns/`, un nouveau shell. D'abord la seule vraie dépendance — `jq`, utilisé pour construire la charge JSON envoyée à Ollama :

Prerequisite: jq

```bash
$ sudo apt update && sudo apt install jq -y

Reading package lists... Done
Building dependency tree... Done
0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded.
```

Verify:

```bash
$ jq --version

jq-1.7
```

Il vous faut aussi un Ollama qui tourne quelque part d'accessible — j'ai déjà parlé de [son installation](/blog/ollama-installation) et de [son exposition sur mon réseau](/blog/accessing-ollama-across-your-local-network) ; le mien reste inactif la majeure partie de la journée, alors pourquoi ne pas le braquer sur la seule tâche que je n'arrête pas de reporter ?

**Deux outils optionnels : bat et docker (cliquez si vous voulez les détails)**

**`bat`** — si [il](https://github.com/sharkdp/bat) est installé, le code généré est coloré syntaxiquement avant d'arriver dans votre terminal. Sinon, `ai-test` retombe sur une sortie brute. Attention à une bizarrerie Debian/Ubuntu : le nom `bat` était déjà pris par `bacula-console-qt`, donc `apt install bat` installe le binaire sous le nom **`batcat`**. C'est pour ça que le helper `_ai_bat` teste les deux orthographes au lieu de coder `bat` en dur.

Prerequisite: bat

```bash
$ sudo apt install bat
```

Verify:

```bash
$ batcat --version
bat 0.24.0
```

**`docker`** — nécessaire uniquement pour l'étape finale « lancer la suite maintenant ». `ai-test` saute cette étape avec un message quand `docker` n'est pas dans votre `PATH`.

Prerequisite: docker

```bash
$ sudo apt install docker
```

Verify:

```bash
$ docker --version
Docker version 29.6.2, build dfc4efb
```

Maintenant les deux fichiers. `_ollama.zsh` contient tout ce que la série partage — le helper de requête, le registre de commandes et le point d'entrée `ai` — tandis que `ai-test.zsh` est la fonction elle-même :

**Project: \~/.zsh/fns**

**\_ollama.zsh**

````bash
# Foundation file for every "ai-*" function in this series. Named with a
# leading underscore: the ~/.zsh/fns/*.zsh loader sources files in
# alphabetical order, and "_" sorts before any letter — so this file is
# always loaded first, no matter how many ai-* functions get added later.
#
# Provides:
#   - _ollama_check       reachability guard for Ollama
#   - _ollama_query       shared HTTP client (curl + jq)
#   - _ai_strip_fences    removes the Markdown fences models add anyway
#   - _ai_bat             resolves "bat" vs Debian's "batcat"
#   - _ai_confirm         yes/no prompt
#   - _git_staged_diff    validated staged-diff fetcher (shared by pre-commit functions)
#   - AI_COMMANDS         registry each ai-* function adds itself to
#   - AI_PARAMS           registry of interactive parameter types (file/language/number/text/none)
#   - _ai_prompt_*        FZF/read helpers for interactive argument collection
#   - ai                  entry-point dispatcher / interactive menu

typeset -gA AI_COMMANDS
typeset -gA AI_PARAMS

# ---------------------------------------------------------------------------
# Ollama helpers
# ---------------------------------------------------------------------------

# _ollama_check — verify that the Ollama daemon is reachable.
# Fails loudly so callers can bail early rather than watching curl hang.
_ollama_check() {
  local host="${OLLAMA_HOST:-http://localhost:11434}"
  if ! curl --silent --max-time 2 "${host}/api/tags" >/dev/null 2>&1; then
    echo "ollama: cannot reach ${host} — is the Ollama container running?" >&2
    return 1
  fi
}

# _ollama_query <prompt> — send a prompt to the local model and print the response.
# Assumes Ollama is reachable; call _ollama_check first if you need an early guard.
_ollama_query() {
  local prompt="$1"
  local model="${OLLAMA_MODEL:-qwen3-coder:30b}"
  local host="${OLLAMA_HOST:-http://localhost:11434}"

  _ollama_check || return 1

  # The prompt is piped into jq rather than passed with "--arg": Linux caps a
  # single argv entry at 128 KB (MAX_ARG_STRLEN), and a large staged diff or a
  # long source file sails straight through that ceiling — jq then dies with
  # "argument list too long". "-R -s" slurps stdin as one raw string, which jq
  # escapes exactly like --arg would.
  print -r -- "$prompt" \
    | jq -Rs --arg model "$model" '{model: $model, prompt: ., stream: false}' \
    | curl --silent "${host}/api/generate" --data-binary @- \
    | jq -r '.response // empty'
}

# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------

# _ai_strip_fences <text> — return only the code, without the Markdown fences.
#
# Every prompt in this series ends with "no markdown fences" and models still
# wrap their answer in ```language … ``` often enough that the output can't be
# piped or pasted as-is. When at least one fence is present, only the fenced
# regions are kept (which also drops the "Here is your test suite:" preamble);
# when there is none, the text is returned untouched.
_ai_strip_fences() {
  local text="$1"

  if [[ "$text" != *'```'* ]]; then
    print -r -- "$text"
    return 0
  fi

  print -r -- "$text" | awk '
    /^[[:space:]]*```/ { inside = !inside; next }
    inside             { print }
  '
}

# _ai_bat — print the name of the "bat" binary installed on this machine, if any.
# Debian and Ubuntu ship the package as "batcat" because the "bat" name was
# already taken by bacula-console-qt, so both spellings have to be probed.
_ai_bat() {
  local candidate
  for candidate in bat batcat; do
    if command -v "$candidate" >/dev/null 2>&1; then
      print -- "$candidate"
      return 0
    fi
  done
  return 1
}

# _ai_relpath <path> — print <path> relative to the current folder when it sits
# below it. Purely cosmetic: it keeps prompts readable instead of asking
# "Save as /home/christophe/projects/…/tests/backup.bats?".
_ai_relpath() {
  local path="${1:A}"
  print -- "${path#${PWD}/}"
}

# _ai_confirm <question> — ask a yes/no question; return 0 only on an explicit yes.
_ai_confirm() {
  local answer
  print -n "${1} [y/N] "
  read -r answer
  [[ "$answer" == [yY]* ]]
}

# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------

# _git_staged_diff <caller> — validate the git context and return the staged diff.
# Handles three early-exit cases (not a repo, nothing staged, oversized diff)
# so every pre-commit function gets them for free without repeating the guards.
#
# Past AI_DIFF_MAX_CHARS the full diff is *not* returned: a diff of several
# hundred kilobytes overflows the model's context window, and the answer that
# comes back is worse than the one you get from a summary. The fallback keeps
# the shape of the change — per-file stat, plus the file and hunk headers,
# which carry the enclosing function names git puts after each "@@" — and
# drops the line-by-line content.
_git_staged_diff() {
  local caller="${1:-ai}"
  local max="${AI_DIFF_MAX_CHARS:-12000}"

  if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
    echo "${caller}: not inside a git repository" >&2
    return 1
  fi

  local diff
  diff=$(git diff --staged)

  if [[ -z "$diff" ]]; then
    echo "${caller}: nothing staged — run 'git add' first" >&2
    return 1
  fi

  # "print -r" everywhere below: without it, print expands escape sequences and
  # a literal \n or \t inside the diff would come back mangled.
  if (( ${#diff} <= max )); then
    print -r -- "$diff"
    return 0
  fi

  echo "${caller}: staged diff is large (${#diff} chars) — sending a structural summary instead of the full diff. Commit in smaller chunks for a more precise answer." >&2

  local summary
  summary="$(git diff --staged --stat)

--- FILE AND HUNK HEADERS ONLY (full diff omitted, ${#diff} chars) ---
$(print -r -- "$diff" | grep -E '^(diff --git|new file|deleted file|rename (from|to)|@@)')"

  # Even the summary can exceed the ceiling on a very wide change.
  if (( ${#summary} > max )); then
    summary="${summary[1,$max]}
[…summary truncated…]"
  fi

  print -r -- "$summary"
}

# ---------------------------------------------------------------------------
# Interactive argument helpers (used by the ai() dispatcher)
# ---------------------------------------------------------------------------

# _ai_prompt_file [label] — open an fzf file picker; print the chosen path.
_ai_prompt_file() {
  local label="${1:-Select a file:}"
  local bat_bin preview='cat {}'
  bat_bin=$(_ai_bat) && preview="${bat_bin} --color=always {} 2>/dev/null || cat {}"

  if command -v fd >/dev/null 2>&1; then
    fd --type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview"
  else
    find . -type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview"
  fi
}

# _ai_prompt_language — open an fzf language picker; print the chosen language.
_ai_prompt_language() {
  printf '%s\n' English French Dutch German Spanish Italian Portuguese Japanese Chinese Korean \
    | fzf --prompt='Target language: ' --height=40% --reverse
}

# _ai_prompt_number [label] — read a positive integer; print it (or nothing on Enter, which
# lets the caller fall back to its own default). Returns 1 only on invalid input.
_ai_prompt_number() {
  local label="${1:-Number (Enter for default):}"
  print -n "${label} "
  local n
  read n
  if [[ -z "$n" ]]; then
    return 0
  fi
  if [[ ! "$n" =~ ^[1-9][0-9]*$ ]]; then
    echo "Expected a positive integer." >&2
    return 1
  fi
  print -- "$n"
}

# _ai_prompt_text [label] — read a free-form string; print it. Returns 1 if empty.
_ai_prompt_text() {
  local label="${1:-Input:}"
  print -n "${label} "
  local t
  read t
  if [[ -z "$t" ]]; then
    echo "No input given." >&2
    return 1
  fi
  print -- "$t"
}

# ---------------------------------------------------------------------------
# Entry-point dispatcher
# ---------------------------------------------------------------------------

# ai              → interactive fzf menu of every registered ai-* command
# ai <cmd> [args] → dispatch straight to ai-<cmd> [args]
#
# When opened via the fzf menu, each command's declared AI_PARAMS type triggers
# a secondary picker before the command runs:
#   file     → fzf file picker (fd + preview if available)
#   language → fzf language list
#   number   → read prompt, empty input uses the command's own default
#   text     → read prompt, required
#   none     → run the command directly (it collects its own input)
# Space-separated values declare multiple parameters in order, e.g. "file language".
ai() {
  local cmd="$1"

  # Direct dispatch: ai <cmd> [args]
  if [[ -n "$cmd" && "$cmd" != "help" && "$cmd" != "list" ]]; then
    if (( ${+AI_COMMANDS[$cmd]} )); then
      shift
      "ai-${cmd}" "$@"
      return
    fi
    echo "ai: unknown command '${cmd}'" >&2
    echo >&2
  fi

  # Interactive menu
  if ! command -v fzf >/dev/null 2>&1; then
    echo "Available ai commands:"
    for key in ${(ok)AI_COMMANDS}; do
      printf "  ai %-10s %s\n" "$key" "$AI_COMMANDS[$key]"
    done
    return
  fi

  local picked
  picked=$(
    for key in ${(ok)AI_COMMANDS}; do
      printf '%s\t%s\n' "$key" "$AI_COMMANDS[$key]"
    done \
      | fzf --delimiter='\t' --with-nth=2 --prompt='ai > ' --height=40% --reverse \
      | cut -f1
  )
  [[ -z "$picked" ]] && return

  # Collect arguments interactively based on AI_PARAMS declaration
  local -a args=()
  local param_type
  for param_type in ${(s: :)${AI_PARAMS[$picked]:-none}}; do
    case "$param_type" in
      file)
        local f
        f=$(_ai_prompt_file "File for ai-${picked}:") || return 1
        args+=("$f")
        ;;
      language)
        local l
        l=$(_ai_prompt_language) || return 1
        args+=("$l")
        ;;
      number)
        local n
        n=$(_ai_prompt_number) || return 1
        [[ -n "$n" ]] && args+=("$n")
        ;;
      text)
        local t
        t=$(_ai_prompt_text) || return 1
        args+=("$t")
        ;;
      none) ;;
    esac
  done

  "ai-${picked}" "${args[@]}"
}
````

**ai-test.zsh**

```bash
# ai-test <file> — generate (or complete) a unit test suite for a script using
# a local LLM. Detects Bash/PHP/Python from the extension, looks for an
# existing test file using each ecosystem's naming convention, and asks the
# model to either write a full suite or fill the coverage gaps only.
#
# The result is printed first, then offered for saving at the path its
# ecosystem expects, then optionally run inside a throw-away Docker container
# so no test runner has to be installed on the host.

AI_COMMANDS[test]="ai-test <file.sh|.php|.py>  — generate or complete a unit test suite"
AI_PARAMS[test]="file"

# Images used to run the generated suites. Overridable from ~/.zshrc.
: ${AI_TEST_BATS_IMAGE:=bats/bats:latest}
: ${AI_TEST_PHP_IMAGE:=php:8.4-cli}
: ${AI_TEST_PY_IMAGE:=python:3.14-slim}

# _ai_project_root <dir> <marker...> — walk up from <dir> until a folder
# containing one of the markers is found; print it, or <dir> when none matches.
# This is what keeps "src/Backup.php" from proposing "src/tests/…".
_ai_project_root() {
  local dir="${1:A}"
  shift
  local candidate="$dir" marker
  while [[ "$candidate" != "/" ]]; do
    for marker in "$@"; do
      if [[ -e "${candidate}/${marker}" ]]; then
        print -- "$candidate"
        return 0
      fi
    done
    candidate="${candidate:h}"
  done
  print -- "$dir"
}

# _ai_test_root <ext> <dir> — print the folder the "tests/" tree hangs from,
# using the marker file each ecosystem puts at the root of a project.
_ai_test_root() {
  local ext="$1" dir="$2"
  case "$ext" in
    sh|bash) _ai_project_root "$dir" .git ;;
    php)     _ai_project_root "$dir" composer.json .git ;;
    py)      _ai_project_root "$dir" pyproject.toml setup.py .git ;;
  esac
}

# _ai_test_target <ext> <root> <base> — print the conventional path where the
# generated suite belongs, following each ecosystem's own layout:
#   Bats   → tests/<name>.bats
#   Pest   → tests/Unit/<Name>Test.php   (PSR-4 style, next to composer.json)
#   Pytest → tests/test_<name>.py
_ai_test_target() {
  local ext="$1" root="$2" base="$3"
  case "$ext" in
    sh|bash) print -- "${root}/tests/${base}.bats" ;;
    php)     print -- "${root}/tests/Unit/${base}Test.php" ;;
    py)      print -- "${root}/tests/test_${base}.py" ;;
  esac
}

# _ai_relative_to <from_dir> <path> — print <path> as seen from <from_dir>.
# Used to tell the model how the test file will reach the code under test.
_ai_relative_to() {
  local from="${1:A}" to="${2:A}" rel

  if rel=$(realpath --relative-to="$from" "$to" 2>/dev/null); then
    print -- "$rel"
    return 0
  fi

  # Portable fallback: climb until "from" is a prefix of "to", counting the
  # hops as "../" segments.
  local common="$from" up=""
  while [[ "$to" != "${common}/"* && "$common" != "/" ]]; do
    common="${common:h}"
    up="../${up}"
  done
  print -- "${up}${to#${common}/}"
}

# _ai_test_bootstrap <ext> <file> <target> — print the paragraph that tells the
# model how the suite is going to load the code it is testing.
#
# This is the single most important part of the prompt. Without it the model
# writes a technically perfect suite that calls functions nobody ever sourced,
# and every test dies with "command not found" (exit code 127). The model can't
# guess it: it only ever sees the file *contents*, never the layout. Now that
# ai-test knows the exact path the suite will be saved at, it can dictate the
# bootstrap line instead of hoping.
_ai_test_bootstrap() {
  local ext="$1" file="$2" target="$3"
  local rel
  rel=$(_ai_relative_to "${target:h}" "$file")

  case "$ext" in
    sh|bash)
      cat <<EOT

IMPORTANT — the suite will be saved as "$(_ai_relpath "$target")". Bats runs every test in a fresh shell that has NOT loaded the file under test, so the suite MUST begin with a setup() function that sources it, exactly like this:

setup() {
  source "\$BATS_TEST_DIRNAME/${rel}"
}

Without that setup(), every single test fails with "command not found". Use it as written, and put any per-test fixtures after the source line.

Use ONLY plain Bash assertions: [ ... ] or [[ ... ]]. Do NOT use the bats-assert helpers (assert_success, assert_failure, assert_output, refute_output, ...): they come from an optional library that is not loaded here, and every test calling them fails with "command not found".

Do not hard-code any value that depends on the current date. If the code computes something from today's date, compute the expected value the same way inside the test instead of writing a literal.
EOT
      ;;
    py)
      cat <<EOT

IMPORTANT — the suite will be saved as "$(_ai_relpath "$target")" and pytest is run with the project root on PYTHONPATH. Import the module under test directly, with "import ${file:t:r}" or "from ${file:t:r} import ...". Do not manipulate sys.path and do not use relative imports.
EOT
      ;;
    php)
      local namespace class="${file:t:r}"
      namespace=$(grep -m1 -E '^[[:space:]]*namespace[[:space:]]' "$file" 2>/dev/null \
        | sed -E 's/^[[:space:]]*namespace[[:space:]]+//; s/[[:space:]]*;.*$//')
      if [[ -n "$namespace" ]]; then
        cat <<EOT

IMPORTANT — the suite will be saved as "$(_ai_relpath "$target")". The class under test is ${namespace}\\${class} and is autoloaded by Composer, so reference it with a "use ${namespace}\\${class};" statement. Do not require the file manually.
EOT
      else
        cat <<EOT

IMPORTANT — the suite will be saved as "$(_ai_relpath "$target")". The file under test declares no namespace, so load it explicitly with: require_once __DIR__ . '/${rel}';
EOT
      fi
      ;;
  esac
}

# _ai_test_lint_existing <ext> <file> — warn about a suite that is already
# broken before we add anything to it.
#
# The classic one: a Bats file calling assert_success / assert_output without
# ever loading bats-assert. Those helpers are an optional library, so every
# such test dies with "command not found" — and since gap-fill mode asks the
# model to match the existing style, the new cases inherit the breakage.
_ai_test_lint_existing() {
  local ext="$1" file="$2"

  [[ "$ext" == sh || "$ext" == bash ]] || return 0

  if grep -qE '(^|[^[:alnum:]_])(assert|refute)_[a-z]' "$file" 2>/dev/null \
    && ! grep -qE 'load[[:space:]].*bats-(assert|support)' "$file" 2>/dev/null; then
    echo "→ Warning: $(_ai_relpath "$file") calls bats-assert helpers but never loads them." >&2
    echo "  Those tests already fail with 'command not found'; add to setup():" >&2
    echo "    load '/usr/lib/bats/bats-support/load'" >&2
    echo "    load '/usr/lib/bats/bats-assert/load'" >&2
  fi
}

# _ai_test_run <ext> <root> <relative_test_path> — offer to run the suite in a
# disposable container. Nothing is installed on the host and the container is
# removed on exit; only the project folder is mounted.
_ai_test_run() {
  local ext="$1" root="$2" rel="$3"

  if ! command -v docker >/dev/null 2>&1; then
    echo "→ Docker not available — skipping the test run." >&2
    return 0
  fi

  _ai_confirm "→ Run the suite now, in Docker?" || return 0

  case "$ext" in
    sh|bash)
      # The working directory is a scratch folder *inside* the container, not
      # the mounted project. Any test that calls the code under test with a
      # default "current directory" destination then writes its artifacts to a
      # path that disappears with --rm, instead of littering the repository
      # with half-cleaned-up files. Tests that need real files on disk use
      # $BATS_TEST_DIRNAME, which still points into /code.
      docker run --rm --user "$(id -u):$(id -g)" \
        --volume "${root}:/code" --workdir /tmp \
        "$AI_TEST_BATS_IMAGE" "/code/${rel}"
      ;;
    php)
      if [[ ! -x "${root}/vendor/bin/pest" ]]; then
        echo "ai-test: ${root}/vendor/bin/pest not found — run 'composer install' first." >&2
        return 1
      fi
      docker run --rm --user "$(id -u):$(id -g)" \
        --volume "${root}:/code" --workdir /code \
        "$AI_TEST_PHP_IMAGE" vendor/bin/pest "$rel"
      ;;
    py)
      # Runs as root so "pip install" can reach the system site-packages; the
      # two cache flags are what stop it from leaving root-owned folders behind.
      # PYTHONPATH=/code is what makes a plain "import backup" work from
      # tests/test_backup.py, so the suite needs no sys.path gymnastics.
      docker run --rm --env PYTHONDONTWRITEBYTECODE=1 --env PYTHONPATH=/code \
        --volume "${root}:/code" --workdir /code \
        "$AI_TEST_PY_IMAGE" \
        sh -c "pip install --quiet --root-user-action=ignore pytest && pytest -p no:cacheprovider '${rel}'"
      ;;
  esac
}

ai-test() {
  local file="$1"

  if [[ -z "$file" || ! -f "$file" ]]; then
    echo "Usage: ai-test <file.sh|file.php|file.py>" >&2
    return 1
  fi

  local ext="${file:e}"
  local lang framework bat_lang
  case "$ext" in
    sh|bash)  lang="Bash";   framework="Bats";   bat_lang="bash"   ;;
    php)      lang="PHP";    framework="Pest";   bat_lang="php"    ;;
    py)       lang="Python"; framework="Pytest"; bat_lang="python" ;;
    *)
      echo "ai-test: unsupported extension .$ext (expected .sh, .php or .py)" >&2
      return 1
      ;;
  esac

  local dir="${file:h}"
  local base="${file:t:r}"
  local root target
  root=$(_ai_test_root "$ext" "$dir")
  target=$(_ai_test_target "$ext" "$root" "$base")
  local existing=""

  case "$ext" in
    sh|bash)
      existing=$(find "$dir" "$dir/tests" "$root/tests" -maxdepth 2 \
        -iname "${base}.bats" 2>/dev/null | head -1)
      ;;
    php)
      existing=$(find "$dir" "$dir/tests" "$root/tests" -maxdepth 3 \
        -iregex ".*/${base}Test\.php" 2>/dev/null | head -1)
      ;;
    py)
      existing=$(find "$dir" "$dir/tests" "$root/tests" -maxdepth 2 \
        \( -iname "test_${base}.py" -o -iname "${base}_test.py" \) 2>/dev/null | head -1)
      ;;
  esac

  local source_code
  source_code=$(<"$file")

  local prompt
  if [[ -n "$existing" ]]; then
    echo "→ Found existing tests: $(_ai_relpath "$existing") — asking only for the coverage gaps." >&2
    _ai_test_lint_existing "$ext" "$existing"
    local existing_tests
    existing_tests=$(<"$existing")
    prompt="You are a senior $lang test engineer. Below is a source file and its CURRENT $framework test suite. Compare them, identify every function, branch and edge case in the source that is NOT exercised by the current tests, and output ONLY the additional $framework test cases needed to reach 100% coverage. Do not repeat any existing test. Output raw $framework code only — no explanations, no markdown fences.

The new cases are appended to the existing file, so they inherit its setup/fixtures: do not repeat them. Use only constructs and helpers that already appear in the current suite, and do not hard-code any value derived from today's date.

--- SOURCE: $file ---
$source_code

--- EXISTING TESTS: $existing ---
$existing_tests"
  else
    echo "→ No test file found for '$base' — generating a full $framework suite." >&2
    local bootstrap
    bootstrap=$(_ai_test_bootstrap "$ext" "$file" "$target")
    prompt="You are a senior $lang test engineer. Write a complete $framework test suite for the file below: happy path, edge cases, and error conditions. Output raw $framework code only — no explanations, no markdown fences.
${bootstrap}

--- SOURCE: $file ---
$source_code"
  fi

  local result
  result=$(_ollama_query "$prompt") || return 1

  # The prompt says "no markdown fences"; models add them anyway often enough
  # that stripping is the only reliable way to get paste-ready code.
  result=$(_ai_strip_fences "$result")

  if [[ -z "$result" ]]; then
    echo "ai-test: the model returned an empty response." >&2
    return 1
  fi

  local bat_bin
  if bat_bin=$(_ai_bat); then
    print -r -- "$result" \
      | "$bat_bin" --language="$bat_lang" --style=plain --paging=never --color=always
  else
    print -r -- "$result"
  fi

  # --- Save --------------------------------------------------------------
  echo

  local shown
  if [[ -n "$existing" ]]; then
    shown=$(_ai_relpath "$existing")
    _ai_confirm "→ Append these cases to ${shown}?" || { echo "→ Not saved."; return 0; }
    print -r -- "" >> "$existing"
    print -r -- "$result" >> "$existing"
    echo "→ Appended to ${shown}"
    target="$existing"
  else
    shown=$(_ai_relpath "$target")
    if [[ -e "$target" ]]; then
      _ai_confirm "→ ${shown} already exists. Overwrite it?" || { echo "→ Not saved."; return 0; }
    else
      _ai_confirm "→ Save this suite as ${shown}?" || { echo "→ Not saved."; return 0; }
    fi
    mkdir -p "${target:h}"
    print -r -- "$result" > "$target"
    echo "→ Written to ${shown}"
  fi

  # --- Run ---------------------------------------------------------------
  local abs_target="${target:A}"
  _ai_test_run "$ext" "$root" "${abs_target#${root}/}"
}
```

Si vous avez déjà mis en place l'autoloader `~/.zsh/fns/` de [mon article sur ripgrep](/blog/ripgrep), c'est terminé : ouvrez un nouveau shell. Sinon, voici ce qu'il faut ajouter à votre `~/.zshrc` existant :

```bash title="~/.zshrc"
export OLLAMA_MODEL="qwen3-coder:30b"       # optional, this is already the default
export AI_TEST_PHP_IMAGE="php:8.4-cli"      # optional, override to match your project's PHP
export AI_TEST_PY_IMAGE="python:3.14-slim"  # optional, same idea for Python

for fn_file in ~/.zsh/fns/*.zsh; do
  source "$fn_file"
done
```

Les exports sont placés *au-dessus* de la boucle volontairement : les valeurs par défaut `AI_TEST_*` sont appliquées au moment du sourcing, donc tout ce que vous définiriez ensuite arriverait trop tard.

### Un seul point d'entrée : `ai` {#one-entry-point-ai}

Ouvrez un nouveau shell et tapez `ai`, sans argument. Plutôt que de devoir vous rappeler comment vous avez nommé vos fonctions il y a six mois, vous obtenez un sélecteur `fzf` listant chaque commande qui s'est enregistrée — aujourd'hui juste `test`, demain tout ce que la série ajoutera :

![ai-test](./images/ai-test.gif)

Choisissez-en une et elle s'exécute. Si vous savez déjà ce que vous voulez, `ai test greet.sh` envoie directement vers `ai-test` et saute complètement le menu — les deux formes sont équivalentes, l'une est explorable, l'autre est rapide.

> **À lire avant de faire confiance:**
>
> Les tests générés sont un brouillon, pas une garantie — lisez-les comme une pull request venant d'un contributeur junior. Lancer la suite prouve qu'elle *s'exécute* ; seule la lecture prouve qu'elle *vérifie* quelque chose.

## Plus de démos {#more-demos}

### Un vrai script, et les manques qu'il comble {#a-real-script-and-the-gaps-it-fills}

`greet.sh` était un échauffement. `backup.sh` est d'un autre calibre — une commande externe, un timestamp, et de vrais effets de bord sur le système de fichiers :

**backup.sh**

```bash
#!/usr/bin/env bash
# backup.sh — tar+gzip a directory into a timestamped archive

backup_dir() {
  local src="$1"
  local dest_dir="${2:-.}"

  if [[ -z "$src" ]]; then
    echo "Usage: backup_dir <source_dir> [dest_dir]" >&2
    return 1
  fi

  if [[ ! -d "$src" ]]; then
    echo "Error: '$src' is not a directory" >&2
    return 1
  fi

  local timestamp
  timestamp=$(date +%Y%m%d-%H%M%S)
  local archive_name
  archive_name="$(basename "$src")-${timestamp}.tar.gz"

  mkdir -p "$dest_dir"

  if tar -czf "${dest_dir}/${archive_name}" -C "$(dirname "$src")" "$(basename "$src")" 2>/dev/null; then
    echo "${dest_dir}/${archive_name}"
    return 0
  else
    echo "Error: tar failed for '$src'" >&2
    return 1
  fi
}
```

`ai-test backup.sh` a renvoyé dix tests, tous verts. Voici les trois premiers :

```bash
❯ ai-test backup.sh

→ No test file found for 'backup' — generating a full Bats suite.

setup() {
  source "$BATS_TEST_DIRNAME/../backup.sh"
}

@test "happy path: backup a directory with default destination" {
  local test_dir="$BATS_TEST_DIRNAME/test_dir"
  local archive_name
  mkdir -p "$test_dir"
  echo "test content" > "$test_dir/file1.txt"
  mkdir -p "$test_dir/subdir"
  echo "sub content" > "$test_dir/subdir/file2.txt"

  archive_name=$(backup_dir "$test_dir")
  [ -f "$archive_name" ]
  [ -n "$archive_name" ]
  [[ "$archive_name" == *.tar.gz ]]
}

@test "happy path: backup a directory with custom destination" {
  local test_dir="$BATS_TEST_DIRNAME/test_dir"
  local dest_dir="$BATS_TEST_DIRNAME/dest"
  local archive_name
  mkdir -p "$test_dir"
  echo "test content" > "$test_dir/file1.txt"

  archive_name=$(backup_dir "$test_dir" "$dest_dir")
  [ -f "$archive_name" ]
  [ -n "$archive_name" ]
  [[ "$archive_name" == "$dest_dir"/* ]]
  [[ "$archive_name" == *.tar.gz ]]
}

@test "edge case: backup directory with special characters in name" {
  local test_dir="$BATS_TEST_DIRNAME/test dir with spaces"
  local archive_name
  mkdir -p "$test_dir"
  echo "test content" > "$test_dir/file.txt"

  archive_name=$(backup_dir "$test_dir")
  [ -f "$archive_name" ]
  [ -n "$archive_name" ]
}

→ Save this suite as tests/backup.bats? [y/N] y

→ Written to tests/backup.bats

→ Run the suite now, in Docker? [y/N] y
1..3
ok 1 happy path: backup a directory with default destination
ok 2 happy path: backup a directory with custom destination
ok 3 edge case: backup directory with special characters in name
```

Maintenant, relancez exactement la même commande une seconde fois :

```bash
$ ai-test backup.sh

→ Found existing tests: tests/backup.bats — asking only for the coverage gaps.

@test "error condition: tar fails when source directory is empty" {
  local test_dir="$BATS_TEST_DIRNAME/empty_dir"
  local archive_name
  mkdir -p "$test_dir"

  archive_name=$(backup_dir "$test_dir" 2>&1)
  [ -n "$archive_name" ]
  [[ "$archive_name" == *"Error: tar failed"* ]]
}
```

`ai-test` a trouvé le `tests/backup.bats` qu'il avait écrit une minute plus tôt, l'a envoyé au modèle avec la source, et n'a demandé que les cas manquants. Au lieu de relire dix tests auxquels je fais déjà confiance, je relis un seul ajout, ciblé — et il est **ajouté** au fichier existant, pas écrit dans un second qui dupliquerait le `setup()`.

C'est le mode que vous utiliserez le plus : chaque fois que vous ajoutez une branch à une fonction, une seconde exécution demande au modèle ce qui a changé, et rien d'autre.

### La même commande, en PHP et en Python {#the-same-command-in-php-and-in-python}

Deux démos Bash pourraient donner l'impression qu'il s'agit d'un jouet pour scripting shell, alors soyons explicites : la seule chose qui change entre les langages, c'est l'extension que vous tapez. Voici une classe PHP suffisamment petite pour tenir en tête, qui fait la chose que tout projet finit par réécrire :

**src/Slug.php**

```php
<?php

declare(strict_types=1);

namespace App\Support;

/**
 * Turn a human title into a URL-friendly slug.
 */
final class Slug
{
    public static function make(string $title): string
    {
        $slug = strtolower(trim($title));

        // Everything that is not a letter or a digit becomes a separator,
        // then repeated separators are collapsed into a single dash.
        $slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
        $slug = trim((string) $slug, '-');

        if ($slug === '') {
            throw new \InvalidArgumentException('Title produces an empty slug');
        }

        return $slug;
    }
}
```

Et son équivalent Python dans l'esprit — deux fonctions pures, quelques clauses de garde, aucune I/O :

**src/prices.py**

```python
"""Tiny pricing helpers — small enough to check the generated tests by hand."""


def apply_discount(price: float, percent: float) -> float:
    """Return `price` reduced by `percent`, rounded to the cent.

    A 0% discount returns the price unchanged; 100% returns 0.0.
    """
    if price < 0:
        raise ValueError("price cannot be negative")

    if not 0 <= percent <= 100:
        raise ValueError("percent must be between 0 and 100")

    return round(price * (1 - percent / 100), 2)


def cheapest(offers: dict[str, float]) -> str:
    """Return the name of the cheapest offer.

    Ties are resolved by name, so the result never depends on dict ordering.
    """
    if not offers:
        raise ValueError("no offer given")

    return min(sorted(offers), key=offers.__getitem__)
```

Lancez `ai-test src/Slug.php` et `ai-test src/prices.py`, et tout se comporte de la même manière : même prompt, mêmes deux confirmations, même container jetable. Seule la destination change — `tests/Unit/SlugTest.php` et `tests/test_prices.py`, tous deux résolus depuis la racine du projet (`composer.json` pour PHP, `pyproject.toml` ou `setup.py` pour Python) et non depuis `src/`.

## Sous le capot (passez si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it}

### La logique, étape par étape {#the-logic-step-by-step}

1. S'enregistrer avec `AI_COMMANDS[test]=...` — la seule ligne qui place `ai-test` dans le menu `ai`.
2. Remonter jusqu'à la racine du projet (`.git`, `composer.json`, `pyproject.toml`…), en déduire où la suite *devrait* se trouver, et chercher un fichier de tests existant selon la convention de nommage de chaque écosystème : `<name>.bats`, `<Name>Test.php`, `test_<name>.py` / `<name>_test.py`.
3. Construire l'un des deux prompts — « écris une suite complète », ou « voici la source *et* les tests actuels, donne-moi uniquement ce qui manque » — et, dans le premier cas, y ajouter le **paragraphe de bootstrap**.
4. L'envoyer via `_ollama_query`, retirer les fences Markdown que le modèle ajoute malgré la consigne inverse, et afficher joliment le résultat avec `bat` s'il est disponible.

Trois points méritent d'être soulignés dans `_ollama.zsh` lui-même :

* L'underscore initial est volontaire : le loader (`for fn_file in ~/.zsh/fns/*.zsh`) source les fichiers par ordre alphabétique et `_` se classe avant toute lettre, donc `AI_COMMANDS` et `ai()` existent toujours avant qu'un fichier `ai-*.zsh` n'essaie de s'y enregistrer.
* `_ollama_query` ping d'abord `${host}/api/tags`, avec un timeout de deux secondes, et échoue bruyamment si Ollama n'est pas joignable — mieux vaut une erreur immédiate qu'un `curl` suspendu trente secondes contre un container arrêté. Il construit aussi sa charge JSON avec `jq`, donc les guillemets, backslashes et retours à la ligne du code source que je vais coller dans le prompt sont échappés pour moi. Notez que le prompt est *envoyé par pipe* à `jq` plutôt que passé comme valeur `--arg` : Linux limite un argument de ligne de commande à 128 Ko, et un gros fichier ou un long diff exploserait sinon avec `argument list too long`.
* `AI_COMMANDS` est un simple tableau associatif, et c'est pourquoi une seule ligne d'enregistrement suffit pour faire apparaître une nouvelle fonction dans le menu `ai` — aucune liste centrale à maintenir, aucun fichier à éditer quand la série grandit.

### La seule instruction qui fait la différence {#the-one-instruction-that-makes-the-difference}

Ce paragraphe de bootstrap de l'étape 3 est ce qui décide si une suite générée fonctionne ou est complètement inerte.

La règle est simple : **un test ne peut appeler que du code qu'il a chargé, et le modèle ne voit jamais où ce code se trouve.** Il reçoit un bloc de texte, jamais un chemin. Demandez une suite sans rien préciser d'autre, et vous obtenez des tests bien nommés avec des assertions sensées qui échouent tous de la même façon — `command not found`, code de sortie 127 — parce que rien n'a jamais chargé le fichier testé.

`ai-test` a calculé le chemin d'enregistrement une étape plus tôt, donc il n'espère pas : `_ai_test_bootstrap` dicte la ligne de chargement, dans la forme que chaque écosystème attend.

* **[Bats](/blog/bats-unit-tests)** — chaque test tourne dans un shell frais qui n'a chargé que le fichier `.bats`, donc la suite doit commencer par un `setup()` qui source le script. `_ai_relative_to` calcule le chemin depuis le dossier du test vers la source, si bien que `src/lib/tool.sh` enregistré comme `tests/tool.bats` donne `source "$BATS_TEST_DIRNAME/../src/lib/tool.sh"`.
* **Pytest** — `tests/test_tool.py` ne peut pas faire `import tool` si la racine du projet n'est pas dans le path. Plutôt que de laisser le modèle écrire des incantations `sys.path`, l'exécution Docker définit `PYTHONPATH=/code` et le prompt dit *« importe le module directement, ne touche pas à sys.path »*.
* **Pest** — la classe est autoloadée par Composer, donc ce qu'il faut au modèle, c'est le **namespace** : `_ai_test_bootstrap` extrait la ligne `namespace` de la source avec grep et fournit un `use` prêt à l'emploi. Aucun namespace déclaré ? Il retombe sur un `require_once` avec le chemin relatif calculé.

> **Donnez au modèle la structure, pas seulement le code:**
>
> Ça se généralise bien au-delà des tests. Quand un LLM écrit du code qui va vivre à un *endroit précis* d'un *projet précis*, la plupart de ses erreurs viennent du contexte que vous ne lui avez pas donné — chemins de fichiers, racines d'import, namespaces, conventions. Ce sont des choses que votre script connaît déjà, et chaque fait que vous transmettez est un fait que le modèle n'a plus à deviner de travers.

### Où le fichier est enregistré {#where-the-file-gets-saved}

La suite est d'abord affichée dans votre console : vous la lisez là, et rien n'a encore touché votre disque. Ensuite `ai-test` demande si vous voulez la garder — et comme il connaît le framework, il propose le chemin que ce framework attend, donc vous n'avez jamais à vous souvenir si Pest veut `tests/Unit/` ou `tests/Feature/` :

| Source           | Framework | Chemin proposé              |
| ---------------- | --------- | --------------------------- |
| `backup.sh`      | Bats      | `tests/backup.bats`         |
| `src/Backup.php` | Pest      | `tests/Unit/BackupTest.php` |
| `src/backup.py`  | Pytest    | `tests/test_backup.py`      |

Notez que `tests/` part de la **racine du projet**, pas du dossier du fichier source : `src/Backup.php` propose `tests/Unit/BackupTest.php` à la racine du repository, pas l'absurde `src/tests/Unit/BackupTest.php`.

> **Il demande toujours d'abord:**
>
> Rien n'est écrit sans un `y` explicite, et si le fichier cible existe déjà la question devient un *« … existe déjà. L'écraser ? »* sans ambiguïté. Toute autre réponse — y compris simplement appuyer sur Entrée — affiche `→ Not saved.` et s'arrête là. Du code généré ne devrait pas atterrir dans votre repository par accident.

### Lancer la suite sans installer de test runner {#running-the-suite-without-installing-a-test-runner}

Installer un test runner pour du code que je risque de supprimer trente secondes plus tard représente une quantité absurde de mise en place, donc `ai-test` lance plutôt la suite dans un container. Bats [publie une image officielle](https://hub.docker.com/r/bats/bats) dont l'entrypoint est `bats` lui-même, ce qui en fait un one-liner :

```bash
$ docker run --rm --user "$(id -u):$(id -g)" --volume "${root}:/code" --workdir /tmp bats/bats:latest /code/tests/backup.bats
```

Les trois images sont remplaçables via `AI_TEST_BATS_IMAGE`, `AI_TEST_PHP_IMAGE` et `AI_TEST_PY_IMAGE` — voir le bloc `~/.zshrc` plus haut.

> **Votre code est monté en lecture-écriture:**
>
> Le container reçoit le dossier du projet, pas une copie, parce que les tests doivent créer des fichiers temporaires à côté du code. C'est un container jetable (`--rm`), mais il exécute *bel et bien* du code généré contre votre working tree — une raison de plus de lire la sortie avant de répondre `y`.

### Écrivez pour le test que vous n'avez pas encore écrit {#write-for-the-test-you-havent-written-yet}

Voici le `greet.sh` du début de cet article :

**greet.sh**

```bash
#!/usr/bin/env bash
# greet.sh — ask for a name and a language, then print the greeting.

# normalize_name <name> — trim the surrounding spaces and capitalize the first
# letter, so " christophe " and "Christophe" end up as the same thing.
normalize_name() {
  local name="$1"

  # Strip leading, then trailing whitespace — pure parameter expansion, no
  # external command involved.
  name="${name#"${name%%[![:space:]]*}"}"
  name="${name%"${name##*[![:space:]]}"}"

  if [[ -z "$name" ]]; then
    echo "Error: no name given" >&2
    return 1
  fi

  echo "${name^}"
}

# greet <name> [lang] — print the greeting line. The language defaults to
# English and is case-insensitive.
greet() {
  local name="$1"
  local lang="${2:-en}"

  local clean
  clean=$(normalize_name "$name") || return 1

  case "${lang,,}" in
    en) echo "Hello ${clean}!" ;;
    fr) echo "Bonjour ${clean} !" ;;
    nl) echo "Hallo ${clean}!" ;;
    *)
      echo "Error: unsupported language '${lang}' (expected en, fr or nl)" >&2
      return 1
      ;;
  esac
}

main() {
  local name lang
  read -rp "What is your first name? " name
  read -rp "Which language (en/fr/nl)? " lang
  greet "$name" "$lang"
}

# Only run main() when the script is executed, never when it is sourced —
# this is what lets a test suite load the functions without triggering the
# interactive prompts.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  main "$@"
fi
```

Il est minuscule, mais il a déjà tout ce qui intéresse une suite de tests : deux fonctions, une valeur par défaut, et trois façons d'échouer. Exécuté seul, ce n'est qu'un prompt interactif :

```bash
$ ./greet.sh
What is your first name? christophe
Which language (en/fr/nl)? fr
Bonjour Christophe !
```

Les trois dernières lignes sont ce qui rend tout l'exercice possible :

```bash
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  main "$@"
fi
```

`main` ne s'exécute que quand le script est *exécuté*, jamais quand il est *sourcé*. Sans cette garde, le `setup()` de la suite générée ferait `source greet.sh`, tomberait sur `read -rp "What is your first name? "`, et resterait bloqué à jamais sur une entrée qui n'arrive pas.

D'où les deux habitudes qui rendent n'importe quel script testable : mettez la logique dans des fonctions qui prennent des arguments et affichent des résultats, et enveloppez la partie interactive dans une garde `BASH_SOURCE`. Sautez-les, et le meilleur modèle du monde ne pourra générer que des tests qui se bloquent.

### Choisir son modèle {#choosing-your-model}

Ce n'est pas une situation où un seul modèle convient à tous. Sur une carte de 24 Go, `qwen3-coder:30b` raisonne nettement mieux sur la couverture de tests que les variantes plus petites `1.5b`/`7b` que j'ai comparées dans mon [article sur l'accès réseau](/blog/accessing-ollama-across-your-local-network) — il est plus lent, mais pour une tâche que vous lancez quelques fois par jour, pas des milliers, ces dix secondes en plus n'ont aucune importance. Si votre VRAM est limitée, descendez à `qwen2.5-coder:7b` et attendez-vous à une bonne suite pour le cas nominal avec une couverture des cas limites plus faible ; vous réfléchirez davantage vous-même.

## Conclusion {#conclusion}

Deux fichiers à copier dans `~/.zsh/fns/`, un nouveau shell, et le script que vous n'alliez jamais tester devient `ai-test greet.sh` : une suite complète, verte, le temps de faire un café — rien d'installé, rien qui quitte votre machine.

Récupérez le `greet.sh` ci-dessus et essayez-le sur vos propres scripts. Puis regardez celui que vous évitez depuis des mois, le `backup.sh` de votre repository, et lancez-le là aussi : au pire vous jetez la sortie, au mieux vous avez enfin une suite.
