Setting it up with an AI agent¶
Most teams will not wire Angelo in by hand. They will ask an agent — Claude Code, Copilot, Cursor, or whatever their company runs internally — to "add mutation testing to CI", and the agent will produce a workflow file in about a minute.
That workflow is usually green and meaningless. Angelo's characteristic failure is inventing a test gap that does not exist, and an agent that skips four setup facts produces exactly that: a pipeline that runs, prints a score, blocks nothing, and measures nothing.
This page is the missing context. It is written to be pasted into an agent, not read for pleasure. If you are a person doing this by hand, Integrations is the shorter road.
Ask one question before writing anything¶
Angelo produces two different setups and they are not substitutes. An agent that guesses gets it wrong half the time, so ask.
flowchart TD
A[Add mutation testing] --> B{What should it do<br>when the score is bad?}
B -->|Block the merge| C[Gate the build<br>--fail-under]
B -->|Show me on a dashboard| D[SonarQube<br>--sonar-report]
B -->|Both| E[Both, and they compose]
C --> F[Exit code stops CI]
D --> G[Survivors as issues<br>no score, no gate]
E --> F
E --> G
| The user wants | Route | Gives them |
|---|---|---|
| A merge blocked on a bad score | --fail-under |
An exit code. The only thing that actually stops a bad change |
| Survivors visible where they already read code quality | --sonar-report |
Issues on the right files and lines. No score, no history, no gate |
| Both | Both flags on one run | One run, two outputs. This is the common answer |
Never offer the dashboard as a substitute for the gate
Only survivors become Sonar issues. A run where every mutant errors uploads nothing,
and Sonar renders nothing as a clean bill of health. --fail-under is what catches that.
If a user asks for SonarQube only, set up both and tell them why.
If the request does not say which, ask the user. It is one question and it changes the whole deliverable.
Paste this into the agent: a CI gate¶
Set up Angelo mutation testing in CI for this repository.
Angelo is a Rust binary that drives an ordinary pytest suite. It plants faults and
reports which ones the tests fail to catch. Docs: https://angelo.kcvdh.com/
Work through these in order. Do not skip the inspection step — the failure mode of a
careless setup is a pipeline that passes while measuring nothing.
1. INSPECT THE PROJECT FIRST
- Find the pytest config: `[tool.pytest.ini_options]` in pyproject.toml, or
pytest.ini, tox.ini, setup.cfg.
- Read its `addopts`. If it names a pytest plugin — `--cov`, `-n`, `-p xdist`, or any
other — you MUST handle it in step 3 or the run silently scores 0%.
- Find the source directory (the package being tested), not the tests directory.
- Check whether the suite is currently green.
2. INSTALL, IN THE SAME JOB AS THE SUITE
- The project's own test dependencies, exactly as the existing test job installs them.
- `pip install coverage` — near-mandatory. Without it Angelo runs one mutant per
pytest run (about 8x slower) and cannot route around a red suite.
- `pip install --index-url https://test.pypi.org/simple/ angelo` — pin the version.
Real PyPI is still to come. That index flag REPLACES PyPI, so give Angelo its own
pip command, or add `--extra-index-url https://pypi.org/simple/` to keep PyPI in
the search.
3. WRITE angelo.conf AT THE REPOSITORY ROOT AND COMMIT IT
```toml
paths = ["src"] # the package, never "." and never the tests
test_command = "python -m pytest" # must stay this shape, see below
fail_under = 0 # leave 0 here; pass the threshold as a flag
exclude = ["**/migrations/**"] # generated and vendored code
```
- `test_command` MUST match `<something containing python> -m pytest`. A bare
`pytest`, `uv run pytest`, `poetry run pytest`, `tox` or `make test` disables
coverage, batching, test selection and warm workers all at once. Point it at an
interpreter instead, with an ABSOLUTE path if it is not the one on PATH.
- If the project's `addopts` loads a plugin, add `warm_workers = false` and put the
neutralising flags in `test_command` (see the traps table on this page). Do not
add extra pytest flags while `warm_workers` is true — they are dropped.
- Do NOT run `angelo init` in CI. It exits 1 when angelo.conf already exists, and
`angelo exec` writes one itself when it is missing.
4. ADD THE WORKFLOW STEP
- Scope it to the pull request with `--diff-base`, and set `fetch-depth: 0` on the
checkout. A shallow clone has no merge base and Angelo will stop rather than guess.
- Gate with `--fail-under`. Start at a threshold the project ALREADY clears — run it
once, read the score, set the number just under it. Do not invent 80.
- Write `--html-report` and upload it with `if: always()`. The report matters most on
the run that failed.
5. VERIFY BEFORE HANDING IT OVER
Run it locally or on a branch and check the output against the "Verify" section of
https://angelo.kcvdh.com/ai-setup/. A score of 0.0% or 100% is a broken setup, not a
result. Report the actual score you saw. Do not claim it works if you did not run it.
The workflow that instruction should produce:
name: Mutation testing
on: pull_request
jobs:
mutants:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # --diff-base needs a merge base
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install the suite, coverage and Angelo
run: |
pip install -e ".[test]"
pip install coverage
pip install --index-url https://test.pypi.org/simple/ "angelo==0.2.2"
- name: Mutate what this branch adds
run: angelo exec --diff-base --fail-under 70 --html-report angelo.html
- name: Keep the report either way
if: always()
uses: actions/upload-artifact@v4
with:
name: mutation-report
path: angelo.html
Paste this into the agent: SonarQube as well¶
Add this to the instruction above rather than replacing it. The two compose into one run.
Also publish the survivors to SonarQube.
- Add `--sonar-report angelo-sonar.json` to the same `angelo exec` command that already
carries `--fail-under`. One run writes both.
- Point the scanner at it: `-Dsonar.externalIssuesReportPaths=angelo-sonar.json`.
- Run the scan step with `if: always()`, because the issues are most worth uploading on
the run the gate failed.
- Install NOTHING on the SonarQube server. Angelo writes Sonar's generic issue import
format and Sonar registers the two rules as external rules from the file alone. This
works on SonarQube Cloud, Server and Community Build.
- Run the scanner from the repository root, the same directory Angelo ran in. Sonar
resolves `filePath` by literal string match against the scanner's base directory, and
a path that does not match is dropped WITH NO ERROR.
- Tell the user two things plainly:
1. No mutation SCORE reaches SonarQube this way. Generic import creates issues; a
percentage is a measure, and there is no import path for measures. The score
lives in the exit code and the HTML report.
2. Getting the score into Sonar as a real metric needs a Java plugin on a
self-hosted server, and third-party plugins do not run on SonarQube Cloud at
all. If the user is on Cloud, do not offer it.
- Do not build a converter. Do not fetch Stryker's jq filter. `--sonar-report` writes the
current format directly; the jq route emits a shape SonarQube has deprecated and warns
about mid-scan.
- name: Mutate what this branch adds
run: angelo exec --diff-base --fail-under 70 --sonar-report angelo-sonar.json
- name: SonarQube scan
if: always()
uses: SonarSource/sonarqube-scan-action@v6
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
args: -Dsonar.externalIssuesReportPaths=angelo-sonar.json
For a company's own assistant¶
An internal assistant answers the same question a hundred times, so the facts belong in its
rules file — AGENTS.md, CLAUDE.md, a system prompt, a retrieval corpus, whatever the
platform calls it. This block is sized to drop in whole.
## Angelo (mutation testing)
Angelo measures test QUALITY, not coverage. Coverage says a line ran; a mutation score
says its behaviour was actually asserted on. A normal score is 50-80%. A score of 100%
means too few mutants, and 0% means a broken setup.
When asked to add, change or debug Angelo:
- Ask whether they want a build gate (`--fail-under`), a SonarQube dashboard
(`--sonar-report`), or both. Do not guess. Recommend both, and explain that only the
exit code can block a merge.
- Never raise `--fail-under` above the score the project currently earns. A gate that
fails on day one gets deleted on day two.
- Never present a score without its `error` count. An all-`error` run means the test
command is broken, not that the code is untestable.
- `--diff-base` for a pull request, `--diff` for local editing. They are not
interchangeable: `--diff` compares against the working tree, and a pushed branch has
no uncommitted changes, so in CI it finds nothing.
- The pool is fixed at enumeration. Changing `paths`, `exclude` or `--diff` does nothing
until `.angelo/` is deleted.
- `--sample N` DELETES the surplus mutants and reseeds from the clock, so two sampled
runs are not comparable. Never use it in a gate.
- Angelo runs natively on Windows. Do not suggest WSL, and do not suggest mutmut as an
alternative on Windows — it needs fork().
- Do not add a mutation testing job to a repository whose suite is not already green in
CI. Fix the suite first.
The checklist an agent works through¶
| Check | Why it matters | If it is wrong |
|---|---|---|
python and pytest on PATH |
Angelo drives the real suite | Nothing runs |
coverage installed |
Unlocks batching and test selection | ~8x slower, and a red suite stops the run |
test_command is python -m pytest shaped |
Coverage wrapping, warm workers and selection all pattern-match it | Every speed feature silently switches off |
No plugin flags in the pytest addopts |
Angelo does not neutralise them | 0.0% score, every mutant a false survivor |
paths names the package |
The default is . when there is no src/ |
Docs, scripts and examples get mutated |
fetch-depth: 0 on the checkout |
--diff-base needs a merge base |
The run stops and says so |
| A green suite in CI, not just locally | A red baseline needs coverage to route around | Mutants come back untestable |
angelo.conf committed, angelo init not in the job |
init exits 1 when the file exists |
The job fails on a file that is already correct |
Six ways the setup fails quietly¶
Ranked by how hard they are to notice. The first two are the dangerous ones, because they inflate the score, and a flattering number is the one nobody investigates.
| Symptom | Cause | Fix |
|---|---|---|
Score much higher than expected, most verdicts timeout |
A pytest plugin from addopts is running inside the warm worker and hanging it. Timeouts count as detected |
warm_workers = false in angelo.conf, plus the neutralising flag below |
| Score is 100% on a suite you do not trust | The baseline exits 1 for a reason that is not a test failure — a pytest-cov --cov-fail-under threshold, or a plugin that crashes on import. Every mutant run then exits 1, and exit 1 means killed |
Run the test command by hand. It must exit 0 |
| Score is 0.0%, every mutant "survived without a single run" | pytest-cov (--cov in addopts) or pytest-xdist (-n auto) took over the coverage run, so the per-test map matched no mutant. Angelo warns, at warn level |
Neutralise the plugin in test_command, see below |
Every mutant is error |
The test command cannot run in the pipeline. Usually a missing dependency or plugin | Run the test command by hand in the job |
| The run is slow and the log says nothing about batches | coverage is not installed, or test_command is not python -m pytest shaped |
Install coverage; fix the command |
A new exclude or --diff changes nothing |
The pool was fixed at enumeration | Delete .angelo/ |
Neutralising a plugin in addopts¶
Measured on the demo project, which scores 62.2% when it is set up correctly.
The project's pytest addopts |
What Angelo scored | With the fix |
|---|---|---|
--cov=. --cov-report=term-missing (pytest-cov) |
0.0%, 74 false survivors, exit 0 | test_command = "python -m pytest --no-cov" |
-n auto (pytest-xdist) |
0.0%, 74 false survivors, exit 0 | test_command = "python -m pytest -o addopts=" |
Extra pytest arguments need warm_workers = false today
The warm worker starts its own pytest with a fixed argument list, so anything you
add to test_command past python -m pytest is honoured by the baseline and dropped
by the mutant runs. The two then disagree. On the pytest-xdist case above that
produced a run of 65 timeouts and an inflated 87.8%, against 62.2% with warm
workers off.
So: if test_command carries any extra argument, set warm_workers = false in the
same config. The run is slower and the verdicts are right, which is the correct trade
every time.
Verify before handing it over¶
An agent must run this once and read the output. Four lines decide whether the setup works.
| Look for | Good | Bad |
|---|---|---|
baseline green in Ns |
Green, and a duration that resembles the real suite | RED, or a duration far too short |
N mutants sit on lines no test executes |
A small fraction of the pool | All of them. The coverage map matched nothing |
running N batches on M workers, covering tests only |
Present, and N is well under the mutant count | Absent — coverage or test_command is wrong |
| The score | Between roughly 40% and 90% | 0.0% or 100.0%, or an error count near the pool size |
Any WARN line |
None | Read every one. They are the setup telling you it is broken |
A run of the demo project that is set up correctly ends like this:
Report the number you actually saw. An agent that says "mutation testing is now wired up" without a score in the message has not finished the job.
What an agent must not do¶
- Do not set a threshold the project does not already clear. Run it, read the score, set the gate under it. A gate that fails on the first pull request gets removed.
- Do not use
--samplein a gate. The sample is redrawn from the clock every run, so the threshold measures a different corner of the codebase each time. - Do not add mutation testing to a red pipeline. Fix the suite first.
- Do not run it on every push to start with. Mutation testing is minutes, not seconds.
on: pull_requestwith--diff-baseis the shape that stays affordable. - Do not report success without a score. A pipeline that runs is not a pipeline that measures.