strat-gameplay-webapp/backend/tests/CLAUDE.md
Cal Corum 9c90893b5d CLAUDE: Update documentation across codebase
Updated CLAUDE.md files with:
- Current test counts and status
- Session injection pattern documentation
- New module references and architecture notes
- Updated Phase status (3E-Final complete)
- Enhanced troubleshooting guides

Files updated:
- Root CLAUDE.md: Project overview and phase status
- backend/CLAUDE.md: Backend overview with test counts
- backend/README.md: Quick start and development guide
- backend/app/api/CLAUDE.md: API routes documentation
- backend/app/database/CLAUDE.md: Session injection docs
- backend/app/utils/CLAUDE.md: Utilities documentation
- backend/tests/CLAUDE.md: Testing patterns and policy
- frontend-sba/CLAUDE.md: Frontend overview
- frontend-sba/store/CLAUDE.md: Store patterns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 12:10:10 -06:00

16 KiB

Backend Tests - Developer Guide

Overview

Comprehensive test suite for the Paper Dynasty backend game engine covering unit tests, integration tests, and end-to-end scenarios.

Test Structure:

tests/
├── unit/                       # Fast, isolated unit tests (no DB)
│   ├── config/                 # League configs, PlayOutcome enum
│   ├── core/                   # Game engine, dice, state manager, validators
│   ├── models/                 # Pydantic models (game, player, roster)
│   └── terminal_client/        # Terminal client modules
├── integration/                # Database-dependent tests
│   ├── database/               # DatabaseOperations tests
│   ├── test_game_engine.py     # Full game engine with DB
│   └── test_state_persistence.py  # State recovery tests
└── e2e/                        # End-to-end tests (future)

Testing Policy

🚨 REQUIRED: 100% unit tests passing before committing to any feature branch.

Commit Policy

This project enforces a strict testing policy to maintain code quality and prevent regressions.

Before Every Commit:

  • MUST: Run uv run pytest tests/unit/ -q
  • MUST: All 609 unit tests passing (100%)
  • MUST: Fix any failing tests before committing
  • ⚠️ OPTIONAL: Use --no-verify for [WIP] commits (feature branches only)

Before Merging to Main:

  • MUST: 100% unit tests passing
  • MUST: Code review approval
  • MUST: CI/CD green build
  • NEVER: Merge with failing tests

Automated Enforcement:

A git pre-commit hook is available to automatically run tests before each commit.

# Install the hook (one-time setup)
cd /mnt/NV2/Development/strat-gameplay-webapp/backend
cp .git-hooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

See backend/CLAUDE.md → "Testing Policy" section for full details.

Current Test Baseline

Must maintain or improve:

  • Unit tests: 979/979 passing (100%)
  • Integration tests: 32/32 passing (100%)
  • ⏱️ Unit execution: ~4 seconds
  • ⏱️ Integration execution: ~5 seconds
  • 📊 Coverage: High coverage of core systems

Running Tests

Fast, reliable, no database required:

# All unit tests
uv run pytest tests/unit/ -v

# Specific module
uv run pytest tests/unit/core/test_game_engine.py -v

# Specific test
uv run pytest tests/unit/core/test_game_engine.py::TestGameEngine::test_start_game -v

# With coverage
uv run pytest tests/unit/ --cov=app --cov-report=html

Unit tests should always pass. If they don't, it's a real code issue.

Integration Tests (Database Required)

Integration tests now work reliably using session injection pattern.

# Run all integration tests
uv run pytest tests/integration/database/test_operations.py -v

# Run specific test class
uv run pytest tests/integration/database/test_operations.py::TestDatabaseOperationsGame -v

# Run with markers
uv run pytest tests/ -v -m integration

Session Injection Pattern (IMPORTANT)

Integration tests use session injection to avoid asyncpg connection conflicts:

# tests/integration/conftest.py
@pytest_asyncio.fixture(scope="function")
async def db_session():
    """Provide isolated database session with automatic rollback"""
    async with TestAsyncSessionLocal() as session:
        yield session
        await session.rollback()  # Cleanup after test

@pytest_asyncio.fixture(scope="function")
async def db_ops(db_session: AsyncSession):
    """DatabaseOperations with injected session"""
    return DatabaseOperations(db_session)  # All operations share this session

Key Points:

  • Each test gets its own session (no connection conflicts)
  • Session is rolled back after test (automatic cleanup)
  • Tests use NullPool to prevent connection reuse issues
  • All db_ops methods use the same session (no "operation in progress" errors)

See app/database/CLAUDE.md for full session injection documentation.

All Tests

# Run everything
uv run pytest tests/ -v

# Run with markers
uv run pytest tests/ -v -m "not integration"  # Skip integration tests
uv run pytest tests/ -v -m integration        # Only integration tests

Test Configuration

pytest.ini

