# 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. ```bash # 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 ### Unit Tests (Recommended) **Fast, reliable, no database required**: ```bash # 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. ```bash # 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: ```python # 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 ```bash # 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 ```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**: ```python # ✅ 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 ```python 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) ```python 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 ```python 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`): ```bash 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) ```python 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 ```python 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 ```bash # 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 ```bash # 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`: ```bash 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**: ```yaml # 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: ```python 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**: ```bash # 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 - **Database CLAUDE.md**: `app/database/CLAUDE.md` - Full session injection documentation - **pytest-asyncio docs**: https://pytest-asyncio.readthedocs.io/ - **AsyncPG docs**: https://magicstack.github.io/asyncpg/ - **SQLAlchemy async**: https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html --- **Summary**: All 1,011 tests passing (979 unit + 32 integration). Session injection pattern ensures reliable, isolated integration tests with automatic cleanup.