โ† Caddy & HTTPS 16 / 18 Next: Architecture โ†’

Testing

Module 6 ยท Quality ยท โฑ ~30 min
Why this matters: 156 tests run in ~34 seconds and verify every critical path โ€” booking creation, conflict detection, payment expiry, admin auth, multi-shop isolation. The test suite is the safety net that lets you change code with confidence.

The Testing Pyramid

          /\
         /  \   System tests (Playwright)
        /    \  โ†’ runs against live jongyang.xyz
       /------\  slow, high-value, few
      /        \
     /  Integr. \  Integration tests (pytest + httpx)
    /    Tests   \  โ†’ in-memory SQLite, real HTTP through the app
   /              \  medium speed, many
  /----------------\
 /                  \  Unit tests (pytest)
/   Unit Tests       \  โ†’ pure functions, no DB, no network
/--------------------  \  fast (0.01s), most tests are here

Unit Tests: Pure Slot Logic

The slot availability algorithm (compute_slots) is a pure function โ€” no database, no network. It's tested exhaustively in test_availability.py:

python โ€” tests/test_availability.py (examples)
from app.services.slots import compute_slots

def test_normal_day_returns_slots():
    # open 10:00โ€“12:00, 60-min slots, 60-min increment โ†’ [10:00, 11:00]
    slots = compute_slots(time(10, 0), time(12, 0), [], 60, 60)
    assert slots == [time(10, 0), time(11, 0)]

def test_boundary_slots():
    # courts: 07:00โ€“22:00, 60-min slots โ†’ 15 slots (07:00 โ€ฆ 21:00)
    slots = compute_slots(time(7, 0), time(22, 0), [], 60, 60)
    assert len(slots) == 15
    assert slots[-1] == time(21, 0)  # last slot starts at 21:00 (ends at 22:00)

def test_overlapping_booking_blocks_adjacent_slots():
    # booking 10:30โ€“11:30 with 30-min increment: blocks 10:00, 10:30, 11:00
    blocked = [(630, 690)]  # 10:30=630min, 11:30=690min
    slots = compute_slots(time(10, 0), time(12, 0), blocked, 60, 30)
    assert slots == []  # all slots overlap

These tests run in milliseconds and never touch a database โ€” making them the fastest feedback mechanism.

The Fixture Chain

Fixtures are reusable setup/teardown functions. They're chained in conftest.py:

session
โ””โ”€โ”€ creates in-memory SQLite DB, runs CREATE TABLE, yields session
    โ””โ”€โ”€ client
        โ””โ”€โ”€ overrides FastAPI's get_db โ†’ uses our test session
            yields httpx.AsyncClient pointing at the real app
            โ””โ”€โ”€ shop_courts
                โ””โ”€โ”€ inserts Shop("BKK Sports Club") to DB
                    โ””โ”€โ”€ court_a
                        โ””โ”€โ”€ inserts Resource("Court A") to DB
                            โ””โ”€โ”€ schedule_court
                                โ””โ”€โ”€ inserts BaseSchedule(open=07:00, close=22:00)
                                    โ””โ”€โ”€ YOUR TEST uses all these
python โ€” conftest.py (key fixtures)
@pytest_asyncio.fixture
async def session():
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",  # in-memory, no disk, reset per test
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)  # creates all tables
    factory = async_sessionmaker(engine, expire_on_commit=False)
    async with factory() as sess:
        yield sess
    # DB is automatically destroyed when the fixture ends

@pytest_asyncio.fixture
async def client(session: AsyncSession):
    async def _override():
        yield session  # give FastAPI THIS session instead of the real DB
    app.dependency_overrides[get_db] = _override
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
        yield ac
    app.dependency_overrides.clear()

Why In-Memory SQLite (Not Mock, Not Real PostgreSQL)

The lesson learned the hard way: At a previous project, mocked database tests passed while a real production migration failed โ€” the mock didn't validate the actual SQL. We learned: test with real SQL, even if on a different database engine.

A Real Integration Test

python โ€” tests/test_api_bookings.py
async def test_double_booking_returns_409(client, court_a, schedule_court):
    # court_a: an asset resource
    # schedule_court: open tomorrow 07:00โ€“22:00
    payload = {
        "resource_id": str(court_a.id),
        "booking_date": TOMORROW,
        "start_time": "10:00:00",
        "duration_min": 60,
        "customer_name": "John Doe",
        "customer_phone": "081-234-5678",
    }
    r1 = await client.post("/api/bookings", json=payload)
    assert r1.status_code == 201  # first booking succeeds

    r2 = await client.post("/api/bookings", json=payload)
    assert r2.status_code == 409  # second booking at same time fails