[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
markers =
    integration: marks tests that require database (deselect with '-m "not integration"')

Key Settings:

  • asyncio_mode = auto: Automatically detect async tests
  • asyncio_default_fixture_loop_scope = function: Each test gets own event loop

Fixture Scopes

Critical for async tests:

# ✅ CORRECT - Matching scopes
@pytest.fixture(scope="function")
async def event_loop():
    ...

@pytest.fixture(scope="function")
async def db_session(event_loop):
    ...

# ❌ WRONG - Mismatched scopes cause errors
@pytest.fixture(scope="module")  # Module scope
async def setup_database(event_loop):  # But depends on function-scoped event_loop
    ...

Rule: Async fixtures should typically use scope="function" to avoid event loop conflicts.

Common Test Patterns

Unit Test Pattern

import pytest
from app.core.game_engine import GameEngine
from app.models.game_models import GameState

class TestGameEngine:
    """Unit tests for game engine - no database required"""

    def test_something(self):
        """Test description"""
        # Arrange
        engine = GameEngine()
        state = GameState(game_id=uuid4(), league_id="sba", ...)

        # Act
        result = engine.some_method(state)

        # Assert
        assert result.success is True

Integration Test Pattern (Session Injection)

import pytest
from uuid import uuid4

# Mark all tests in module as integration tests
pytestmark = pytest.mark.integration

class TestDatabaseOperations:
    """Integration tests - use fixtures from tests/integration/conftest.py"""

    async def test_create_game(self, db_ops, db_session):
        """
        Test creating a game in database.

        Uses db_ops fixture (DatabaseOperations with injected session)
        and db_session for flush/visibility within transaction.
        """
        # Arrange
        game_id = uuid4()

        # Act
        game = await db_ops.create_game(
            game_id=game_id,
            league_id="sba",
            home_team_id=1,
            away_team_id=2,
            game_mode="friendly",
            visibility="public"
        )
        await db_session.flush()  # Make visible within session

        # Assert
        retrieved = await db_ops.get_game(game_id)
        assert retrieved is not None
        assert retrieved.id == game_id
        # Session automatically rolled back after test

Key Pattern Notes:

  • Fixtures (db_ops, db_session) come from tests/integration/conftest.py
  • Use await db_session.flush() to persist changes within test (not commit())
  • Session is automatically rolled back after each test (isolation)
  • All operations share same session (no connection conflicts)

Async Test Pattern

import pytest

# pytest-asyncio automatically detects async tests
async def test_async_operation():
    result = await some_async_function()
    assert result is not None

# Or explicit marker (not required with asyncio_mode=auto)
@pytest.mark.asyncio
async def test_another_async_operation():
    ...

Database Testing

Test Database Setup

Required Environment Variables (.env):

DATABASE_URL=postgresql+asyncpg://paperdynasty:PASSWORD@10.10.0.42:5432/paperdynasty_dev

Database Connection:

  • Integration tests use the same database as development
  • Tests should clean up after themselves (fixtures handle this)
  • Each test should create unique game IDs to avoid conflicts

Transaction Rollback Pattern (tests/integration/conftest.py)

from sqlalchemy import NullPool
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

# Use NullPool to prevent connection reuse issues in tests
test_engine = create_async_engine(DATABASE_URL, poolclass=NullPool)
TestAsyncSessionLocal = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)

@pytest_asyncio.fixture(scope="function")
async def db_session():
    """Provide isolated database session with automatic rollback"""
    async with TestAsyncSessionLocal() as session:
        yield session
        await session.rollback()  # Cleanup - discard all changes

@pytest_asyncio.fixture(scope="function")
async def db_ops(db_session: AsyncSession):
    """DatabaseOperations with injected session for test isolation"""
    return DatabaseOperations(db_session)

Why This Works:

  • NullPool: Prevents asyncpg connection reuse issues
  • scope="function": Each test gets fresh session
  • rollback(): Automatically discards all test data
  • Session injection: All db_ops methods use same session (no conflicts)

Known Test Issues

All major test infrastructure issues have been resolved. The test suite is now stable.

Historical Context (Resolved)

AsyncPG Connection Conflicts - RESOLVED (2025-11-27)

  • Was: "cannot perform operation: another operation is in progress" errors
  • Fix: Session injection pattern in DatabaseOperations + NullPool in test fixtures
  • Result: 32/32 integration tests now passing

Test Coverage

Current Status (as of 2025-11-27):

  • 979 unit tests passing (100%)
  • 32 integration tests passing (100%)
  • Total: 1,011 tests passing

Coverage by Module:

app/config/                 ✅ Well covered
app/core/game_engine.py     ✅ Well covered (unit + integration)
app/core/state_manager.py   ✅ Well covered
app/core/dice.py            ✅ Well covered
app/models/                 ✅ Well covered
app/database/operations.py  ✅ 32 integration tests (session injection pattern)
app/websocket/handlers.py   ✅ 148 WebSocket handler tests
app/middleware/             ✅ Rate limiting, exceptions tested

Testing Best Practices

