Using mutmut with Django: a working recipe (conftest + pyproject) — also closes the gap in #456
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 3/5
- Tiempo estimado
- 1-2 días
- Aptitud para principiantes
- 48/100
- Tipo de issue
- Documentación
- Claridad
- Bastante claro
- Estado de actividad
- Tranquilo
- Área
- documentation, testing-qa
Línea de trabajo
Comienza con el README y las discusiones referenciadas #456 y #414; después, revisa los ejemplos de conftest.py y pyproject.toml en la raíz incluidos en este issue. Determina si una receta condensada de mutmut-plus-Django encaja en la documentación y cubre el arranque de Django, also_copy, la selección de tests y la delimitación mediante la CLI; se considera terminado cuando el proyecto haya aceptado una guía de documentación o exista una decisión clara sobre el alcance.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Using mutmut with Django: a working recipe (conftest + pyproject)
Opening this as an issue because Discussions aren't enabled on the repo — happy to close/move it if there's a better venue. This is intended as a reference for other Django users who hit the same integration problems I did, and a prompt for whether any of it deserves to land in the official docs.
Relation to #456. This is effectively a complete answer to the open question in #456 ("Is there a recommended way to use mutmut with Django projects?"). @Otto-AA's reply there correctly points at
paths_to_mutate=<root>+ a CLI glob (per #414), which is necessary but not sufficient for a Django app — the OP's second failure mode ("64 passed … we could not find any test case for any mutant") is caused by Django not being bootstrapped insidemutants/when pytest runs there. The three pieces below (rootconftest.pywith an idempotent bootstrap,also_copy, andpytest_add_cli_args_test_selection) close that gap.
TL;DR
mutmut works great against a Django codebase, but getting there requires three non-obvious pieces to click together:
- A root
conftest.pythat bootstraps Django idempotently across the manypytest.main()calls mutmut makes per process. - A
[tool.mutmut]config that copies that conftest intomutants/, keeps the whole project tree aspaths_to_mutate(so the type checker can still resolve intra-project imports), and narrows pytest collection to a single test file per target. - A CLI glob passed to
mutmut runso only the target module's mutants are actually executed, even though every file was generated.
Full working setup below — battle-tested on a Django 5.2 + Python 3.14 + pyrefly project, reaching ~83% effective mutation score on service modules.
The problem
Django's test story assumes python manage.py test (unittest-based DiscoverRunner). mutmut drives tests via pytest.main(). Three friction points:
- Django isn't configured at pytest-collection time, so importing any
django.test.TestCasesubclass explodes. - mutmut invokes
pytest.main()multiple times per process (for stats, clean baseline, forced-fail check, per-mutant forks). A naivedjango.setup()+setup_databases()at module import would run over and over, tearing down and recreating the test DB each time. - If you restrict
paths_to_mutateto a single file to "make it fast", your type-check command can no longer resolve imports from sibling modules, so every mutant fails type-check for the wrong reason.
The fix
1. Root conftest.py
"""
Root conftest.py loaded only by pytest (used indirectly via mutmut).
Bootstraps Django and the test database so `django.test.TestCase`
subclasses run under plain pytest without pytest-django. Copied into
`mutants/conftest.py` by mutmut via `[tool.mutmut].also_copy`. Django's
own test runner does not load pytest conftests, so this file is inert
under `python manage.py test`.
Why guard on `_TestState` instead of a module-level flag: mutmut invokes
`pytest.main()` multiple times per process (stats, clean run, forced-fail,
per-mutant forks). Between invocations pytest re-imports conftests, which
resets module-level variables but leaves Django's already-imported modules
(and therefore `django.test.utils._TestState`) intact. Using Django's own
state as the idempotency check survives the re-import. `keepdb=True` makes
`setup_databases` idempotent on disk as well.
"""
from __future__ import annotations
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "onsen.settings")
import django # noqa: E402
django.setup()
from django.test.runner import DiscoverRunner # noqa: E402
from django.test.utils import _TestState # noqa: E402
from django.test.utils import setup_test_environment # noqa: E402
if not hasattr(_TestState, "saved_data"):
setup_test_environment()
_runner = DiscoverRunner(verbosity=0, keepdb=True)
_runner.setup_databases()
Key insights:
- Idempotency check uses
django.test.utils._TestState, not a module-level_bootstrapped = Trueflag. Pytest re-imports the conftest betweenpytest.main()calls, wiping module globals — but Django's_TestStateclass attribute persists because Django's own modules are not re-imported. Checkinghasattr(_TestState, "saved_data")is a reliable "did we already callsetup_test_environment()?" probe. keepdb=Truemakessetup_databases()reuse the existing test DB on subsequent calls instead of dropping and recreating it. Combined with the_TestStateguard, Django is fully bootstrapped exactly once per process.- pytest-django is intentionally not used. The project's canonical test runner is
python manage.py test; adding pytest-django would introduce a parallel test-DB lifecycle. This conftest is the minimum shim to let mutmut's pytest-driven pipeline coexist with Django's unittest runner.
2. pyproject.toml
[tool.mutmut]
paths_to_mutate = ["onsen"]
pytest_add_cli_args_test_selection = [
"tests/onsen/apps/core/service/test_gateway_payment_manager.py",
]
also_copy = ["conftest.py"]
type_check_command = ["pyrefly", "check", "--output-format=json"]
debug = false
Why each key matters:
paths_to_mutate = ["onsen"]— the whole app tree is copied tomutants/so the type checker (pyrefly here, but mypy would be the same) can resolve intra-project imports (e.g. a view importing its sibling form). Narrowing this to a single file breaks type checking for every mutant that touches an import. We scope the actual mutation testing via the CLI glob instead (next section).pytest_add_cli_args_test_selection— narrows pytest collection to the one test file relevant to the target module. Without this, pytest collects the entiretests/tree and blows up on unrelated files that import Django-specific helpers at module scope (django.utils.timezone, settings accessors, etc.). This one line is the difference between "mutmut works" and "every run fails at collection".also_copy = ["conftest.py"]— ships the root conftest intomutants/so Django bootstraps against the mutated source tree, not the original. Required for any target whose tests hit the ORM or signals. Harmless for pure-function targets, so leave it on.type_check_command = ["pyrefly", "check", "--output-format=json"]— pre-rejects ~15–20% of generated mutants as type-invalid before they even reach pytest. Massive speedup. The JSON output makes mutmut's parsing reliable.debug = false— flip totruewhen diagnosing bootstrap failures; it prints the pytest invocation per run.
3. Running with a CLI glob
mutmut run "onsen.apps.core.service.gateway_payment_manager*"
Even though the whole onsen/ tree gets generated into mutants/, the glob scopes execution to the target module. Trivial mutants in unrelated files are created but skipped, so a run takes minutes instead of hours while type checking still sees a coherent project.
4. Use --noinput when running Django's own test runner
Because conftest.py calls setup_databases(keepdb=True), the test DB is left on disk. If you then run python manage.py test to sanity-check your changes, it will prompt interactively to drop the reused DB and hang in CI or Docker. Pass --noinput:
python manage.py test --noinput tests.onsen.apps.core.service.test_gateway_payment_manager
Observed result
On a ~600-line service module with 157 mutants:
- 103 killed by tests
- 27 caught by pyrefly (type-invalid)
- 27 survived — all logger-argument mutants (
logger.debug("msg %s", x)→logger.debug(None, x)) or mutations on a no-opdict.popline
Effective score = (killed + type_caught) / total = 82.8%.
The logger-argument survivors are the only consistent "accept as survivor" bucket I've found — killing them requires asserting exact log-message strings, which is brittle and violates parameterized-logging conventions. Anything else in practice has been a real test gap or a real latent bug.
Questions / possible doc additions
- Is there a more idiomatic way to bootstrap Django for mutmut? The
_TestStateguard works but feels like leaning on a Django implementation detail. Is there a supported "run me once per process" hook I'm missing? - Would a short "mutmut + Django" section in the README be welcome? I'd be happy to open a PR with a distilled version of the above — roughly the three code blocks and the four bullet-point rationales — if it's in scope for the project. If accepted, this could also serve as the canonical answer to #456 and close it.
paths_to_mutate+ CLI-glob pattern. This two-step (generate everything, execute a subset) isn't obvious from the docs — #456 is a recent example of a user hitting exactly this. Is there a leaner way to keep type-check context while narrowing execution, or is this the intended pattern?
Thanks for a genuinely great tool — the type-check integration alone saved us hours on every run.
- Lenguaje dominante
- Python
- Estrellas
- 1.5k
- Forks
- 179
- Merge medio
- 1 d 18 h
- PR fusionados (30 d)
- 6
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Sin plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de boxed/mutmut
-
`run` with a `name__mutmut_*` pattern runs the whole test suite in the clean-test checkPosiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
Los mantenedores suelen responder en 1 día
-
Invalid unary plus generated in match statementPosiblemente ocupada @GhostCoder6969 la tomó hace 9 días. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
Los mantenedores suelen responder en 1 día
-
What do the emoji's mean?Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
boxed/mutmut#560 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
boxed/mutmut#529 · 7 comentarios ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 64/100
boxed/mutmut#503 · 4 comentarios ·
Los mantenedores suelen responder en 1 día
Todos los issues de boxed/mutmut
Issues similares
-
docs(types): update the collection binding note now that typed collections shipped in pycubrid 1.9.0Abiertodocumentation priority: low size: S
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
cubrid-lab/sqlalchemy-cubrid#768 ·
Los mantenedores suelen responder en 1 día
-
--csv-bom was never wired up: PR #850 added an unused helper parameter, so #846 is not fixedAbiertobug help wanted
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
Los mantenedores suelen responder en 1 día
-
Broken link in index.rstAbiertodocumentation
Dificultad 1/5 Menos de una hora Aptitud para principiantes 65/100
ansys/pydpf-core#3547 ·
Los mantenedores suelen responder en 1 día
-
core
Dificultad 2/5 1-3 horas Aptitud para principiantes 70/100
vectorize-io/hindsight#5457 ·
Los mantenedores suelen responder en 1 día
-
[Bug]: LangChain drops OpenAI Responses text blocks from session recordingPosiblemente ocupada @ktz03 la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
volcengine/OpenViking#5806 ·
Los mantenedores suelen responder en 1 día