# This test exercises: HTTP โ†’ FastAPI router โ†’ DB read โ†’ DB write โ†’ HTTP response
# In one coherent test, covering the full stack

The Expiry Task Integration Test

python โ€” tests/test_integration.py
async def test_expired_booking_frees_slot(session, client, court_a, schedule_court):
    # Insert an already-expired booking directly (bypassing the API)
    expired = Booking(
        ...,
        status="pending_payment",
        expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),  # 1 second ago
    )
    session.add(expired)
    await session.commit()

    # The slot should be blocked right now
    avail_blocked = (await client.get(
        f"/api/availability/{court_a.id}?date={TOMORROW}&duration_min=60"
    )).json()["slots"]
    assert "10:00:00" not in avail_blocked  # slot is held

    # Run the expiry task (normally runs every 5 minutes)
    await run_expire_task(session)
    await session.refresh(expired)
    assert expired.status == "cancelled"

    # Slot must now be available again
    avail_freed = (await client.get(
        f"/api/availability/{court_a.id}?date={TOMORROW}&duration_min=60"
    )).json()["slots"]
    assert "10:00:00" in avail_freed  # slot is free again!

Running the Tests

bash โ€” running tests
cd backend

# Create venv (Linux server โ€” do NOT use the .venv312 which was built on macOS)
python3.12 -m venv .venv_linux
.venv_linux/bin/pip install -r requirements.txt

# Run all tests
.venv_linux/bin/python -m pytest tests/ -v

# Run one test file
.venv_linux/bin/python -m pytest tests/test_api_bookings.py -v

# Run one specific test
.venv_linux/bin/python -m pytest tests/test_api_bookings.py::test_double_booking_returns_409 -v

# Expected output: 156 passed in ~34s

Playwright System Tests

System tests hit the real, live production site using a headless browser:

javascript โ€” tests/system/smoke.js (excerpt)
const { chromium } = require('playwright');

const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage();

// Test 1: admin login
await page.goto('https://admin.jongyang.xyz/admin/login');
await page.locator('input[type=email]').fill('bkk@demo.com');
await page.locator('input[type=password]').fill('Demo1234!');
await page.locator('button[type=submit]').click();
await page.waitForLoadState('load');
assert(page.url().includes('/admin/dashboard'), 'Should redirect to dashboard');

// Run with:
// NODE_PATH=/tmp/node_modules node tests/system/smoke.js

๐Ÿง  Self-Check Quiz

1. Why is compute_slots() isolated as a pure function and tested separately from the database?

Because it's the core booking engine โ€” if it's wrong, all bookings are wrong. A pure function (no I/O) can be tested exhaustively without any infrastructure. You can test every edge case (boundary slots, partial blocks, overlapping bookings) in milliseconds, with precise control over inputs. If it were entangled with the database, testing would require DB setup and be much slower.

2. What does app.dependency_overrides[get_db] = _override do in the test fixtures?

It replaces FastAPI's real database dependency with a test version. Every route that declares Depends(get_db) normally gets a production PostgreSQL session. In tests, this override makes them get the in-memory SQLite test session instead โ€” so tests run against isolated, throwaway data without touching the production database.

3. Why don't we mock the database for integration tests?

Mocks lie โ€” they return whatever you tell them, bypassing real SQL execution. A mocked test can pass while the actual SQL fails in production (wrong column name, constraint violation, type mismatch). We learned this the hard way at a previous project. Using in-memory SQLite runs real SQL while still being fast and isolated.

4. The test_expired_booking_frees_slot test inserts a booking directly via the session instead of via the API. Why?

The booking's expires_at must already be in the past โ€” but the API always sets it to 15 minutes in the future. To test the expiry path, we bypass the API and insert directly into the database with a pre-expired timestamp. This is a valid testing technique: use the API for happy-path creation, but reach behind the API to set up unusual states that the API wouldn't normally create.

5. You're on the Linux server (not macOS). Why shouldn't you use the .venv312 directory that was already there?

.venv312 was created on a macOS machine. Python virtualenvs contain absolute paths to the Python interpreter (e.g., /opt/homebrew/...) and compiled binary extensions built for macOS/arm64. These paths don't exist on Linux, and the binaries are incompatible. You must create a fresh venv using the Linux Python installation: python3.12 -m venv .venv_linux.