Auto-Generate Pytest Mocks for Legacy Python in 15 Minutes

Stop writing boilerplate mocks manually. Generate pytest fixtures from existing code using AST parsing and save hours on legacy Python testing.

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 than ast)
  • pytest-mock: Provides the mocker fixture 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)
  • libcst preserves formatting better than stdlib ast
  • 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