- Posted on
- • Artificial Intelligence
Testing AI Agents
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
Testing AI Agents from the Linux Command Line: A Bash‑First Guide
Your AI agent works great—until it doesn’t. A single parameter change or dependency upgrade can turn yesterday’s solid answers into today’s costly failures. How do you test an inherently probabilistic system with the tools you already trust on Linux? With Bash, curl, and jq, you can build a practical, repeatable test harness that keeps your agents honest.
This article shows you how to:
Make AI agent tests reproducible enough to catch regressions
Build a tiny Bash harness that runs tests and produces clear pass/fail output
Use golden files that let you intentionally approve changes
Run tests in parallel and prep for CI
Everything runs from your terminal. No heavy frameworks required.
Why testing AI agents is different (and necessary)
Stochastic outputs: Language models are non-deterministic by design. Tests need to constrain randomness or assert on properties rather than exact strings.
Interface drift: Model versions and tool integrations change. Without tests, “good enough last week” silently becomes “broken today.”
Cost and latency: Fail fast locally before burning tokens or waiting on slow pipelines.
Reproducibility: A small, transparent Bash harness is easy to audit, vendor-free, and ideal for Linux environments.
What we’ll build
A JSONL file of test cases (prompt + expected property)
A portable Bash test runner that:
- Calls your agent HTTP endpoint (or uses a deterministic stub if no endpoint is set)
- Extracts the response text with jq
- Asserts substring or regex expectations
- Supports golden files for end-to-end regression checking
- Runs tests in parallel for speed
Prerequisites and installation
We’ll use bash, curl, jq, and optionally GNU parallel.
Install with your package manager:
- Debian/Ubuntu (apt):
sudo apt update
sudo apt install -y curl jq parallel
- Fedora/RHEL (dnf):
sudo dnf install -y curl jq parallel
- openSUSE (zypper):
sudo zypper refresh
sudo zypper install -y curl jq parallel
Verify:
bash --version
curl --version
jq --version
parallel --version
Step 1: Define test cases in JSONL
Create tests/cases.jsonl with one JSON object per line. We’ll support both “contains” and “regex” assertions so you can be strict where it matters and flexible where it doesn’t.
mkdir -p tests tests/golden
cat > tests/cases.jsonl << 'JSONL'
{"id":"hello", "prompt":"Say hello", "expect_contains":"hello", "temperature":0}
{"id":"sum", "prompt":"What is 2 + 2?", "expect_regex":"(^|[^0-9])4([^0-9]|$)", "temperature":0}
{"id":"facts", "prompt":"Name a planet in our solar system", "expect_regex":"(mercury|venus|earth|mars|jupiter|saturn|uranus|neptune)", "temperature":0}
JSONL
Notes:
temperature: Keep at 0 when possible to reduce variance.
Regex uses Extended Regular Expressions (ERE) compatible with grep -E.
Step 2: A minimal Bash harness
This script:
Calls your agent via HTTP if AGENT_URL is set
Otherwise uses a deterministic local stub (great for CI or offline work)
Extracts the response text from common JSON shapes
Checks contains/regex expectations
Supports golden files
Create agent-test.sh:
#!/usr/bin/env bash
set -euo pipefail
# Colors (fallback to no color if not a TTY)
if [[ -t 1 ]]; then
green='\033[0;32m'; red='\033[0;31m'; yellow='\033[1;33m'; reset='\033[0m'
else
green=''; red=''; yellow=''; reset=''
fi
require() {
command -v "$1" >/dev/null 2>&1 || { echo "Missing dependency: $1" >&2; exit 127; }
}
require jq
require curl
AGENT_URL="${AGENT_URL:-}" # e.g., http://localhost:5000/complete
SEED_DEFAULT="${SEED_DEFAULT:-42}"
MAX_TOKENS_DEFAULT="${MAX_TOKENS_DEFAULT:-256}"
# Call the agent or return a stubbed JSON response.
# Feel free to customize payload and headers for your real endpoint.
call_agent() {
local prompt="$1" temperature="${2:-0}" max_tokens="${3:-$MAX_TOKENS_DEFAULT}" seed="${4:-$SEED_DEFAULT}"
if [[ -n "$AGENT_URL" ]]; then
curl -sS -X POST "$AGENT_URL" \
-H 'Content-Type: application/json' \
-d "$(jq -n \
--arg prompt "$prompt" \
--argjson temperature "$temperature" \
--argjson max_tokens "$max_tokens" \
--argjson seed "$seed" \
'{prompt:$prompt, temperature:$temperature, max_tokens:$max_tokens, seed:$seed}')"
else
# Deterministic stub: lowercases and prefixes the prompt so tests can run offline.
printf '{ "text": "%s" }\n' "$(printf '%s' "$prompt" | tr '[:upper:]' '[:lower:]' | sed '1s/^/stub: /')"
fi
}
# Extract text from common response shapes.
extract_text() {
jq -r '(
.text
// .choices[0].message.content
// .choices[0].text
// .output
// .reply
// .completion
// empty
)'
}
# Compare against golden file if present; update with GOLDEN_UPDATE=1
check_golden() {
local id="$1" text="$2"
local gf="tests/golden/${id}.txt"
if [[ "${GOLDEN_UPDATE:-0}" == "1" ]]; then
printf '%s\n' "$text" > "$gf"
echo -e "${yellow}updated golden:${reset} $gf"
return 0
fi
if [[ -f "$gf" ]]; then
if diff -u --label "golden/$id" --label "actual/$id" "$gf" <(printf '%s\n' "$text") >/dev/null; then
return 0
else
echo -e "${red}GOLDEN MISMATCH:${reset} $gf"
diff -u --label "golden/$id" --label "actual/$id" "$gf" <(printf '%s\n' "$text") || true
return 1
fi
else
# No golden yet; skip with notice
echo -e "${yellow}no golden:${reset} $gf (set GOLDEN_UPDATE=1 to create)"
return 0
fi
}
pass=0
fail=0
run_case_line() {
local line="$1"
# Parse fields
local id prompt temperature max_tokens seed expect_contains expect_regex
id="$(printf '%s' "$line" | jq -r '.id')"
prompt="$(printf '%s' "$line" | jq -r '.prompt')"
temperature="$(printf '%s' "$line" | jq -r '.temperature // 0')"
max_tokens="$(printf '%s' "$line" | jq -r '.max_tokens // env.MAX_TOKENS_DEFAULT | tonumber? // env.MAX_TOKENS_DEFAULT')"
seed="$(printf '%s' "$line" | jq -r '.seed // env.SEED_DEFAULT | tonumber? // env.SEED_DEFAULT')"
expect_contains="$(printf '%s' "$line" | jq -r '.expect_contains // empty')"
expect_regex="$(printf '%s' "$line" | jq -r '.expect_regex // empty')"
# Call agent and extract text
local response text
response="$(call_agent "$prompt" "$temperature" "$max_tokens" "$seed" || true)"
text="$(printf '%s' "$response" | extract_text || true)"
local ok=0
if [[ -n "$expect_contains" ]]; then
if printf '%s' "$text" | grep -Fqi -- "$expect_contains"; then ok=1; fi
fi
if [[ -n "$expect_regex" ]]; then
if printf '%s' "$text" | grep -Eqi -- "$expect_regex"; then ok=1; else ok=0; fi
fi
# Golden check (non-fatal unless you decide it should be)
if ! check_golden "$id" "$text"; then
# You can choose to fail the test on golden mismatch by uncommenting:
# ok=0
:
fi
if [[ "$ok" -eq 1 ]]; then
echo -e "${green}PASS${reset} [$id]"
pass=$((pass+1))
else
echo -e "${red}FAIL${reset} [$id]"
echo " prompt: $prompt"
echo " text: $(printf '%q' "$text")"
fail=$((fail+1))
fi
}
run_all() {
while IFS= read -r line; do
[[ -z "$line" ]] && continue
run_case_line "$line"
done < tests/cases.jsonl
echo
echo "Summary: $pass passed, $fail failed"
[[ "$fail" -eq 0 ]]
}
# Allow per-line execution for parallel runs
run_json() {
local json="$1"
run_case_line "$json"
}
mode="${1:-run}"
case "$mode" in
run) run_all ;;
run-json) run_json "${2:?json-line-required}" ;;
*) echo "Usage: $0 [run|run-json <json>]" >&2; exit 2 ;;
esac
Make it executable:
chmod +x agent-test.sh
Run it (offline stub mode):
./agent-test.sh
You should see PASS/FAIL along with a summary.
To save initial goldens:
GOLDEN_UPDATE=1 ./agent-test.sh
To point the harness at a real agent:
export AGENT_URL="http://localhost:5000/complete"
./agent-test.sh
Tip: Adjust the JSON payload in call_agent() to match your API (headers, schema, etc.). Keep temperature near 0 and set a seed if your provider supports it.
Step 3: Property assertions and golden files
Property assertions (contains/regex) are resilient to small wording changes. Use them for core correctness.
Golden files are perfect for end-to-end UX snapshots. When a deliberate change happens, approve it with:
GOLDEN_UPDATE=1 ./agent-test.sh
- If a change is accidental, the diff highlights exactly what regressed.
Step 4: Make tests as deterministic as possible
Temperature 0: Minimizes randomness.
Seed: If your provider supports a seed parameter, wire it in. We included seed in the payload.
Constrain output: Ask for specific formats (e.g., JSON) and assert structurally with jq, e.g.:
printf '%s' "$text" | jq -e '.answer == 4' >/dev/null
- Stable context: Keep tools, prompts, and retrieval data versioned; reference by commit or tag in your agent.
Step 5: Run tests in parallel and prep for CI
Speed up runs with GNU parallel:
# One process per test line
cat tests/cases.jsonl | parallel --jobs 4 --line-buffer \
'./agent-test.sh run-json {=uq=}'
# Or with xargs (less robust for JSON)
jq -c . tests/cases.jsonl | xargs -I{} -P4 ./agent-test.sh run-json '{}'
CI tips:
Cache or pin models/containers so tests don’t drift across runs.
Fail the build if any test fails (the script already exits non-zero on failure).
Store goldens in your repo; protect updates with code review.
Bonus: Mock external tools and time
Agents often call tools or depend on timestamps. Keep your tests hermetic:
- Time: Set SOURCE_DATE_EPOCH to freeze timestamps in your prompts/tools.
export SOURCE_DATE_EPOCH=1700000000
- Tool shims: Put a fake tool earlier in PATH for tests:
mkdir -p .test-bin
cat > .test-bin/mytool <<'SH'
#!/usr/bin/env bash
echo '{"result":"ok","value":42}'
SH
chmod +x .test-bin/mytool
export PATH="$(pwd)/.test-bin:$PATH"
Now your agent “tool call” gets a deterministic reply.
Real‑world patterns that work
Normalize responses: Ask the agent for JSON, then validate fields with jq instead of brittle string matches.
Minimize context: Keep prompts small and focused in tests to reduce variance.
Golden at the edges, properties in the core: Use goldens for UX text and properties for factual or structural correctness.
Keep a “smoke” suite: 5–10 fast tests you can run before every commit.
Conclusion and next steps
You don’t need heavyweight frameworks to keep AI agents in line. With Bash, curl, and jq you can:
Write clear, property-based tests
Capture end-to-end behavior with goldens
Run everything quickly and repeatably on Linux
Your next step: 1) Copy the script and cases into your repo. 2) Point AGENT_URL at your agent. 3) Add 3–5 critical tests today. 4) Wire it into CI and protect GOLDEN_UPDATE with review.
If you want a deeper dive next, extend the harness to:
Validate JSON schemas with jq
Measure latency and token usage per test
Emit JUnit XML for CI dashboards
Testing AI agents doesn’t have to be mysterious. Keep it simple, keep it bashy, and ship with confidence.