Posted on
Artificial Intelligence

Artificial Intelligence Bash Testing

Author
  • User
    linuxbash
    Posts by this author
    Posts by this author

Artificial Intelligence Bash Testing: Let an LLM Find Your Edge Cases

Ever shipped a Bash script that worked on your machine but broke in production because of a stray space, a weird URL, or an unexpected newline? Manual testing and “happy-path” checks miss the tricky stuff. What if an AI could propose adversarial inputs while your CI enforces style and correctness?

In this article you’ll:

  • Set up a portable Bash testing toolchain (ShellCheck, shfmt, Bats, jq).

  • Write deterministic unit tests for a real script.

  • Use an AI endpoint to generate extra edge cases as executable tests.

  • Wire it all into CI so your scripts stay robust.

Why this matters

  • Bash glues the modern Linux stack together. A one-liner in a release pipeline can halt deployments if it meets the wrong filename or locale.

  • Static analysis is great but can’t anticipate the infinite weirdness of user input.

  • LLMs are surprisingly good at generating adversarial test cases—especially for surface-area heavy shell scripts.

  • Combine: formatter + linter + deterministic tests + AI-generated edge cases + CI. You get fewer regressions and faster feedback.


1) Install the testing toolchain

We’ll use:

  • shellcheck: Static analysis to catch common shell bugs.

  • shfmt: Opinionated formatting to prevent style drift.

  • Bats: TAP-compliant Bash tests.

  • jq: Parse AI JSON output for generated tests.

  • curl: Call your AI endpoint.

On Debian/Ubuntu (apt):

sudo apt update
sudo apt install -y shellcheck shfmt bats jq curl

On Fedora/RHEL/CentOS (dnf):

sudo dnf install -y ShellCheck shfmt bats jq curl

On openSUSE (zypper):

sudo zypper refresh
sudo zypper install -y ShellCheck shfmt jq curl
# Bats package name varies by repo:
sudo zypper install -y bats || sudo zypper install -y bats-core

If Bats isn’t available in your repo, install from source:

sudo zypper install -y git || sudo dnf install -y git || sudo apt install -y git
git clone https://github.com/bats-core/bats-core.git
sudo ./bats-core/install.sh /usr/local

2) Baseline hygiene: format and lint on every change

Create a quick script to format and lint everything:

#!/usr/bin/env bash
set -euo pipefail

# Format in-place and keep diffs minimal.
shfmt -w -s .

# Fail on any ShellCheck finding.
shellcheck -S style -x **/*.sh

Optional: add a simple pre-commit hook to prevent sloppy diffs:

# .git/hooks/pre-commit
#!/usr/bin/env bash
set -euo pipefail
shfmt -w -s .
shellcheck -S style -x **/*.sh
git add -A

Then:

chmod +x .git/hooks/pre-commit

3) Write deterministic tests with Bats

Here’s a non-trivial but focused example: a CLI that prints the last path segment of a URL or path, ignoring query and fragment. Trailing slashes collapse to the previous segment.

Script: url_basename.sh

#!/usr/bin/env bash
set -euo pipefail

