Skip to main content

pytest-leela

Type-aware mutation testing for Python.

PyPI version Python versions License CI


What it does

pytest-leela runs mutation testing inside your existing pytest session. It injects AST mutations via import hooks (no temp files), maps each mutation to only the tests that cover that line, and uses type annotations to skip mutations that can't possibly fail your tests.

It's opinionated: we target latest Python, favour speed over configurability, and integrate with pytest without separate config files or runners. If that fits your workflow, great. MIT licensed — fork it if it doesn't.


Install

pip install pytest-leela

Quick Start

Run mutation testing on your whole test suite:

pytest --leela

When no --target is given, pytest-leela auto-discovers source files:

  1. Looks for a target/ or src/ directory (in that order) and mutates all .py files inside it
  2. If neither exists, falls back to the project root (skipping .venv, build, dist, __pycache__, node_modules, .git, and similar non-source directories)

Test files (test_*.py, *_test.py, conftest.py, tests.py) are always excluded from mutation.

Exclude patterns from [tool.pytest-leela] (see Configuration) are applied after target discovery, so you can auto-discover src/ while still excluding specific paths.

Target specific modules (pass --target multiple times):

pytest --leela --target myapp/models.py --target myapp/views.py

Only mutate lines changed vs a branch:

pytest --leela --diff main

Limit CPU cores:

pytest --leela --max-cores 4

Cap memory usage:

pytest --leela --max-memory 4096

Combine flags:

pytest --leela --diff main --max-cores 4 --max-memory 4096

Generate an interactive HTML report:

pytest --leela --leela-html report.html

Benchmark optimization layers:

pytest --leela-benchmark

Features

  • Type-aware mutation pruning — uses type annotations to skip mutations that can't possibly trip your tests (e.g. won't swap + to - on a str operand)
  • Per-test coverage mapping — each mutant runs only the tests that exercise its lines, not the whole suite
  • In-process execution via import hooks — mutations applied via sys.meta_path, zero filesystem writes, fast loop
  • Git diff mode — --diff <ref> limits mutations to lines changed since that ref
  • Framework-aware — clears Django URL caches between mutants so view reloads work correctly
  • Resource limits — --max-cores N caps parallelism; --max-memory MB guards memory
  • HTML report — --leela-html generates an interactive single-file report with source viewer, survivor navigation, and test source overlay
  • CI exit codes — exits non-zero when mutants survive or error, so CI pipelines fail on incomplete kill rates
  • Benchmark mode — --leela-benchmark measures the speedup from each optimization layer

Configuration

Configure pytest-leela in your pyproject.toml under [tool.pytest-leela]:

[tool.pytest-leela]
exclude = [
    "*/migrations/*",
    "manage.py",
    "*/conftest.py",
]
operators = [
    "arithmetic",
    "comparison",
    "boolean",
    "return",
]
fail_on_error = true

exclude

