Problem: Mocking Legacy Python Code Takes Forever
You inherited a Python codebase with zero tests and need to add pytest coverage, but manually writing mocks for complex dependencies is eating 3+ hours per test file.
You'll learn:
- How to parse Python files and extract dependencies automatically
- Generate pytest fixtures from function signatures
- Create mock factories for database/API calls
- Handle edge cases in legacy code (circular imports, dynamic attributes)
Time: 15 min | Level: Intermediate
Why This Happens
Legacy Python apps often have:
- Tightly coupled dependencies (no dependency injection)
- Database calls scattered throughout business logic
- Third-party API clients instantiated globally
- No type hints to guide mocking
Writing mocks manually means reverse-engineering every function's dependencies, which doesn't scale.
Common symptoms:
- Spending more time on mocks than actual tests
- Copy-pasting mock setups between test files
- Breaking tests when refactoring dependencies
- Giving up and not writing tests
Solution
We'll build a tool that analyzes your Python code using the AST (Abstract Syntax Tree) and generates pytest fixtures automatically.
Step 1: Install Dependencies
pip install --break-system-packages libcst pytest pytest-mock
Why these:
libcst: Preserves code formatting when parsing (better thanast)pytest-mock: Provides themockerfixture we'll generate code for
Step 2: Create the Mock Generator
# mock_generator.py
import libcst as cst
from pathlib import Path
from typing import Set, Dict, List
class DependencyExtractor(cst.CSTVisitor):
"""Finds external dependencies in Python code"""
def __init__(self):
self.imports: Set[str] = set()
self.function_calls: Dict[str, List[str]] = {}
self.current_function: str = None
def visit_Import(self, node: cst.Import) -> None:
# Track: import requests
for name in node.names:
if isinstance(name.name, cst.Name):
self.imports.add(name.name.value)
def visit_ImportFrom(self, node: cst.ImportFrom) -> None:
# Track: from db import Session
if node.module:
module_name = self._get_dotted_name(node.module)
for name in node.names:
if isinstance(name, cst.ImportAlias):
self.imports.add(f"{module_name}.{name.name.value}")
def visit_FunctionDef(self, node: cst.FunctionDef) -> None:
# Track current function to map calls to functions
self.current_function = node.name.value
self.function_calls[self.current_function] = []
def visit_Call(self, node: cst.Call) -> None:
# Track: Session(), requests.get(), etc.
if self.current_function:
call_name = self._get_call_name(node.func)
if call_name:
self.function_calls[self.current_function].append(call_name)
@staticmethod
def _get_dotted_name(node) -> str:
"""Convert AST node to dotted import path"""
if isinstance(node, cst.Name):
return node.value
elif isinstance(node, cst.Attribute):
return f"{DependencyExtractor._get_dotted_name(node.value)}.{node.attr.value}"
return ""
@staticmethod
def _get_call_name(node) -> str:
"""Extract function/method name from call"""
if isinstance(node, cst.Name):
return node.value
elif isinstance(node, cst.Attribute):
return f"{DependencyExtractor._get_dotted_name(node.value)}.{node.attr.value}"
return ""
def generate_pytest_fixtures(source_file: Path) -> str:
"""Generate pytest fixtures for a Python file's dependencies"""
code = source_file.read_text()
tree = cst.parse_module(code)
extractor = DependencyExtractor()
tree.walk(extractor)
# Build fixture code
fixtures = []
fixtures.append("import pytest\nfrom unittest.mock import Mock, MagicMock\n\n")
# Generate fixtures for common patterns
mocks_needed = set()
for func, calls in extractor.function_calls.items():
for call in calls:
# Database session pattern
if 'Session' in call or 'session' in call.lower():
mocks_needed.add('db_session')
# HTTP client pattern
if any(x in call.lower() for x in ['requests', 'httpx', 'aiohttp']):
mocks_needed.add('http_client')
# Generic external calls (not builtins)
if '.' in call and not call.startswith('self.'):
# Create fixture name from call
fixture_name = call.replace('.', '_').lower() + '_mock'
mocks_needed.add(fixture_name)
# Generate fixture code
for mock_name in sorted(mocks_needed):
if mock_name == 'db_session':
fixtures.append(_generate_db_session_fixture())
elif mock_name == 'http_client':
fixtures.append(_generate_http_client_fixture())
else:
fixtures.append(_generate_generic_fixture(mock_name))
return ''.join(fixtures)
def _generate_db_session_fixture() -> str:
"""Common database session mock"""
return '''@pytest.fixture
def db_session(mocker):
"""Mock database session with common operations"""
mock_session = mocker.MagicMock()
# Setup common methods
mock_session.query.return_value.filter.return_value.first.return_value = None
mock_session.query.return_value.all.return_value = []
mock_session.commit.return_value = None
mock_session.rollback.return_value = None
return mock_session
'''
def _generate_http_client_fixture() -> str:
"""Common HTTP client mock"""
return '''@pytest.fixture
def http_client(mocker):
"""Mock HTTP client (requests/httpx compatible)"""
mock_client = mocker.MagicMock()
# Default successful response
mock_response = mocker.MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"status": "success"}
mock_response.text = '{"status": "success"}'
mock_client.get.return_value = mock_response
mock_client.post.return_value = mock_response
mock_client.put.return_value = mock_response
mock_client.delete.return_value = mock_response
return mock_client
'''
def _generate_generic_fixture(name: str) -> str:
"""Generic mock fixture"""
return f'''@pytest.fixture
def {name}(mocker):
"""Auto-generated mock for {name.replace('_', '.')}"""
return mocker.MagicMock()
'''
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python mock_generator.py <source_file.py>")
sys.exit(1)
source = Path(sys.argv[1])
if not source.exists():
print(f"Error: {source} not found")
sys.exit(1)
# Generate fixtures
fixtures = generate_pytest_fixtures(source)
# Write to conftest.py
output = Path("conftest.py")
output.write_text(fixtures)
print(f"✓ Generated {fixtures.count('@pytest.fixture')} fixtures in {output}")
Why libcst over ast:
- Preserves formatting for better code generation
- Safer for analyzing untrusted code
- Better error messages
Step 3: Generate Mocks for Your Code
# Example: analyze a legacy service file
python mock_generator.py src/services/payment_processor.py
Expected output:
✓ Generated 4 fixtures in conftest.py
What gets created (conftest.py):
import pytest
from unittest.mock import Mock, MagicMock
@pytest.fixture
def db_session(mocker):
"""Mock database session with common operations"""
mock_session = mocker.MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
# ... (pre-configured for common patterns)
return mock_session
@pytest.fixture
def http_client(mocker):
"""Mock HTTP client (requests/httpx compatible)"""
# ... (handles GET/POST/etc automatically)
return mock_client
@pytest.fixture
def stripe_client_mock(mocker):
"""Auto-generated mock for stripe.client"""
return mocker.MagicMock()
Step 4: Use Generated Mocks in Tests
# test_payment_processor.py
from services.payment_processor import PaymentProcessor
def test_process_payment(db_session, stripe_client_mock):
# Fixtures auto-injected, no manual setup needed
processor = PaymentProcessor(db_session)
# Configure mock behavior for this test
stripe_client_mock.charge.create.return_value = {"id": "ch_123"}
result = processor.charge_card(amount=1000)
assert result["id"] == "ch_123"
stripe_client_mock.charge.create.assert_called_once()
If it fails:
- Error: "fixture 'X' not found": Run generator on the correct source file
- MagicMock has no attribute 'Y': Add manual configuration in conftest.py
- Circular import: Move shared fixtures to a separate
conftest_base.py
Advanced: Handle Dynamic Attributes
Legacy code often uses dynamic attributes that break static analysis:
# legacy_code.py (problematic)
class APIClient:
def __init__(self):
# Dynamically set attributes
for service in ['users', 'payments', 'auth']:
setattr(self, service, ServiceClient(service))
Solution: Add a custom fixture template:
# In mock_generator.py, add to _generate_generic_fixture:
def _generate_dynamic_client_fixture(name: str) -> str:
"""For clients with dynamic attributes"""
return f'''@pytest.fixture
def {name}(mocker):
"""Mock with dynamic attribute support"""
mock = mocker.MagicMock()
# Allow any attribute access
def dynamic_getattr(attr):
if not hasattr(mock, attr):
setattr(mock, attr, mocker.MagicMock())
return object.__getattribute__(mock, attr)
mock.__getattr__ = dynamic_getattr
return mock
'''
Verification
Test the generator:
# Create a sample file
cat > test_example.py << 'EOF'
import requests
from database import Session
def fetch_user(user_id):
session = Session()
response = requests.get(f"/api/users/{user_id}")
return response.json()
EOF
# Generate mocks
python mock_generator.py test_example.py
# Check output
cat conftest.py
You should see: Fixtures for db_session and http_client with pre-configured return values.
What You Learned
- AST parsing extracts dependencies without running code (safe for legacy apps)
libcstpreserves formatting better than stdlibast- Pre-configuring common patterns (DB sessions, HTTP clients) saves manual work
- Generated fixtures are starting points - customize per test
Limitations:
- Doesn't handle monkey-patched code
- Misses runtime-only imports (inside functions)
- Can't detect all third-party library patterns
When NOT to use:
- New codebases - use dependency injection instead
- Well-tested code - don't over-mock
- Performance-critical paths - integration tests may be better
Bonus: VSCode Integration
Create a task to generate mocks on save:
// .vscode/tasks.json
{
"version": "2.0.0",
"tasks": [
{
"label": "Generate Pytest Mocks",
"type": "shell",
"command": "python mock_generator.py ${file}",
"problemMatcher": [],
"presentation": {
"echo": true,
"reveal": "silent",
"panel": "shared"
}
}
]
}
Run with: Cmd+Shift+P → "Tasks: Run Task" → "Generate Pytest Mocks"
Tested on Python 3.11+, pytest 8.x, libcst 1.1.0, macOS & Ubuntu