Skip to main content
Glama
Drzymek92

AI Test Pilot

by Drzymek92
README.md
# AI Test Pilot

> An LLM-driven test generator with a shared core and pluggable adapters. Point it at a Python
> module or a web page; it introspects the target, has an LLM propose test scenarios as
> schema-validated JSON, renders them into runnable tests, runs them, and triages the failures.

![Python](https://img.shields.io/badge/python-3.10%2B-blue)
![License](https://img.shields.io/badge/license-MIT-green)
[![CI](https://github.com/Drzymek92/ai-test-pilot/actions/workflows/ci.yml/badge.svg)](https://github.com/Drzymek92/ai-test-pilot/actions/workflows/ci.yml)

![AI Test Pilot — generate, run, and lock a golden suite in one command](docs/demo.gif)

**Why it's different:** most "AI writes your tests" tools let the model emit test *code* directly —
which hallucinates imports, fabricates inputs, and asserts wrong things. AI Test Pilot takes the
opposite stance: the LLM only ever returns **structured, schema-validated JSON**; every line of
runnable code is rendered **deterministically** from that JSON. The LLM is used for the two genuinely
fuzzy steps only — *proposing* scenarios and *judging* ambiguous failures — and nothing else.

## Does it actually catch bugs?

Coverage is a weak proxy — a suite can execute every line and assert nothing useful. The real test of
a test generator is **kill rate**: generate a suite from *correct* code, then run it against a *buggy*
version and count how many bugs a test that passed on the correct code now catches (standard
mutation-testing semantics). The full reproducible eval ships in [`benchmark/`](benchmark/DETECTION.md).

| Corpus | Kill rate (95% Wilson CI) | Notes |
|---|---|---|
| **QuixBugs** (external, default config) | **0.80** [0.584–0.919] (n=20) | human-verified correct↔buggy pairs |
| **In-repo AST mutation** (full ablation) | **0.818** [0.523–0.949] (n=11) | controlled mutants; the "is it overengineered?" ablation substrate |
| **HumanEval held-out — standard tool (cosmic-ray)** | **0.923** [0.906–0.937] (n=1166) | re-measured with a standard mutation tool on code never seen during development |

**vs a search-based peer:** on the same QuixBugs targets and the same kill mechanic, **ours 0.80 vs
Pynguin's 0.30** (Pynguin at a modest 30s/SIMPLE budget — a floor; even the literature's stronger SBST
range, ~0.59–0.70, sits below 0.80). The edge is the **coverage-feedback loop** — after the first pass
the suite's uncovered lines are fed back to the LLM to reach them — the feedback mechanism plain LLM
test-generation lacks.

**Honest framing:** that held-out number was *first* measured with this repo's own lightweight mutation
operators (0.98) — a generalization check, **not** a PIT-style mutation score — so it's reframed and
**re-measured with a standard tool (0.923)** for a comparable figure; cosmic-ray doesn't exclude
equivalent mutants, so 0.923 is a conservative lower bound, and the harder, most representative number
remains QuixBugs **0.80**. Methodology, comparables vs the literature, and re-run procedure:
**[benchmark/DETECTION.md](benchmark/DETECTION.md)**.

```bash
python scripts/main.py detect --subset 20      # QuixBugs + in-repo mutation kill rate + feature ablation
```

## Demo — sample run

```text
$ python scripts/main.py --target path/to/rules/commission.py --selector compute_commission --golden

introspected 1 unit(s); resolved types: OrderView, LineItemView, RulesConfig, CommissionRules
generated 5 scenario(s)
golden mode: locked 5 characterization assertion(s)
run complete: 5/5 passed

✓ 5 passed · 0 failed · 0 error / 5 generated
  tests:  scripts/outputs/tests/test_commission_<ts>.py
  report: scripts/outputs/reports/report_<ts>.md
```

A generated test constructs the real typed inputs and locks the computed result:

```python
def test_standard_commission():
    """Commission for a multi-item order."""
    result = compute_commission(
        order=OrderView(currency="PLN", status="DELIVERED", line_items=[
            LineItemView(category="electronics", unit_amount=Decimal("100.00"), quantity=2)]),
        config=RulesConfig())
    assert repr(result) == ("CommissionBreakdown(currency='PLN', "
        "items_commission=Decimal('4.00'), transaction_fee=Decimal('1.00'), "
        "total_commission=Decimal('5.00'), rule_version='v1')")
```

For the `web_playwright` adapter, the same pipeline produces self-contained Playwright tests and an
idiomatic `.spec.ts` export. Three sample targets are included: `demo/signup.html` (simple form),
`demo/login_app/` (served — auth/`storage_state` + API interception), and `demo/ws_app/` (served —
WebSocket push/echo). The served demos are run with `--serve`:

```bash
# deep web: emits base_url + auth_state fixtures, page.route interception, async variant
python scripts/main.py --adapter web_playwright --target demo/login_app/index.html --serve

# websocket: emits page.route_web_socket mock (server push + echo) + expect_ws_message
python scripts/main.py --adapter web_playwright --target demo/ws_app/index.html --serve
```

## Features

- **Structured-output pipeline** — the LLM returns JSON validated against a Pydantic schema; every line
  of test code is rendered deterministically from the validated objects, never written by the model.
- **Typed-input construction** — resolves parameter *types* from source (dataclass/Pydantic/`attrs`/
  `NamedTuple`, nested) via `ast` **without importing the target**, so it tests real domain/OO code, not
  just functions taking primitives.
- **Reproducible & fail-safe** — `temperature=0` + a scenario cache replay identical tests for an
  unchanged target; the LLM call has timeout/retry and a documented **exit-code contract**, so it never
  emits a half-generated suite.
- **Proven bug detection** — a `detect` command measures **mutation kill rate** (not just coverage),
  with a feature ablation and a **coverage-feedback loop** plain LLM test-gen lacks.
- **Advanced Playwright (served mode)** — `base_url`/`auth_state` fixtures, `storage_state` reuse,
  `page.route` network interception, and in-process WebSocket mocking — all from JSON the model emits.
- **MCP server + quality/cost gates** — callable from any MCP client; a quality-regression gate
  (false-positive rate, test-smell density…) and opt-in budget caps that abort before overspending.

<details>
<summary><b>Full feature detail</b> (click to expand)</summary>

- **Structured-output pipeline** — the LLM returns JSON validated against a Pydantic schema (with a
  one-shot repair retry); code is generated from the validated objects, never written by the model.
  The value grammar's `$type`/`$call`/`$enum` symbols (and constructor argument names) are
  **allow-listed against the types resolved from the target's own source**, so a crafted
  docstring/source can't smuggle code tokens into a generated test that then gets executed.
- **Typed-input construction** — recursively resolves a function's parameter *types* from source
  (dataclass + Pydantic + `attrs` + `NamedTuple`, nested, `Decimal`/`datetime`/`Enum`, defaults) via
  `ast` — **without importing the target** — and builds real constructor calls. It even surfaces
  Pydantic field constraints (`gt`/`le`/`min_length`, `Annotated[...]`) so the model picks
  *valid* values instead of triggering a construction-time `ValidationError`. Lets it test
  domain/OO code, not just functions taking primitives.
- **Reproducible generation** — generation runs at `temperature=0` and caches each scenario set keyed
  by the prompt + resolved model version + temperature, so re-running an unchanged target replays the
  *identical* tests at zero token cost; a model change invalidates the cache. `--no-cache` /
  `--refresh-cache` override it.
- **Assertion strength from source** — the unit's own code is fed into generation (a bounded slice),
  so assertions target *specific computed behaviour*, not just type/shape. `--golden` then locks the
  real result; together they turn a draft into a regression guard.
- **Characterization (golden) mode** — runs each call and locks the assertion to the real
  result, turning a generated test into a regression guard. Guarded against time-bombs: it
  double-runs and keeps only reproducible results, skipping any clock/RNG-reading unit whose
  time isn't pinned.
- **File & fixture inputs** — creates real temp files for file-processing functions, and can
  optionally seed inputs from a companion [synthetic data factory](https://github.com/Drzymek92/synthetic-data-factory).
- **Failure triage** — a deterministic signal table classifies most failures for free
  (`bad_scenario` / `env_issue` / a broken golden lock → `real_bug`); the LLM is called only for the
  genuinely ambiguous ones.
- **Advanced Playwright (served web mode)** — fixtures (`base_url`, `auth_state`), authenticated
  sessions via saved `storage_state`, network interception (`page.route`) to stub APIs
  deterministically, in-process WebSocket mocking (`page.route_web_socket`, server-push + echo), and
  an `async_playwright` variant. Each is just structured JSON the LLM emits — no Playwright code from
  the model.
- **Self-tracking ledger + self-improving tuning** — every run is recorded to DuckDB; `accept`
  backfills how many tests you kept. The tool then proposes the best prompt version *and* (in `auto`
  mode) injects your previously-accepted scenarios for the same target as few-shot exemplars — closing
  the loop with **zero extra LLM calls**.
- **Draft → suite workflow** — `discover` scans a project and prints ready-to-run commands per module;
  `promote` strips a draft's boilerplate, rewrites golden locks into value assertions, and appends only
  the non-duplicate tests into an existing suite. Both deterministic, zero-token.
- **MCP server** — exposes the engine as tools (`introspect`, `generate_tests`, `triage_failures`,
  `run_metrics`, `accept_run`) so it's callable from any MCP client.
- **Fail-safe by contract** — the LLM call has a timeout + exponential-backoff retry and raises
  rather than ever emit a half-generated suite; a per-test run cap bounds a hanging test; and the CLI
  has a documented **exit-code contract** (`0` ok · `1` internal · `2` usage · `3` uninspectable
  target · `4` LLM failure · `5` quality regression · `6` budget exceeded · `7` detection regression)
  for scripting.
- **Quality regression gate** — `quality` runs a curated known-good target set and reports a metric
  panel (coverage, pass-rate, false-positive rate, error rate, test-smell density, acceptance), gated
  against a stored baseline so a generation/prompt change that regresses quality fails loudly.
- **Cost guardrails** — every run measures and records real token spend (a cache replay is free);
  opt-in `[budget]` caps abort before overspending, and `sweep` "tests the diff" by generating only
  for git-changed modules under a per-sweep cap.
- **Proven bug detection (not just coverage)** — a `detect` command measures **mutation kill rate**
  (generate from correct code, re-run against buggy/mutant versions, count what's caught) over an
  external corpus (QuixBugs) + in-repo mutants, with a feature ablation and a standard-tool
  (cosmic-ray) cross-check on held-out code. A **coverage-feedback loop** then feeds the suite's
  uncovered lines back to the LLM to target them — the feedback mechanism single-shot LLM test-gen
  lacks. See [Does it actually catch bugs?](#does-it-actually-catch-bugs) and `benchmark/DETECTION.md`.

</details>

The same engine drives two target types through one **adapter seam**, so adding a new kind of
target is a single new file with zero changes to the core:

- **`python_pytest`** — points at a Python module, generates `pytest` tests.
- **`web_playwright`** — points at a web page, generates Playwright end-to-end tests (and exports
  idiomatic TypeScript alongside the runnable Python).

## How it works

```mermaid
flowchart LR
    T([target: module or URL]) --> I[1 · introspect<br/>ast / DOM — deterministic]
    I --> G[2 · generate<br/>LLM → schema-validated JSON]
    G --> M[3 · materialize<br/>render code — deterministic]
    M --> R[4 · run<br/>pytest / Playwright]
    R --> TR[5 · triage<br/>signals + LLM for ambiguous]
    TR --> L[6 · record<br/>DuckDB ledger]
    L -. 7 · propose tuning .-> G
```

Stages 1, 3, 4, 6 cost **zero tokens**. Stage 2 is one batched LLM call; stage 5 calls the LLM only
for failures the deterministic signal table can't classify. The core never imports an adapter
directly — only through a name registry — which is what keeps the two target types fully decoupled:

```mermaid
flowchart TD
    C[shared core<br/>introspect · generate · materialize · run · triage · record] --> RG[registry]
    RG --> A1[python_pytest adapter]
    RG --> A2[web_playwright adapter]
    A1 --> P[(pytest)]
    A2 --> PW[(Playwright + TS export)]
```

## Tech Stack

- **Language:** Python 3.10+
- **Core:** `pydantic` (the schema spine), `jinja2` (pytest emission), `duckdb` (the run ledger)
- **LLM:** `langchain-openai` against any OpenAI-compatible gateway (`LLM_BASE_URL`/`LLM_MODEL`/`LLM_API_KEY`)
- **Adapters:** `pytest` (python target runner), `playwright` (web — bundles its own driver, no Node needed)
- **Integration:** `mcp` (FastMCP) — exposes the engine over the Model Context Protocol

## Getting Started

### Prerequisites
- Python 3.10+ on PATH

### Installation
```bash
git clone https://github.com/Drzymek92/ai-test-pilot.git
cd ai-test-pilot
python -m venv .venv
.venv\Scripts\activate          # Windows  (source .venv/bin/activate on macOS/Linux)
pip install -e .                # installs deps + the `ai-test-pilot` command
#   (or `pip install -r requirements.txt` for deps only, without the console entry point)
cp config/.env.example config/.env     # then fill in your LLM gateway values
# for the web adapter only:
python -m playwright install chromium
```

Installed this way you can call it as `ai-test-pilot ...` anywhere; the examples below use
`python scripts/main.py ...`, which is equivalent.

### Usage
```bash
# generate pytest tests for selected functions
python scripts/main.py --target path/to/module.py --selector func_a,func_b

# lock assertions to real results (characterization / regression mode)
python scripts/main.py --target path/to/module.py --golden

# also emit deterministic validator-rejection tests (a validator-gated pydantic type refuses bad input)
python scripts/main.py --target path/to/module.py --reject-tests

# generate Playwright tests for a web page
python scripts/main.py --adapter web_playwright --target path/to/page.html

# record how many proposed tests you kept (feeds tuning)
python scripts/main.py accept <run_id> --kept 4

# scan a project for testable targets (deterministic, no LLM)
python scripts/main.py discover path/to/project

# incremental: only the modules changed in git (regenerate tests for what moved, not the whole tree)
python scripts/main.py discover path/to/project --changed          # working tree vs HEAD
python scripts/main.py discover path/to/project --since main        # vs a ref / tag / commit

# clean a draft for the suite: strip boilerplate, rewrite golden locks, append non-duplicates
python scripts/main.py promote <run_id> --into tests/test_module.py

# force a fresh (uncached) generation, or regenerate + overwrite the cache
python scripts/main.py --target path/to/module.py --no-cache       # bypass the scenario cache
python scripts/main.py --target path/to/module.py --refresh-cache  # regenerate and re-store

# quality gate: run the curated set, print the metric panel, fail (exit 5) on regression
python scripts/main.py quality                       # gate vs baseline
python scripts/main.py quality --update-baseline     # (re)set the baseline

# "test the diff": generate only for git-changed modules in a project, under a budget cap
python scripts/main.py sweep path/to/project --since main
```

Run as an **MCP server** (callable from any MCP client) — register it with:
```json
{ "command": "python", "args": ["/path/to/ai-test-pilot/scripts/mcp_server.py"] }
```

## Project Structure
```
scripts/
  main.py               # CLI + run_pipeline() (the one pipeline every interface reuses)
  mcp_server.py         # MCP server (FastMCP) exposing the engine as tools
  cli.py                # argument parsing + subcommand dispatch + the exit-code contract
  pipeline.py           # run_pipeline() — the one pipeline every interface reuses (typed RunRequest in)
  core/                 # adapter-agnostic engine: models, generate, materialize, runner, triage,
                        #   ledger, tuning, context, fixtures, registry, discover, promote, report,
                        #   feedback, cache, errors (exit codes), quality (gate), budget, detection
  adapters/             # python_pytest · web_playwright  (one file per target type)
  prompts/              # scenario-generation prompts + the pytest Jinja template
config/                 # ai_test_pilot.toml (defaults) + .env.example
benchmark/              # reproducible efficacy eval: mutation kill rate, ablation, cosmic-ray +
                        #   Pynguin baselines, Wilson CIs, corpora loaders, DETECTION.md + artifacts
demo/                   # signup.html · login_app/ (auth) · ws_app/ (websocket) — web adapter targets
tests/                  # 204 unit tests
```

## Design notes

- **Determinism first.** Introspection, code emission, running, the triage signal table, and the
  ledger are all plain code. The LLM is a tool for the two irreducibly fuzzy steps only.
- **Never imports the target.** Introspection is `ast`-only, so a target's heavy/optional
  dependencies are never triggered to generate tests for it.
- **Human-in-the-loop.** Generated tests are *proposed* into `scripts/outputs/` — never written into
  a target repository. Promoting them is a separate, explicit step.

Validated end-to-end across a typed business-rules engine, pure data-transformation helpers, a web
form, an authenticated app with API interception, and a WebSocket feed — producing runnable,
correctly-typed, regression-grade tests in each case.

> **CI note:** the published CI runs the full unit suite, which is browser-free by design — the
> `web_playwright` tests assert on the *generated test source*, not a live browser. The `--serve`
> demos are run locally (after `playwright install chromium`); CI doesn't download a browser.

## Limitations

The `python_pytest` adapter is a tested, quality-gated, cost-bounded tool I rely on for my own
pipelines — reproducible (temperature 0 + scenario cache), fail-safe (typed exit-code contract), and
guarded by a regression gate. Its honest scope is **trusted, single-machine** code: it executes the
tests it generates and sends introspected source to the configured LLM gateway, so it is not a
sandbox for untrusted targets. The `web_playwright` adapter remains a **demo** of advanced Playwright
technique. The input shapes the tool deliberately won't guess at, the non-determinism of the LLM
step, and the scale/safety/packaging boundaries are all written up in
**[LIMITATIONS.md](LIMITATIONS.md)** — knowing them is part of using it well.

## License

Licensed under the MIT License — see [LICENSE](LICENSE).