Skip to main content

teeth

Your tests are green. Do they bite?

CI Zero dependencies Languages MIT


Your agent adds a spending limit:

if amount > limit:
    raise ValueError("over limit")

It writes two tests: one well under the limit, one well over. All green. PR approved.

Now change > to >=. The tests still pass. Nobody checked what happens at the limit, which is exactly where the bug report will come from.

Your tests execute that line, so coverage says 100%. They just don't check it. A test suite can be green and blind at the same time.

teeth finds the blind spots. It makes small, deliberate bugs in the lines you changed, one at a time, and reruns your tests. Every bug your tests don't notice is a behavior nobody is checking:

$ teeth 'pytest -q'
Mutation score: 3/4 caught (75%)

1 mutant(s) survived. Your tests did not notice these behavior changes:

  billing.py
    :2    comparison if amount > limit:
               → if amount >= limit:

One boundary test later: 4/4, and that bug can't ship.

Try it on your branch

uvx --from git+https://github.com/sandeepsirodia/teeth teeth 'pytest -q'        # or 'npm test', 'go test ./...', 'cargo test'

It only mutates lines your branch changed (vs main), so it takes seconds to minutes, not the hours classic mutation testing takes on a whole codebase.

What it breaks on purpose

Mutation Example
comparisons > → >=, == → !=, === → !==
booleans and ↔ or, && ↔ ||, True ↔ False, drop a not
arithmetic + ↔ -, * ↔ /
constants 0 ↔ 1, n → n+1
returns return total → return None / return null
deleted checks raise … / throw … / a bare call → removed
forced branches if cond: → if True: and if False:

Strings and comments are never touched: each file is lexed first (quotes, #/////* */, Python triple quotes, JS template literals, Rust lifetimes).

Make your agent do it: Claude Code Stop hook

teeth install-hook --test-cmd 'pytest -q' --min-score 0.8

Now when your agent says "done, all tests pass", teeth runs first. If mutants survive, the agent gets told exactly which behavior changes its tests missed, and it goes back to write the assertion. It only blocks once per turn, so it can never loop.

On a real Claude-written commit

teeth on simonw/llm commit e1267a4: 9 of 15 mutants caught; survivors at the condensing length boundary, the empty-container case and a missing model id

simonw/llm commit e1267a4 (co-authored with Claude) added JSON payload condensing plus 150 lines of tests. teeth, 20 sampled mutants, 45 seconds:

Mutation score: 9/15 caught (60%)
  llm/logs.py
    :925  comparison if size >= _CONDENSE_MIN_LENGTH:
               → if size > _CONDENSE_MIN_LENGTH:
    :918  condition  if isinstance(value, (dict, list)) and value:
               → if True:
    :1026 condition  if not model_id:
               → if False:
    …

Those are real questions for the tests: nothing pins the exact length where condensing starts, the empty-container case, or a missing model id. That's no criticism of a well-tested project. It's what 150 lines of good tests still leave open, and it takes a mutation tool to see it.

I first ran it against the commit's own test file (160 tests), then against every llm test file that's green in my environment (763 tests): the same 9/15 and the same six survivors both times, so it isn't an artifact of a narrow test selection.

The first time I ran this, teeth said 0/16. llm is installed in editable mode, so every import went back to the original checkout and none of the mutants ever ran. That's now fixed (the copy is put first on PYTHONPATH), and more importantly guarded: teeth replaces each changed file with garbage before starting, and if your tests still pass, it tells you they never load that file instead of reporting fake survivors.

More real runs: Claude-written commits in simonw/datasette

Commit What it did Score
96226621 Fix SQL injection in escape_sqlite() 3/3 caught: the fix is well pinned
211e70d4 Return 400 instead of 500 for wrong-arity row URLs 3/3 caught
591b909a Escape table names containing [brackets] 0/1 with the commit's own test file, 1/1 with the whole suite
1c514d69 Fix open redirect via backslash nothing to mutate (the fix is inside a regex string)

Two lessons the third and fourth rows taught me:

  • A survivor only means "not caught by the tests you ran". Run just the changed test file and teeth will report survivors that the rest of the suite catches. Pass your real test command (the whole suite, or a fast marker-selected subset) before reading anything into a low score.
  • teeth refuses to score a red suite. My first datasette run stopped with "your tests fail without any mutation" because I'd installed dependencies at the wrong commit. That's the tool working as designed: a mutation score over failing tests would be meaningless.

I ran it on my own code first

The morning I wrote teeth, I pointed it at a fix I'd just committed to readme-lies:

Mutation score: 5/6 caught (83%)
  readme_lies.py
    :285  constant   … m.group(1) …
               → … m.group(0) …

I looked, and it's an equivalent mutant: group(0) is group(1) plus the tool's own name, which never contains a flag, so behavior can't change. No test could catch it, because there's nothing to catch. That's the honest limit of mutation testing, and why waivers exist:

args = re.sub(…, m.group(1))  # teeth: ignore — group(0) only adds the tool name; equivalent

Waivers need a reason, and the report lists them, so they stay visible in review.

Built to be trusted

teeth writing a wrong verdict would be worse than no verdict, so:

  • Your working tree is never touched. Mutants live in temporary copies; heavy directories like node_modules are symlinked, not copied.
  • Every mutant really runs. Build caches (__pycache__, etc.) are stripped and timestamps are bumped. Without that, a same-size edit like n -= 1 → n -= 0 can silently run the cached original. I hit exactly that bug while building teeth; there's now a regression test for it.
  • Every file is proven to be exercised. Before any mutant runs, each changed file is replaced with garbage. If your tests still pass, that file isn't loaded by your test command (not imported, or imported from an installed copy), so its mutants are skipped and flagged, never reported as fake survivors.
  • Infinite loops count as caught. A mutant that makes your tests hang is killed at --timeout, whole process tree included.
  • Deterministic. Same results with -j 1 and -j 8. --max-mutants 50 samples evenly across files with a fixed seed.

Options

--base main What to diff against (default: origin/HEAD, main or master)
-j 8 Parallel workers, each in its own copy
--max-mutants 50 Cap the run for big diffs
--min-score 0.8 Exit 1 below this, for a CI gate
--format github Survivors as PR annotations
--list Show the mutants without running anything
--json Machine-readable
{file} in the test command Replaced by the mutated file, for per-file test runs

Honest limits

  • Mutations are text-level (with real lexing), not AST-perfect. Some mutants won't compile (e.g. a < inside TypeScript generics); those count as caught, which slightly flatters the score.
  • Regex patterns and string constants are never mutated, because strings are masked so they can't be corrupted. A real example: datasette's fix for an open redirect changed re.sub(r"^/+", …) to re.sub(r"^[/\\]+", …). The whole fix lives inside a string, so teeth reports "nothing to mutate" for it. Mutation testing of regexes needs a different tool.
  • Equivalent mutants exist (see above). teeth can't prove equivalence; you waive them with a reason.
  • A score is about your changed lines only. It says nothing about code you didn't touch.

Prior art, and what's new here

Mutation testing is decades old, and diff-scoped runs aren't new either: cargo-mutants --in-diff, Stryker, PIT, mutmut, gomutants and mull all do real mutation testing. They understand their language far better than teeth does. If one exists for your stack, use it.

teeth is for the gap around them:

  • one zero-install tool across Python, JS/TS, Go and Rust
  • a Claude Code Stop hook, so the agent that wrote the tests has to answer for them
  • a canary check that refuses to report mutants in files your tests never load
Development
python -m unittest discover -s tests -v

Tests map 1:1 to SPEC.md. They build real git repos with a main and a feature branch. E12 checks teeth against a hand-labelled answer key: which mutants the tests should catch and which they shouldn't.

MIT © Sandeep Sirodia · If teeth found a blind spot before production did, a ⭐ helps others find it.

Release files for teeth 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for teeth 0.1.0
File Size Uploaded
teeth-0.1.0.tar.gz 18.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for teeth 0.1.0
File Interpreter ABI Platform
teeth-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 33.2 kB

Release files / teeth-0.1.0.tar.gz

Download URL teeth-0.1.0.tar.gz
Size 18.5 kB
Tags Source
SHA-256 checksum
How to use checksums
fe3f67d21954037eafbf01a1decb2f8455346383554617d6a84ecde804751de9
BLAKE2b-256 checksum
How to use checksums
2749b1647f0a627e7cd708063eb48018d1b567d06696d1f6c860fd2c190bfc0a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release files / teeth-0.1.0-py3-none-any.whl

Download URL teeth-0.1.0-py3-none-any.whl
Size 14.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7f5614b8f739d37a391e9636f6192714ba06d811e66d0427298967a26c53a11b
BLAKE2b-256 checksum
How to use checksums
58f92f253a774b1c95e2c7cdc9bf1a81418118db86c2bf156830dd84eb410e07
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page