DO

  • Write unit tests first (fast, reliable, no DB)
  • Use descriptive test names: test_game_ends_after_27_outs
  • Follow Arrange-Act-Assert pattern
  • Use fixtures for common setup
  • Test edge cases and error conditions
  • Mock external dependencies (API calls, time, random)
  • Keep tests independent (no shared state)
  • Run unit tests frequently during development

DON'T

  • Don't share state between tests
  • Don't test implementation details (test behavior)
  • Don't use real API calls in tests (use mocks)
  • Don't depend on test execution order
  • Don't use commit() in integration tests (use flush() - session auto-rollbacks)
  • Don't create DatabaseOperations() without session injection in integration tests

Mocking Examples

from unittest.mock import Mock, patch, AsyncMock

# Mock database operations
@patch('app.core.game_engine.DatabaseOperations')
def test_with_mock_db(mock_db_class):
    mock_db = Mock()
    mock_db_class.return_value = mock_db
    mock_db.create_game = AsyncMock(return_value=None)

    # Test code that uses DatabaseOperations
    ...

# Mock dice rolls for deterministic tests
@patch('app.core.dice.DiceSystem.roll_d20')
def test_with_fixed_roll(mock_roll):
    mock_roll.return_value = 15
    # Test code expecting roll of 15
    ...

# Mock Pendulum time
with time_machine.travel("2025-10-31 12:00:00", tick=False):
    # Test time-dependent code
    ...

Debugging Failed Tests

Verbose Output

# Show full output including print statements
uv run pytest tests/unit/core/test_game_engine.py -v -s

# Show local variables on failure
uv run pytest tests/unit/core/test_game_engine.py -v -l

# Stop on first failure
uv run pytest tests/unit/core/test_game_engine.py -v -x

# Show full traceback
uv run pytest tests/unit/core/test_game_engine.py -v --tb=long

Interactive Debugging

# Drop into debugger on failure
uv run pytest tests/unit/core/test_game_engine.py --pdb

# Drop into debugger on first failure
uv run pytest tests/unit/core/test_game_engine.py --pdb -x

Logging in Tests

Tests capture logs by default. View with -o log_cli=true:

uv run pytest tests/unit/core/test_game_engine.py -v -o log_cli=true -o log_cli_level=DEBUG

CI/CD Considerations

Recommended CI Test Strategy:

# Run fast unit tests on every commit
- name: Unit Tests
  run: uv run pytest tests/unit/ -v --cov=app

# Run integration tests (session injection pattern makes these reliable)
- name: Integration Tests
  run: uv run pytest tests/integration/database/test_operations.py -v

All tests can now run together thanks to session injection pattern.

Troubleshooting

"cannot perform operation: another operation is in progress"

This issue is RESOLVED. If you see it, you're likely:

  • Creating DatabaseOperations() without session injection in tests
  • Not using the db_ops fixture from tests/integration/conftest.py

Solution: Use session injection pattern:

async def test_something(self, db_ops, db_session):  # Use fixtures!
    await db_ops.create_game(...)  # Uses injected session

"Task got Future attached to a different loop"

Solution: Ensure all fixtures use scope="function" (already configured in conftest.py)

"No module named 'app'"

Solution:

# Recommended: Use UV (handles PYTHONPATH automatically)
cd /mnt/NV2/Development/strat-gameplay-webapp/backend
uv run pytest tests/unit/ -v

# Alternative: Set PYTHONPATH manually
export PYTHONPATH=/mnt/NV2/Development/strat-gameplay-webapp/backend
pytest tests/unit/ -v

Tests hang indefinitely

Likely Cause: Async test without proper event loop cleanup

Solution: Check fixture scopes and ensure asyncio_mode = auto in pytest.ini

Database connection errors

Check:

  1. PostgreSQL is running: psql $DATABASE_URL
  2. .env has correct DATABASE_URL
  3. Database exists and schema is migrated: alembic upgrade head

Integration Test Architecture (Completed 2025-11-27)

The integration test infrastructure has been fully refactored using session injection:

Key Components

  1. tests/integration/conftest.py:

    • NullPool engine (prevents connection reuse issues)
    • db_session fixture (function-scoped, auto-rollback)
    • db_ops fixture (DatabaseOperations with injected session)
  2. app/database/operations.py:

    • Constructor accepts optional AsyncSession
    • _get_session() context manager handles both modes
    • Methods use flush() not commit()
  3. app/core/game_engine.py:

    • Creates DatabaseOperations(session) for transaction groups
    • Ensures atomic save_play + update_game_state operations

Pattern Summary

Production:  db_ops = DatabaseOperations()        → Auto-commit per operation
Testing:     db_ops = DatabaseOperations(session) → Shared session, caller controls
Transactions: db_ops = DatabaseOperations(session) → Multiple ops, single commit

Additional Resources


Summary: All 1,011 tests passing (979 unit + 32 integration). Session injection pattern ensures reliable, isolated integration tests with automatic cleanup.