Skip to content

Commit ecf740b

Browse files
author
Nils Bars
committed
Fix coverage SQLite race condition with pytest-xdist
Remove conflicting coverage settings that caused SQLite race conditions when running tests in parallel with pytest-xdist (-n 10): - Remove `parallel = true` from pyproject.toml (pytest-cov manages this) - Remove `data_file` setting that forced all workers to same database - Add stale coverage file cleanup in pytest_sessionstart - Collect coverage from both tests/ and coverage_reports/ directories - Add path mapping to combine container coverage (/app/ref/) with host The path mapping uses relative paths (../../webapp/ref) resolved from tests/coverage_reports/ where coverage combine runs, making it portable across different machines and CI environments. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 53874ea commit ecf740b

2 files changed

Lines changed: 59 additions & 17 deletions

File tree

tests/conftest.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -392,27 +392,51 @@ def combine_all_coverage() -> None:
392392
"""Combine all coverage files (unit tests + container coverage) and generate reports.
393393
394394
This is called at the end of the test session to merge:
395-
- pytest-cov coverage from unit tests (host)
396-
- Container coverage from e2e tests (Docker)
395+
- pytest-cov coverage from tests/.coverage.* (host pytest workers)
396+
- Container coverage from coverage_reports/.coverage.* (Docker containers)
397397
"""
398-
if not COVERAGE_OUTPUT_DIR.exists():
399-
return
398+
tests_dir = Path(__file__).parent
399+
400+
# Collect coverage files from both locations:
401+
# 1. tests/.coverage.* - pytest-cov worker files (host unit/e2e tests)
402+
# 2. coverage_reports/.coverage.* - container coverage files (e2e Docker)
403+
coverage_files: list[Path] = list(tests_dir.glob(".coverage*"))
404+
if COVERAGE_OUTPUT_DIR.exists():
405+
coverage_files.extend(COVERAGE_OUTPUT_DIR.glob(".coverage*"))
400406

401-
coverage_files = list(COVERAGE_OUTPUT_DIR.glob(".coverage*"))
402407
if not coverage_files:
403408
print("[Coverage] No coverage data found to combine")
404409
return
405410

406-
print(f"[Coverage] Found {len(coverage_files)} coverage files to combine")
411+
print(f"[Coverage] Found {len(coverage_files)} coverage files to combine:")
412+
for cf in coverage_files:
413+
print(f" - {cf}")
414+
415+
# Copy all files to coverage_reports for combination
416+
COVERAGE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
417+
for cov_file in coverage_files:
418+
if cov_file.parent != COVERAGE_OUTPUT_DIR:
419+
dest = COVERAGE_OUTPUT_DIR / cov_file.name
420+
try:
421+
shutil.copy(cov_file, dest)
422+
print(
423+
f"[Coverage] Copied {cov_file.name} to {COVERAGE_OUTPUT_DIR.name}/"
424+
)
425+
except Exception as e:
426+
print(f"[Coverage] Warning: Failed to copy {cov_file.name}: {e}")
427+
428+
# Use pyproject.toml from tests/ directory for coverage config
429+
# This contains the path mapping for container -> host paths
430+
rcfile = str(tests_dir / "pyproject.toml")
407431

408432
orig_dir = os.getcwd()
409433
try:
410434
os.chdir(COVERAGE_OUTPUT_DIR)
411435

412-
# Combine all coverage files
436+
# Combine all coverage files with explicit config
413437
try:
414438
result = subprocess.run(
415-
["coverage", "combine", "--keep"],
439+
["coverage", "combine", "--keep", f"--rcfile={rcfile}"],
416440
check=False,
417441
capture_output=True,
418442
text=True,
@@ -423,7 +447,7 @@ def combine_all_coverage() -> None:
423447
if result.returncode != 0:
424448
# Try without --keep for older coverage versions
425449
result = subprocess.run(
426-
["coverage", "combine"],
450+
["coverage", "combine", f"--rcfile={rcfile}"],
427451
check=False,
428452
capture_output=True,
429453
text=True,
@@ -434,21 +458,21 @@ def combine_all_coverage() -> None:
434458

435459
# Generate HTML report
436460
subprocess.run(
437-
["coverage", "html", "-d", "htmlcov"],
461+
["coverage", "html", "-d", "htmlcov", f"--rcfile={rcfile}"],
438462
check=False,
439463
capture_output=True,
440464
)
441465

442466
# Generate XML report (Cobertura format)
443467
subprocess.run(
444-
["coverage", "xml", "-o", "coverage.xml"],
468+
["coverage", "xml", "-o", "coverage.xml", f"--rcfile={rcfile}"],
445469
check=False,
446470
capture_output=True,
447471
)
448472

449473
# Print summary report
450474
result = subprocess.run(
451-
["coverage", "report"],
475+
["coverage", "report", f"--rcfile={rcfile}"],
452476
check=False,
453477
capture_output=True,
454478
text=True,
@@ -828,6 +852,16 @@ def pytest_sessionstart(session: Session) -> None:
828852
# Also clean any legacy resources without timestamps
829853
cleanup_docker_resources_by_prefix("ref-ressource-")
830854

855+
# Clean up stale coverage files to prevent SQLite race conditions
856+
# pytest-cov will write to tests/.coverage.* with unique suffixes per worker
857+
tests_dir = Path(__file__).parent
858+
for coverage_file in tests_dir.glob(".coverage*"):
859+
try:
860+
coverage_file.unlink()
861+
print(f"[REF E2E] Removed stale coverage file: {coverage_file.name}")
862+
except Exception as e:
863+
print(f"[REF E2E] Warning: Failed to remove {coverage_file.name}: {e}")
864+
831865
# Prune unused Docker networks to avoid IP pool exhaustion
832866
print("[REF E2E] Pruning unused Docker networks...")
833867
try:
@@ -841,6 +875,14 @@ def pytest_sessionstart(session: Session) -> None:
841875

842876
COVERAGE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
843877

878+
# Also clean container coverage files from previous runs
879+
for coverage_file in COVERAGE_OUTPUT_DIR.glob(".coverage*"):
880+
try:
881+
coverage_file.unlink()
882+
print(f"[REF E2E] Removed stale container coverage: {coverage_file.name}")
883+
except Exception as e:
884+
print(f"[REF E2E] Warning: Failed to remove {coverage_file.name}: {e}")
885+
844886

845887
def pytest_sessionfinish(session: Session, exitstatus: int) -> None:
846888
"""

tests/pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ reportUnknownLambdaType = false
4242

4343
[tool.coverage.run]
4444
branch = true
45-
parallel = true
4645
source = ["helpers", "../webapp/ref"]
4746
omit = [
4847
"*/tests/*",
@@ -52,13 +51,14 @@ omit = [
5251
"conftest.py",
5352
"test_*.py",
5453
]
55-
data_file = "coverage_reports/.coverage"
5654

5755
[tool.coverage.paths]
58-
# Map paths for combining coverage from different sources
56+
# Map container paths to host paths for combined coverage reporting.
57+
# First path must exist on the reporting machine; others are patterns to remap.
58+
# Paths are relative to tests/coverage_reports/ where coverage combine runs.
5959
source = [
60-
"helpers/",
61-
"../webapp/ref/",
60+
"../../webapp/ref",
61+
"/app/ref",
6262
]
6363

6464
[tool.coverage.report]

0 commit comments

Comments
 (0)