A list of glob patterns. Files whose path (relative to the project root) matches any pattern are excluded from mutation. Uses fnmatch matching — * matches within a single directory, */migrations/* matches any migrations directory at any depth.

Default: [] (nothing excluded).

operators

A list of operator category names controlling which mutation types are applied. Use "all" as a shorthand to enable every category.

Default: ["arithmetic", "comparison", "boolean", "unary", "return"]

Category What it mutates Default
arithmetic + - * / // % ** on
comparison == != < <= > >= is in and negations on
boolean and / or on
unary unary - + not on
return return values (True/False, None, literals, expressions) on
bitwise & | ^ << >> off
augmented_assign += -= *= etc. off
ternary x if cond else y branch swaps off
control_flow break / continue swaps off
exception except handler broadening, body-to-raise off

fail_on_error

Whether errored mutants fail the session (non-zero exit). Default: true.

See Mutant statuses. Set it to false only when you accept that some mutants go untested, for example while fixing a known collection problem.


Mutant statuses

Each mutant ends in exactly one of three states:

Status Meaning Counts toward the score
killed At least one test failed or errored in setup/teardown, a test module failed to collect, the run timed out (an infinite loop introduced by the mutant), or a conftest failed to import because the mutated module raised while being imported. A setup error or a collection failure caused by the environment or by nondeterminism rather than the mutant (a flaky fixture, a module that sometimes fails to import) also counts as a kill (#13) yes
survived Tests ran and all of them passed yes
error The inner run never tested the mutant: pytest crashed (for example a conftest that raised Skipped at import, or called pytest.exit() from pytest_addoption), a conftest called pytest.exit() at import or from a collection hook, a conftest's own check failed, the run exited abnormally with no test failure or collection failure (usage error, nothing collected, interrupted), or ran zero tests (everything skipped) no

The mutation score is killed / (killed + survived). Errors are listed separately, with their reason, in the terminal report, the HTML report and the JSON output, and they fail the session unless fail_on_error = false. An error means leela could not tell whether your tests catch the mutant, so it is reported as neither a kill nor a survival.

A test module that fails to collect under the mutant is a kill: the clean baseline has already shown it collects unmutated, so the mutant broke it. That covers the target module itself raising at import time (module-level code) and a test module's own module-level check, such as assert calc.RATIO == 5. The killing tests are the files that failed to collect.

A conftest that fails to import counts as a kill only when the mutated module itself raised during that import. A conftest's own check does not count, nor does a conftest that catches the error and calls pytest.exit() or pytest.skip(): those are errors. So the same failing check is deliberately treated differently by location: assert calc.RATIO == 5 at module level in a test module is a kill, but in a conftest it is an error, because a conftest failure is not attributed to the mutant.

If a test caught the import error and the suite completed green, the mutant survived: no test noticed it. If the mutant made the tests skip themselves (a module-level pytest.skip, a skipif marker, or a conftest or test module that skips or calls pytest.exit() at import), the mutant is an error: no test ran.

Known limits of these rules:

  • A module that can only be imported once per process. If a test module imports something that refuses to load twice (for example it raises when a process-global registry is already installed), every re-import fails to collect. Through pytest --leela the outer session has already imported it, so the clean baseline fails and the run aborts. When Engine.run is called directly with use_coverage=False and a test directory, the baseline is the first import and passes, and every mutant is then scored KILLED with tests_run: 0 (#13).
  • A deliberate exit hides every kill not yet reported. When a conftest or a test module catches the target's import error and calls pytest.exit(), the inner run ends there and the mutant is an error. Tests run only after collection, so no test failure is ever reported, including a test that imports the target inside its body; nor is a sibling module's collection failure that pytest would have reached after the exit. Only a module that already failed to collect before the exit stops the run first (-x) and still kills.

Clean baseline before any mutant

Before the first mutant, leela runs the selected tests once with no mutation applied, through the same in-process path every mutant uses. If anything fails or errors there, the run aborts with leela: aborted: tests do not pass with no mutation applied (...) and a non-zero exit, and no mutants are scored. Without this check, a test that fails for an unrelated reason (a fixture that cannot run in-process, a missing service) would score every mutant as killed.

pytest-django

pytest-django blocks database access for the whole session and only restores it at unconfigure, after leela has run. Leela lifts that outer block for the duration of its run (through pytest-django's own django_db_blocker.unblock()), so database tests in the inner sessions can open connections as they do in a normal run.

Django model and admin modules are never reloaded between mutants (unless one is itself a --target), and neither is any project module they reference. Re-executing them registers models and admin classes a second time, which Django rejects, and re-importing a module they reference would split class identity (a kept model subclassing the old copy of a reloaded mixin). The trade-off: if a model or admin module imports a function from your target at module level, calls through that reference run the unmutated code, which shows up as a survivor, never as a fake kill.

Installed packages are never reloaded

Between mutants leela evicts the project's own modules so that mutated code is re-imported. Installed packages are left loaded, even when the virtualenv sits inside the project directory (uv's default .venv): anything under sys.prefix, sys.base_prefix, site.getsitepackages(), site.getusersitepackages(), or a site-packages / dist-packages directory. Re-importing them would break C extensions such as numpy, which CPython can load only once per process.


Pragma Skip

Add # leela: skip to suppress mutations on individual lines:

result = x + y  # leela: skip

To skip an entire file, place the pragma on line 1:

# leela: skip
"""This module is excluded from mutation testing."""

def legacy_code():
    ...

The pragma is detected via Python's tokenize module, so it only matches actual comments — not string literals or docstrings that happen to contain the text.


HTML Report

--leela-html report.html generates a single self-contained HTML file with no external dependencies.

What it shows:

  • Overall mutation score badge
  • Per-file breakdown with kill/survive/error counts
  • Source code viewer with syntax highlighting

Interactive features:

  • Click any line to see mutant details (original → mutated code, status, relevant tests)
  • Survivor navigation overlay — keyboard shortcuts: n next survivor, p previous, l list all, Esc close
  • Test source overlay — click any test name to see its source code

Uses the Catppuccin Mocha dark theme.


Requirements

  • Python >= 3.12
  • pytest >= 7.0

License

MIT

Metadata

Release files for pytest-leela 0.9.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 pytest-leela 0.9.0
File Size Uploaded
pytest_leela-0.9.0.tar.gz 164.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pytest-leela 0.9.0
File Interpreter ABI Platform
pytest_leela-0.9.0-py3-none-any.whl Python 3 none any Details

Total release size: 223.3 kB

Release files / pytest_leela-0.9.0.tar.gz

Download URL pytest_leela-0.9.0.tar.gz
Size 164.6 kB
Tags Source
SHA-256 checksum
How to use checksums
46dfa80b54532e2356bbf6aa6eb7bbe86a7c5398417de239ef91ef6b5f141701
BLAKE2b-256 checksum
How to use checksums
571af8a7d485c8c1856770c9f409eb187dcb62ce70caba1fffb14c1381c73c5e
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 Oct 14, 2026.

Transparency log

Release files / pytest_leela-0.9.0-py3-none-any.whl

Download URL pytest_leela-0.9.0-py3-none-any.whl
Size 58.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2c1f3729103d076ee97a3063e8e5d27cb5b814b7564df8c9e6c25fc13400c3d7
BLAKE2b-256 checksum
How to use checksums
040c46a1b41378cb6a3c2e32b39973a817297aa62dc3f8e3f85199accd5288e8
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 Oct 14, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.9.0 This release

2 release files

0.8.0

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

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