conftest.py•12.5 kB
"""
Global pytest configuration and fixtures for TDD test suite with intelligent status tracking.
This file provides:
1. Shared fixtures and configuration for all test levels
2. Automatic marking of tests based on implementation status
3. Clear distinction between intentional vs unintentional failures
4. Progress tracking across development phases
5. Enhanced reporting of test status
"""
import fnmatch
import json
import shutil
import tempfile
from collections.abc import Generator
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
# Test environment setup
@pytest.fixture(scope="session")
def test_environment():
"""Set up test environment variables and configuration."""
import os
original_env = os.environ.copy()
# Set test-specific environment variables
os.environ["LOG_LEVEL"] = "DEBUG"
# Test environment ready
# Remove ClaudeCode's modified PATH to avoid git/gh redirectors
if "PATH" in os.environ:
path_entries = os.environ["PATH"].split(os.pathsep)
# Filter out ClaudeCode's redirect paths
clean_path = [
p
for p in path_entries
if not any(
redirect in p
for redirect in [
"claude-code",
"ClaudeCode",
".claude",
"redirector",
"mcp",
]
)
]
os.environ["PATH"] = os.pathsep.join(clean_path)
yield
# Restore original environment
os.environ.clear()
os.environ.update(original_env)
@pytest.fixture
def temp_dir() -> Generator[Path, None, None]:
"""Create a temporary directory for test files."""
temp_path = Path(tempfile.mkdtemp())
try:
yield temp_path
finally:
shutil.rmtree(temp_path, ignore_errors=True)
def _run_git_isolated(cmd: list, cwd: Path, **kwargs):
"""Run git command with clean PATH environment (no ClaudeCode redirectors)."""
import os
import subprocess
env = os.environ.copy()
# Remove ClaudeCode's modified PATH to avoid git redirectors
if "PATH" in env:
path_entries = env["PATH"].split(os.pathsep)
clean_path = [
p
for p in path_entries
if not any(
redirect in p
for redirect in [
"claude-code",
"ClaudeCode",
".claude",
"redirector",
"mcp",
]
)
]
env["PATH"] = os.pathsep.join(clean_path)
return subprocess.run(cmd, cwd=cwd, env=env, **kwargs)
@pytest.fixture
def mock_git_repo(temp_dir: Path) -> Path:
"""Create a mock git repository for testing."""
repo_path = temp_dir / "test_repo"
repo_path.mkdir()
# Initialize git repo with isolated environment
_run_git_isolated(["git", "init"], cwd=repo_path, check=True, capture_output=True)
_run_git_isolated(
["git", "config", "user.name", "Test User"], cwd=repo_path, check=True
)
_run_git_isolated(
["git", "config", "user.email", "test@example.com"], cwd=repo_path, check=True
)
# Create initial commit
(repo_path / "README.md").write_text("# Test Repository")
_run_git_isolated(["git", "add", "README.md"], cwd=repo_path, check=True)
_run_git_isolated(
["git", "commit", "-m", "Initial commit"], cwd=repo_path, check=True
)
return repo_path
@pytest.fixture
def mock_github_responses() -> dict[str, Any]:
"""Mock GitHub API responses for testing."""
return {
"user": {
"login": "testuser",
"id": 12345,
"name": "Test User",
"email": "test@example.com",
},
"repo": {
"id": 67890,
"name": "test-repo",
"full_name": "testuser/test-repo",
"private": False,
"default_branch": "main",
},
"pulls": [
{
"number": 1,
"title": "Test Pull Request",
"state": "open",
"base": {"ref": "main"},
"head": {"ref": "feature-branch"},
}
],
}
@pytest.fixture
def mock_mcp_client():
"""Mock MCP client for protocol testing."""
client = MagicMock()
client.connect = MagicMock()
client.disconnect = MagicMock()
client.send_message = MagicMock()
client.receive_message = MagicMock()
return client
# Test Status Tracking System
def load_test_status() -> dict[str, Any]:
"""Load test status configuration from .taskmaster/test-status.json"""
status_file = Path(__file__).parent.parent / ".taskmaster" / "test-status.json"
if status_file.exists():
with open(status_file) as f:
return json.load(f)
return {"current_phase": "unknown", "test_phases": {}}
def should_test_fail(test_nodeid: str, test_status: dict[str, Any]) -> bool:
"""Check if a test is expected to fail in the current phase"""
current_phase = test_status.get("current_phase", "")
if not current_phase or current_phase not in test_status.get("test_phases", {}):
return False
phase_info = test_status["test_phases"][current_phase]
expected_failing = phase_info.get("expected_failing", [])
for pattern in expected_failing:
if fnmatch.fnmatch(test_nodeid, pattern):
return True
return False
def should_test_pass(test_nodeid: str, test_status: dict[str, Any]) -> bool:
"""Check if a test is expected to pass in the current phase"""
current_phase = test_status.get("current_phase", "")
if not current_phase or current_phase not in test_status.get("test_phases", {}):
return False
phase_info = test_status["test_phases"][current_phase]
expected_passing = phase_info.get("expected_passing", [])
for pattern in expected_passing:
if fnmatch.fnmatch(test_nodeid, pattern):
return True
return False
@pytest.fixture(scope="session")
def test_status():
"""Provide test status information to tests"""
return load_test_status()
@pytest.fixture(scope="session")
def current_phase(test_status):
"""Provide current development phase to tests"""
return test_status.get("current_phase", "unknown")
# Markers for test categorization
pytest_plugins: list[str] = []
def pytest_configure(config):
"""Configure pytest markers including test status tracking."""
# Original markers
config.addinivalue_line("markers", "unit: Unit tests for individual components")
config.addinivalue_line(
"markers", "integration: Integration tests between components"
)
config.addinivalue_line("markers", "system: End-to-end system tests")
config.addinivalue_line("markers", "slow: Tests that take more than 1 second")
config.addinivalue_line(
"markers", "requires_git: Tests that require git repository setup"
)
config.addinivalue_line(
"markers", "requires_github: Tests that require GitHub API access"
)
# Test status tracking markers
config.addinivalue_line(
"markers", "expected_fail: Test expected to fail (TDD red phase)"
)
config.addinivalue_line(
"markers", "implementation_pending: Test waiting for implementation"
)
config.addinivalue_line("markers", "phase_1: Test part of phase 1 (foundation)")
config.addinivalue_line("markers", "phase_2: Test part of phase 2 (implementation)")
config.addinivalue_line("markers", "phase_3: Test part of phase 3 (integration)")
config.addinivalue_line("markers", "critical: Test critical for current phase")
def pytest_collection_modifyitems(config, items):
"""Automatically mark tests based on their location and implementation status."""
test_status = load_test_status()
current_phase = test_status.get("current_phase", "")
for item in items:
test_nodeid = item.nodeid
# Auto-mark based on test file location (original functionality)
if "tests/unit/" in str(item.fspath):
item.add_marker(pytest.mark.unit)
elif "tests/integration/" in str(item.fspath):
item.add_marker(pytest.mark.integration)
elif "tests/system/" in str(item.fspath):
item.add_marker(pytest.mark.system)
# Mark tests that use git fixtures
if "mock_git_repo" in item.fixturenames:
item.add_marker(pytest.mark.requires_git)
# Mark tests that use GitHub fixtures
if "mock_github_responses" in item.fixturenames:
item.add_marker(pytest.mark.requires_github)
# Apply phase-specific markers based on current development phase
if "types/" in test_nodeid:
if current_phase == "phase_1_foundation":
item.add_marker(pytest.mark.phase_1)
elif current_phase == "phase_2_implementation":
item.add_marker(pytest.mark.phase_2)
elif current_phase == "phase_3_integration":
item.add_marker(pytest.mark.phase_3)
# Mark tests as expected to fail or pass based on test status
if should_test_fail(test_nodeid, test_status):
item.add_marker(pytest.mark.expected_fail)
item.add_marker(pytest.mark.implementation_pending)
item.add_marker(
pytest.mark.xfail(
reason=f"Expected to fail in {current_phase} - implementation pending",
strict=False,
run=True,
)
)
elif should_test_pass(test_nodeid, test_status):
item.add_marker(pytest.mark.critical)
def pytest_terminal_summary(terminalreporter, exitstatus, config):
"""Enhanced terminal summary with phase information and failure analysis."""
test_status = load_test_status()
current_phase = test_status.get("current_phase", "unknown")
# Add phase summary
terminalreporter.write_sep("=", "DEVELOPMENT PHASE SUMMARY")
terminalreporter.write_line(f"Current Phase: {current_phase}")
if current_phase in test_status.get("test_phases", {}):
phase_info = test_status["test_phases"][current_phase]
terminalreporter.write_line(
f"Description: {phase_info.get('description', 'N/A')}"
)
terminalreporter.write_line(f"Status: {phase_info.get('status', 'unknown')}")
# Count expected vs unexpected failures
failed_reports = terminalreporter.stats.get("failed", [])
expected_failures = 0
unexpected_failures = 0
unexpected_failure_tests = []
for report in failed_reports:
test_nodeid = report.nodeid
if should_test_fail(test_nodeid, test_status):
expected_failures += 1
else:
unexpected_failures += 1
unexpected_failure_tests.append(test_nodeid)
# Summary with color coding
terminalreporter.write_line("")
terminalreporter.write_line(
f"Expected Failures (TDD Red Phase): {expected_failures}", green=True
)
if unexpected_failures > 0:
terminalreporter.write_line(
f"Unexpected Failures: {unexpected_failures}", red=True
)
else:
terminalreporter.write_line(
f"Unexpected Failures: {unexpected_failures}", green=True
)
if unexpected_failures == 0:
terminalreporter.write_line(
"✅ ALL FAILURES ARE EXPECTED (TDD Red Phase)", green=True
)
terminalreporter.write_line(
" No action required - continue with implementation", green=True
)
else:
terminalreporter.write_line(
f"❌ {unexpected_failures} UNEXPECTED FAILURES DETECTED", red=True
)
terminalreporter.write_line(" These require immediate attention:", red=True)
for test_nodeid in unexpected_failure_tests:
terminalreporter.write_line(f" - {test_nodeid}", red=True)
terminalreporter.write_line("")
terminalreporter.write_line("🚨 NEXT ACTIONS:", yellow=True)
terminalreporter.write_line(
" 1. Fix unexpected failures before proceeding", yellow=True
)
terminalreporter.write_line(
" 2. Update .taskmaster/test-status.json if failures are intentional",
yellow=True,
)
terminalreporter.write_line(" 3. Commit fixes and re-run tests", yellow=True)