basename_from_url() {
  local u="${1-}"

  # Trim leading/trailing whitespace
  u="${u#"${u%%[![:space:]]*}"}"
  u="${u%"${u##*[![:space:]]}"}"

  # Strip scheme
  u="${u#*://}"

  # Remove query and fragment
  u="${u%%[\?#]*}"

  # Remove trailing slashes
  while [[ "$u" == */ ]]; do u="${u%/}"; done

  # If looks like domain/path, drop the domain part (up to first slash)
  if [[ "$u" == *"/"* ]]; then
    if [[ "$u" != /* ]]; then
      u="${u#*/}"
    fi
  fi

  # Last segment
  local base="${u##*/}"
  printf '%s\n' "$base"
}

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
  if [[ $# -gt 0 ]]; then
    for arg in "$@"; do
      basename_from_url "$arg"
    done
  else
    while IFS= read -r line; do
      basename_from_url "$line"
    done
  fi
fi

Tests: tests/url_basename.bats

#!/usr/bin/env bats

setup() {
  chmod +x ./url_basename.sh
}

@test "simple URL" {
  run ./url_basename.sh "https://example.com/a/b/c.txt"
  [ "$status" -eq 0 ]
  [ "$output" = "c.txt" ]
}

@test "trailing slash yields dir basename" {
  run ./url_basename.sh "https://example.com/a/b/"
  [ "$status" -eq 0 ]
  [ "$output" = "b" ]
}

@test "ignores query and fragment" {
  run ./url_basename.sh "https://ex.com/a/b/file.log?x=1#top"
  [ "$status" -eq 0 ]
  [ "$output" = "file.log" ]
}

@test "stdin mode supports one-per-line" {
  run bash -c 'printf "%s\n" "a/b/c" "/tmp/z" | ./url_basename.sh'
  [ "$status" -eq 0 ]
  [ "$output" = $'c\nz' ]
}

@test "domain-only returns domain" {
  run ./url_basename.sh "https://example.org"
  [ "$status" -eq 0 ]
  [ "$output" = "example.org" ]
}

Run tests:

bats -r tests

4) Let an AI draft edge cases as runnable tests

You can ask an LLM to propose adversarial inputs (spaces, Unicode, empty segments, odd slashes) and convert its JSON into Bats tests.

Prerequisites:

  • An OpenAI-compatible Chat Completions endpoint (or a compatible gateway).

  • Environment variable OPENAI_API_KEY set.

Install jq and curl if you skipped earlier:

  • apt: sudo apt install -y jq curl

  • dnf: sudo dnf install -y jq curl

  • zypper: sudo zypper install -y jq curl

Script: scripts/gen_ai_cases.sh

#!/usr/bin/env bash
set -euo pipefail

: "${AI_ENDPOINT:=https://api.openai.com/v1/chat/completions}"
: "${AI_MODEL:=gpt-4o-mini}"

if [[ -z "${OPENAI_API_KEY:-}" ]]; then
  echo "OPENAI_API_KEY not set. Skipping AI test generation." >&2
  exit 0
fi

mkdir -p tests

prompt=$'Given a CLI ./url_basename.sh that prints the last path segment of a URL or path, ignoring any query (?...) or fragment (#...). Trailing slashes mean take the previous segment (e.g., /a/b/ -> b). If only a domain is present (example.com), echo example.com.\nGenerate 8 concise test cases as strict JSON: [{"input":"...","expected":"..."}] with no commentary.'

payload=$(jq -n --arg model "$AI_MODEL" --arg prompt "$prompt" '{
  model: $model,
  temperature: 0,
  messages: [
    {role:"system", content:"You generate adversarial test cases for a Bash CLI. Respond ONLY with strict JSON: an array of objects with keys input and expected."},
    {role:"user", content:$prompt}
  ]
}')

resp_json=$(curl -sS -H "Content-Type: application/json" \
  -H "Authorization: Bearer ${OPENAI_API_KEY}" \
  -d "$payload" "$AI_ENDPOINT")

# Extract content (JSON string) and ensure it is JSON
cases_json=$(printf '%s' "$resp_json" | jq -r '.choices[0].message.content' || true)

if [[ -z "$cases_json" || "$cases_json" == "null" ]]; then
  echo "AI returned no content. Raw response:" >&2
  printf '%s\n' "$resp_json" >&2
  exit 1
fi

# Validate the content parses as JSON array
if ! printf '%s' "$cases_json" | jq -e 'type=="array"' >/dev/null 2>&1; then
  echo "AI content is not an array. Content was:" >&2
  printf '%s\n' "$cases_json" >&2
  exit 1
fi

out=tests/generated.bats
{
  echo '#!/usr/bin/env bats'
  echo
  echo 'setup() { chmod +x ./url_basename.sh; }'
  idx=1
  printf '%s' "$cases_json" | jq -c '.[]' | while read -r item; do
    input=$(printf '%s' "$item" | jq -r '.input')
    expected=$(printf '%s' "$item" | jq -r '.expected')

    # Shell-quote safely
    printf -v inq "%q" "$input"
    printf -v exq "%q" "$expected"

    cat <<EOF
@test "AI case $idx" {
  run ./url_basename.sh <<< $inq
  [ "\$status" -eq 0 ]
  [ "\$output" = $exq ]
}
EOF
    idx=$((idx+1))
  done
} > "$out"

chmod +x "$out"
echo "Wrote $out"

Generate tests:

bash scripts/gen_ai_cases.sh
bats -r tests

Notes and guardrails:

  • Never paste secrets or proprietary inputs into prompts.

  • Review generated tests; keep what’s valuable, discard nonsense.

  • Lock behavior with your hand-written tests; AI adds breadth, not truth.


5) Automate it in CI

Example GitHub Actions workflow (Ubuntu runner):

# .github/workflows/bash-ai-tests.yml
name: Bash + AI tests

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # optional; omit to skip AI gen

    steps:
      - uses: actions/checkout@v4

      - name: Install deps
        run: |
          sudo apt-get update
          sudo apt-get install -y shellcheck shfmt bats jq curl

      - name: Lint and format
        run: |
          shfmt -d .
          shellcheck -S style -x **/*.sh

      - name: Generate AI tests (optional)
        run: |
          bash scripts/gen_ai_cases.sh || true

      - name: Run tests
        run: |
          chmod +x ./url_basename.sh
          bats -r tests

For Fedora/openSUSE CI, use containerized jobs (e.g., with a fedora or opensuse image) and run the corresponding dnf/zypper install commands.


Real-world tips

  • Lock your interface: decide exactly what your script prints and what exit codes mean, then encode that in Bats.

  • Keep tests fast: small, deterministic, filesystem-safe. Put slow or networked cases behind an opt-in flag.

  • Normalize the environment in tests: set LC_ALL=C, PATH minimal, and use temporary directories.

  • Make AI optional: CI should still pass without it. Treat AI output as proposals, not ground truth.


Conclusion and next steps

You now have a practical pattern:

  • shfmt + ShellCheck keep code clean and correct.

  • Bats pins behavior with deterministic tests.

  • An AI endpoint proposes adversarial cases you didn’t think of.

  • CI enforces all of the above on every change.

Next steps:

  • Drop url_basename.sh into a repo and wire in this setup.

  • Point the AI prompt at your real scripts and curate the best generated cases.

  • Expand to more scripts and add coverage badges so teams see quality rise.

If you found this useful, share it with your team and start converting your one-off Bash scripts into battle-tested utilities.