Testing
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:
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
@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)
- Not PostgreSQL โ requires Docker to be running; slow to start; state persists between tests (needs cleanup)
- Not mocks โ mocks lie. A mocked DB session can return anything. Real SQL finds real bugs.
- SQLite in-memory โ starts in milliseconds, always available, real SQL execution, destroyed after each test automatically. The SQL dialect is different from PostgreSQL, but our queries are simple enough that it doesn't matter.
A Real Integration Test
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
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
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:
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?
2. What does app.dependency_overrides[get_db] = _override do in the test fixtures?
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?
4. The test_expired_booking_frees_slot test inserts a booking directly via the session instead of via the API. Why?
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.