Frontend UX improvements: - Single-click Discord OAuth from home page (no intermediate /auth page) - Auto-redirect authenticated users from home to /games - Fixed Nuxt layout system - app.vue now wraps NuxtPage with NuxtLayout - Games page now has proper card container with shadow/border styling - Layout header includes working logout with API cookie clearing Games list enhancements: - Display team names (lname) instead of just team IDs - Show current score for each team - Show inning indicator (Top/Bot X) for active games - Responsive header with wrapped buttons on mobile Backend improvements: - Added team caching to SbaApiClient (1-hour TTL) - Enhanced GameListItem with team names, scores, inning data - Games endpoint now enriches response with SBA API team data Docker optimizations: - Optimized Dockerfile using --chown flag on COPY (faster than chown -R) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
12 KiB
Plan 009: Fix Integration Test Infrastructure
Priority: MEDIUM Effort: 2-3 days Status: NOT STARTED Risk Level: LOW - Testing infrastructure
Problem Statement
Integration tests have infrastructure issues with AsyncPG:
- Connection conflicts with concurrent tests
- Fixture scope mismatches (module vs function)
- ~49 errors, 28 failures in integration test suite
Current workaround: Run tests individually or serially.
Impact
- CI/CD: Can't run full test suite in pipeline
- Confidence: Integration behavior not verified automatically
- Speed: Serial execution is slow
Root Causes
1. AsyncPG Connection Conflicts
asyncpg.exceptions.InterfaceError: cannot perform operation:
another operation is in progress
AsyncPG doesn't support concurrent operations on a single connection.
2. Fixture Scope Mismatches
@pytest.fixture(scope="module") # Module scope
async def setup_database(event_loop): # Depends on function-scoped event_loop
Different scopes cause ScopeMismatch errors.
3. Shared Database State
Tests don't properly isolate database state, causing cascading failures.
Files to Modify
| File | Changes |
|---|---|
backend/tests/conftest.py |
Fix fixture scopes |
backend/tests/integration/conftest.py |
Add test isolation |
backend/pyproject.toml |
Update pytest-asyncio config |
Implementation Steps
Step 1: Update pytest-asyncio Configuration (30 min)
Update backend/pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" # Important!
This ensures each test function gets its own event loop.
Step 2: Create Test Database Utilities (1 hour)
Create backend/tests/utils/db_utils.py:
"""Database utilities for integration tests."""
import asyncio
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import NullPool
from app.models.db_models import Base
from app.config import settings
def get_test_database_url() -> str:
"""Get test database URL (separate from dev/prod)."""
# Use a different database for tests
base_url = settings.database_url
if "strat_gameplay" in base_url:
return base_url.replace("strat_gameplay", "strat_gameplay_test")
return base_url + "_test"
def create_test_engine():
"""Create engine with NullPool (no connection pooling)."""
return create_async_engine(
get_test_database_url(),
poolclass=NullPool, # Each connection is created fresh
echo=False,
)
@asynccontextmanager
async def test_session():
"""Create isolated test session."""
engine = create_test_engine()
async with engine.begin() as conn:
# Create tables
await conn.run_sync(Base.metadata.create_all)
AsyncTestSession = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async with AsyncTestSession() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
# Clean up
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
async def reset_database(session: AsyncSession):
"""Reset database to clean state."""
# Delete all data in reverse dependency order
for table in reversed(Base.metadata.sorted_tables):
await session.execute(table.delete())
await session.commit()
Step 3: Update Integration Test Fixtures (2 hours)
Update backend/tests/integration/conftest.py:
"""Integration test fixtures."""
import pytest
import pytest_asyncio
from uuid import uuid4
from tests.utils.db_utils import create_test_engine, test_session, reset_database
from app.models.db_models import Base
from app.database.operations import DatabaseOperations
@pytest_asyncio.fixture(scope="function")
async def test_db():
"""
Create fresh database for each test.
Uses NullPool to avoid connection conflicts.
"""
engine = create_test_engine()
# Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
# Drop tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture(scope="function")
async def db_session(test_db):
"""
Provide isolated database session for each test.
Automatically rolls back after test.
"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
AsyncTestSession = async_sessionmaker(
test_db,
class_=AsyncSession,
expire_on_commit=False,
)
async with AsyncTestSession() as session:
yield session
# Rollback any uncommitted changes
await session.rollback()
@pytest_asyncio.fixture(scope="function")
async def db_ops(db_session, monkeypatch):
"""
Provide DatabaseOperations with test session.
"""
from app.database import session as db_module
# Monkeypatch to use test session
original_session = db_module.AsyncSessionLocal
# Create factory that returns our test session
async def get_test_session():
return db_session
monkeypatch.setattr(db_module, 'AsyncSessionLocal', get_test_session)
yield DatabaseOperations()
# Restore
monkeypatch.setattr(db_module, 'AsyncSessionLocal', original_session)
@pytest_asyncio.fixture(scope="function")
async def sample_game(db_ops):
"""Create sample game for testing."""
game_id = uuid4()
await db_ops.create_game({
"id": game_id,
"league_id": "sba",
"home_team_id": 1,
"away_team_id": 2,
"status": "in_progress"
})
return game_id
@pytest_asyncio.fixture(scope="function")
async def sample_lineups(db_ops, sample_game):
"""Create sample lineups for testing."""
lineups = []
for team_id in [1, 2]:
for i in range(9):
lineup = await db_ops.create_lineup({
"game_id": sample_game,
"team_id": team_id,
"card_id": 100 + (team_id * 10) + i,
"position": ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF"][i],
"batting_order": i + 1 if i > 0 else None,
"is_active": True
})
lineups.append(lineup)
return lineups
Step 4: Fix Specific Test Files (4 hours)
Update each integration test file to use function-scoped fixtures:
Example: tests/integration/database/test_operations.py
"""Integration tests for DatabaseOperations."""
import pytest
from uuid import uuid4
class TestDatabaseOperations:
"""Tests for database CRUD operations."""
@pytest.mark.asyncio
async def test_create_game(self, db_ops):
"""Test game creation."""
game_id = uuid4()
result = await db_ops.create_game({
"id": game_id,
"league_id": "sba",
"home_team_id": 1,
"away_team_id": 2,
})
assert result is not None
assert result["id"] == game_id
@pytest.mark.asyncio
async def test_get_game(self, db_ops, sample_game):
"""Test game retrieval."""
game = await db_ops.get_game(sample_game)
assert game is not None
assert game["id"] == sample_game
@pytest.mark.asyncio
async def test_create_play(self, db_ops, sample_game, sample_lineups):
"""Test play creation."""
play_id = await db_ops.save_play({
"game_id": sample_game,
"play_number": 1,
"inning": 1,
"half": "top",
"outs_before": 0,
"outs_after": 1,
"batter_id": sample_lineups[0].id,
"pitcher_id": sample_lineups[9].id,
"outcome": "STRIKEOUT",
})
assert play_id is not None
@pytest.mark.asyncio
async def test_get_plays(self, db_ops, sample_game, sample_lineups):
"""Test play retrieval."""
# Create a play first
await db_ops.save_play({
"game_id": sample_game,
"play_number": 1,
# ... play data
})
plays = await db_ops.get_plays(sample_game)
assert len(plays) == 1
Step 5: Add Test Isolation Markers (1 hour)
Update backend/pyproject.toml:
[tool.pytest.ini_options]
markers = [
"integration: marks tests as integration tests (require database)",
"slow: marks tests as slow (>1s)",
"serial: marks tests that must run serially",
]
Update tests that need serial execution:
@pytest.mark.serial
@pytest.mark.integration
class TestGameStateRecovery:
"""Tests that must run serially due to shared state."""
pass
Step 6: Create pytest Plugin for Serial Tests (1 hour)
Create backend/tests/plugins/serial_runner.py:
"""Plugin for running serial tests in order."""
import pytest
def pytest_configure(config):
"""Register serial marker."""
config.addinivalue_line(
"markers", "serial: mark test to run serially"
)
@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(config, items):
"""Ensure serial tests run after parallel tests."""
serial_tests = []
parallel_tests = []
for item in items:
if item.get_closest_marker("serial"):
serial_tests.append(item)
else:
parallel_tests.append(item)
# Run parallel tests first, then serial
items[:] = parallel_tests + serial_tests
Step 7: Update CI Configuration (30 min)
Update .github/workflows/test.yml:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: strat_gameplay_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.13'
- name: Install dependencies
run: |
cd backend
pip install -e ".[dev]"
- name: Run unit tests
run: |
cd backend
pytest tests/unit/ -v --tb=short
- name: Run integration tests
env:
DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/strat_gameplay_test
run: |
cd backend
pytest tests/integration/ -v --tb=short -x
Step 8: Verify Test Suite (1 hour)
cd /mnt/NV2/Development/strat-gameplay-webapp/backend
# Run unit tests (should all pass)
pytest tests/unit/ -v
# Run integration tests (should now pass)
pytest tests/integration/ -v
# Run full suite
pytest tests/ -v --tb=short
# Check for any remaining failures
pytest tests/ -v --tb=long -x # Stop on first failure
Verification Checklist
- All unit tests pass (739/739)
- Integration tests pass without connection errors
- No fixture scope mismatch errors
- Tests can run in parallel with pytest-xdist
- CI pipeline passes
- Test execution time < 60 seconds
Expected Outcome
| Metric | Before | After |
|---|---|---|
| Unit tests | 739/739 ✅ | 739/739 ✅ |
| Integration tests | ~49 errors | 0 errors |
| Total time | N/A (can't run) | < 60s |
| CI status | ❌ Failing | ✅ Passing |
Rollback Plan
If issues persist:
- Keep serial execution as workaround
- Skip problematic tests with
@pytest.mark.skip - Run integration tests nightly instead of per-commit
Dependencies
- None (can be implemented independently)
Notes
- Consider using testcontainers for fully isolated DB
- May want to add pytest-xdist for parallel execution
- Future: Add database seeding scripts for complex scenarios