Skip to content

Commit 8e6567b

Browse files
authored
fix: implement mutation testing with mutmut v3 (#280) (#724)
* fix: implement mutation testing with mutmut v3 (#280) - Migrate mutmut configuration from setup.cfg to pyproject.toml - Add workaround script for mutmut v3 stats collection issues - Implement sample-based mutation testing (20 mutants by default) - Add full mutation testing mode for comprehensive coverage - Handle Python 3.11+ except* syntax with pragma comments - Include plugins directory in mutation test environment - Update Makefile with mutmut-run and mutmut-run-full targets - Remove deprecated setup.cfg file The mutation testing now actually runs tests against code mutations to verify test effectiveness, providing a mutation score metric. Signed-off-by: Mihai Criveti <[email protected]> * Plugins now support resources pre/post Signed-off-by: Mihai Criveti <[email protected]> --------- Signed-off-by: Mihai Criveti <[email protected]>
1 parent 751b398 commit 8e6567b

File tree

7 files changed

+253
-8
lines changed

7 files changed

+253
-8
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
mutants
2+
.mutmut-cache
13
CLAUDE.local.md
24
.aider*
35
.scannerwork

Makefile

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ TEST_DOCS_DIR ?= $(DOCS_DIR)/docs/test
2929
DIRS_TO_CLEAN := __pycache__ .pytest_cache .tox .ruff_cache .pyre .mypy_cache .pytype \
3030
dist build site .eggs *.egg-info .cache htmlcov certs \
3131
$(VENV_DIR) $(VENV_DIR).sbom $(COVERAGE_DIR) \
32-
node_modules
32+
node_modules .mutmut-cache html
3333

3434
FILES_TO_CLEAN := .coverage coverage.xml mcp.prof mcp.pstats \
3535
$(PROJECT_NAME).sbom.json \
@@ -310,6 +310,81 @@ doctest-check:
310310
python3 -m pytest --doctest-modules mcpgateway/ --tb=no -q && \
311311
echo '✅ All doctests passing' || (echo '❌ Doctest failures detected' && exit 1)"
312312

313+
# =============================================================================
314+
# 🧬 MUTATION TESTING
315+
# =============================================================================
316+
# help: 🧬 MUTATION TESTING
317+
# help: mutmut-install - Install mutmut in development virtualenv
318+
# help: mutmut-run - Run mutation testing (sample of 20 mutants for quick results)
319+
# help: mutmut-run-full - Run FULL mutation testing (all 11,000+ mutants - takes hours!)
320+
# help: mutmut-results - Display mutation testing summary and surviving mutants
321+
# help: mutmut-html - Generate browsable HTML report of mutation results
322+
# help: mutmut-ci - CI-friendly mutation testing with score threshold enforcement
323+
# help: mutmut-clean - Clean mutmut cache and results
324+
325+
.PHONY: mutmut-install mutmut-run mutmut-results mutmut-html mutmut-ci mutmut-clean
326+
327+
mutmut-install:
328+
@echo "📥 Installing mutmut..."
329+
@test -d "$(VENV_DIR)" || $(MAKE) venv
330+
@/bin/bash -c "source $(VENV_DIR)/bin/activate && \
331+
python3 -m pip install -q mutmut==3.3.1"
332+
333+
mutmut-run: mutmut-install
334+
@echo "🧬 Running mutation testing (sample mode - 20 mutants)..."
335+
@echo "⏳ This should take about 2-3 minutes..."
336+
@echo "📝 Target: mcpgateway/ directory"
337+
@/bin/bash -c "source $(VENV_DIR)/bin/activate && \
338+
cd $(PWD) && \
339+
PYTHONPATH=$(PWD) python run_mutmut.py --sample"
340+
341+
mutmut-run-full: mutmut-install
342+
@echo "🧬 Running FULL mutation testing (all mutants)..."
343+
@echo "⏰ WARNING: This will take a VERY long time (hours)!"
344+
@echo "📝 Target: mcpgateway/ directory (11,000+ mutants)"
345+
@/bin/bash -c "source $(VENV_DIR)/bin/activate && \
346+
cd $(PWD) && \
347+
PYTHONPATH=$(PWD) python run_mutmut.py --full"
348+
349+
mutmut-results:
350+
@echo "📊 Mutation testing results:"
351+
@test -d "$(VENV_DIR)" || $(MAKE) venv
352+
@/bin/bash -c "source $(VENV_DIR)/bin/activate && \
353+
mutmut results || echo '⚠️ No mutation results found. Run make mutmut-run first.'"
354+
355+
mutmut-html:
356+
@echo "📄 Generating HTML mutation report..."
357+
@test -d "$(VENV_DIR)" || $(MAKE) venv
358+
@/bin/bash -c "source $(VENV_DIR)/bin/activate && \
359+
mutmut html || echo '⚠️ No mutation results found. Run make mutmut-run first.'"
360+
@[ -f html/index.html ] && echo "✅ Report available at: file://$$(pwd)/html/index.html" || true
361+
362+
mutmut-ci: mutmut-install
363+
@echo "🔍 CI mutation testing with threshold check..."
364+
@echo "⚠️ Excluding gateway_service.py (uses Python 3.11+ except* syntax)"
365+
@/bin/bash -c "source $(VENV_DIR)/bin/activate && \
366+
cd $(PWD) && \
367+
PYTHONPATH=$(PWD) mutmut run && \
368+
python3 -c 'import subprocess, sys; \
369+
result = subprocess.run([\"mutmut\", \"results\"], capture_output=True, text=True); \
370+
import re; \
371+
match = re.search(r\"killed: (\\d+) out of (\\d+)\", result.stdout); \
372+
if match: \
373+
killed, total = int(match.group(1)), int(match.group(2)); \
374+
score = (killed / total * 100) if total > 0 else 0; \
375+
print(f\"Mutation score: {score:.1f}% ({killed}/{total} killed)\"); \
376+
sys.exit(0 if score >= 75 else 1); \
377+
else: \
378+
print(\"Could not parse mutation results\"); \
379+
sys.exit(1)' || \
380+
{ echo '❌ Mutation score below 75% threshold'; exit 1; }"
381+
382+
mutmut-clean:
383+
@echo "🧹 Cleaning mutmut cache..."
384+
@rm -rf .mutmut-cache
385+
@rm -rf html
386+
@echo "✅ Mutmut cache cleaned."
387+
313388
# =============================================================================
314389
# 📊 METRICS
315390
# =============================================================================

mcpgateway/services/gateway_service.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -507,32 +507,32 @@ async def register_gateway(self, db: Session, gateway: GatewayCreate) -> Gateway
507507
await self._notify_gateway_added(db_gateway)
508508

509509
return GatewayRead.model_validate(db_gateway).masked()
510-
except* GatewayConnectionError as ge:
510+
except* GatewayConnectionError as ge: # pragma: no mutate
511511
if TYPE_CHECKING:
512512
ge: ExceptionGroup[GatewayConnectionError]
513513
logger.error(f"GatewayConnectionError in group: {ge.exceptions}")
514514
raise ge.exceptions[0]
515-
except* GatewayNameConflictError as gnce:
515+
except* GatewayNameConflictError as gnce: # pragma: no mutate
516516
if TYPE_CHECKING:
517517
gnce: ExceptionGroup[GatewayNameConflictError]
518518
logger.error(f"GatewayNameConflictError in group: {gnce.exceptions}")
519519
raise gnce.exceptions[0]
520-
except* ValueError as ve:
520+
except* ValueError as ve: # pragma: no mutate
521521
if TYPE_CHECKING:
522522
ve: ExceptionGroup[ValueError]
523523
logger.error(f"ValueErrors in group: {ve.exceptions}")
524524
raise ve.exceptions[0]
525-
except* RuntimeError as re:
525+
except* RuntimeError as re: # pragma: no mutate
526526
if TYPE_CHECKING:
527527
re: ExceptionGroup[RuntimeError]
528528
logger.error(f"RuntimeErrors in group: {re.exceptions}")
529529
raise re.exceptions[0]
530-
except* IntegrityError as ie:
530+
except* IntegrityError as ie: # pragma: no mutate
531531
if TYPE_CHECKING:
532532
ie: ExceptionGroup[IntegrityError]
533533
logger.error(f"IntegrityErrors in group: {ie.exceptions}")
534534
raise ie.exceptions[0]
535-
except* BaseException as other: # catches every other sub-exception
535+
except* BaseException as other: # catches every other sub-exception # pragma: no mutate
536536
if TYPE_CHECKING:
537537
other: ExceptionGroup[BaseException]
538538
logger.error(f"Other grouped errors: {other.exceptions}")

mcpgateway/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ def _database_version() -> tuple[str, bool]:
340340
"postgresql": "SELECT current_setting('server_version');",
341341
"mysql": "SELECT version();",
342342
}
343-
stmt = stmts.get(dialect, "SELECT version();")
343+
stmt = stmts.get(dialect, "XXSELECT version();XX")
344344
try:
345345
with engine.connect() as conn:
346346
ver = conn.execute(text(stmt)).scalar()

mutmut_config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Configuration for mutmut mutation testing framework."""
2+
3+
4+
def pre_mutation(context):
5+
"""Skip files that shouldn't be mutated."""
6+
if context.filename.endswith("__init__.py"):
7+
context.skip = True
8+
elif "alembic" in context.filename:
9+
context.skip = True
10+
elif "migrations" in context.filename:
11+
context.skip = True
12+
# Skip gateway_service.py due to Python 3.11+ except* syntax not supported by mutmut parser
13+
elif "gateway_service.py" in context.filename:
14+
context.skip = True
15+
16+
17+
def post_mutation(context):
18+
"""Any post-mutation processing."""
19+
pass

pyproject.toml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,3 +457,25 @@ project-excludes = [
457457
'**/\.mypy_cache/',
458458
]
459459
python-version = "3.11.0"
460+
461+
# --------------------------------------------------------------------
462+
# 🧬 mutmut - Mutation testing configuration
463+
# --------------------------------------------------------------------
464+
[tool.mutmut]
465+
paths_to_mutate = ["mcpgateway/"]
466+
tests_dir = ["tests/"]
467+
do_not_mutate = ["mcpgateway/services/gateway_service.py"]
468+
also_copy = ["plugins/", "pyproject.toml", "mutmut_config.py"]
469+
470+
# --------------------------------------------------------------------
471+
# 📊 coverage - Code coverage configuration
472+
# --------------------------------------------------------------------
473+
[tool.coverage.run]
474+
source = ["mcpgateway"]
475+
omit = [
476+
"*/tests/*",
477+
"*/test_*.py",
478+
"*/__init__.py",
479+
"*/alembic/*",
480+
"*/version.py"
481+
]

run_mutmut.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Workaround script for mutmut v3 stats collection failure.
4+
Generates mutants and then runs them despite stats failure.
5+
"""
6+
7+
import subprocess
8+
import sys
9+
import os
10+
import json
11+
from pathlib import Path
12+
13+
def run_command(cmd):
14+
"""Run a shell command and return output."""
15+
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
16+
return result.stdout, result.stderr, result.returncode
17+
18+
def main():
19+
# Check for command line arguments
20+
sample_mode = "--sample" in sys.argv or len(sys.argv) == 1 # Default to sample mode
21+
sample_size = 20 # Default sample size
22+
23+
if "--full" in sys.argv:
24+
sample_mode = False
25+
print("🧬 Starting FULL mutation testing (this will take a long time)...")
26+
else:
27+
print("🧬 Starting mutation testing (sample mode)...")
28+
print("💡 Tip: Use 'python run_mutmut.py --full' for complete testing")
29+
30+
# Clean previous runs
31+
print("🧹 Cleaning previous mutants...")
32+
os.system("rm -rf mutants .mutmut-cache")
33+
34+
# Generate mutants (will fail at stats but mutants are created)
35+
print("📝 Generating mutants (this may take a minute)...")
36+
stdout, stderr, _ = run_command("mutmut run --max-children 2 2>&1 || true")
37+
38+
# Show some output to indicate progress
39+
if "done in" in stdout:
40+
import re
41+
match = re.search(r'done in (\d+)ms', stdout)
42+
if match:
43+
print(f" Generated in {int(match.group(1))/1000:.1f} seconds")
44+
45+
# Check if mutants were generated
46+
if not Path("mutants").exists():
47+
print("❌ Failed to generate mutants")
48+
return 1
49+
50+
print("✅ Mutants generated successfully")
51+
52+
# Get list of mutants
53+
print("📊 Getting list of mutants...")
54+
stdout, stderr, _ = run_command("mutmut results 2>&1 | grep -E 'mutmut_[0-9]+:' | cut -d: -f1")
55+
all_mutants = [m.strip() for m in stdout.strip().split('\n') if m.strip()]
56+
57+
if not all_mutants:
58+
print("❌ No mutants found")
59+
return 1
60+
61+
# Sample mutants for quicker testing
62+
import random
63+
64+
print(f"🔍 Found {len(all_mutants)} total mutants")
65+
66+
if sample_mode:
67+
actual_sample_size = min(sample_size, len(all_mutants))
68+
mutants = random.sample(all_mutants, actual_sample_size)
69+
print(f"📈 Testing a sample of {len(mutants)} mutants for quick results")
70+
else:
71+
mutants = all_mutants
72+
print(f"🚀 Testing ALL {len(mutants)} mutants (this will take a while)...")
73+
74+
# Run each mutant
75+
results = {"killed": 0, "survived": 0, "timeout": 0, "error": 0}
76+
survived_mutants = []
77+
78+
for i, mutant in enumerate(mutants, 1):
79+
print(f" [{i}/{len(mutants)}] Testing {mutant}...", end=" ", flush=True)
80+
81+
# Run the mutant with a shorter timeout and minimal tests
82+
cmd = f"timeout 10 bash -c 'cd mutants && MUTANT_UNDER_TEST={mutant} python -m pytest tests/unit/mcpgateway/utils/ -x --tb=no -q 2>&1'"
83+
stdout, stderr, returncode = run_command(cmd)
84+
85+
if returncode == 124: # timeout
86+
print("⏰ TIMEOUT")
87+
results["timeout"] += 1
88+
elif returncode == 0: # tests passed = mutant survived
89+
print("🙁 SURVIVED")
90+
results["survived"] += 1
91+
survived_mutants.append(mutant)
92+
elif "FAILED" in stdout or returncode != 0: # tests failed = mutant killed
93+
print("🎉 KILLED")
94+
results["killed"] += 1
95+
else:
96+
print("❓ ERROR")
97+
results["error"] += 1
98+
99+
# Print summary
100+
print("\n" + "="*50)
101+
print("📊 MUTATION TESTING RESULTS:")
102+
print("="*50)
103+
print(f"🎉 Killed: {results['killed']} mutants")
104+
print(f"🙁 Survived: {results['survived']} mutants")
105+
print(f"⏰ Timeout: {results['timeout']} mutants")
106+
print(f"❓ Error: {results['error']} mutants")
107+
108+
total = sum(results.values())
109+
if total > 0:
110+
score = (results['killed'] / total) * 100
111+
print(f"\n📈 Mutation Score: {score:.1f}%")
112+
113+
if sample_mode and len(all_mutants) > len(mutants):
114+
estimated_total_score = score # Rough estimate
115+
print(f"📊 Estimated overall score: ~{estimated_total_score:.1f}% (based on sample)")
116+
117+
# Show surviving mutants if any
118+
if survived_mutants and len(survived_mutants) <= 5:
119+
print("\n⚠️ Surviving mutants (need better tests):")
120+
for mutant in survived_mutants[:5]:
121+
print(f" - {mutant}")
122+
print(f" View with: mutmut show {mutant}")
123+
124+
return 0
125+
126+
if __name__ == "__main__":
127+
sys.exit(main())

0 commit comments

Comments
 (0)