跳到主要內容

Web Day 40 專案:測試與驗收

Web Day 40 專案:測試與驗收

執行需求:CPU 可跑。專案做了四天、API 與後台都跑起來了,現在要回答一個最現實的問題:「這個系統能不能上線?」這個問題不是讀程式碼就能回答的,要寫自動化測試把每一條規格驗過去。今天要把「測試」與「驗收」兩件事一次做完:上半場用 pytest 8.4 + httpx 0.28 寫端對端測試,覆蓋預約流程的所有 API(含認證、衝突檢查、通知、後台動作);下半場整理一份「驗收清單」,列出上線前必須勾選的事項(健康檢查、migration、靜態檔案、Security header 等)。完成後,整個預約管理系統在 CI 跑測試只要約 60 秒,所有需求都會被自動驗證。這份測試套件同時是 Day 41 Docker Compose 上線前的最後一關。

引言

很多人對「測試」的印象停留在「寫一個 assert 看看回傳對不對」,但 production 等級的測試其實是一套策略:單元測試驗「一段程式碼」、整合測試驗「幾段程式碼一起跑」、端對端測試驗「真實 HTTP 呼叫走完整條 API」、效能測試驗「給它足夠壓力看它會不會倒」。每一層都不可少,因為它們驗的事情不同:單元測試能抓邏輯錯、整合測試能抓介面錯、端對端能抓部署錯、效能測試能抓容量錯。FastAPI + SQLModel 的測試通常落在「以 HTTP 為主、資料庫為輔」的整合端對端,這也是今天要做的。

貫穿專案的「預約管理系統」是個小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、諮詢工作室,所有資料都是虛構示範)。它有四種角色、四種資源、五條以上主要 API、兩條背景任務(Day 38 的背景通知與排程器)、一個後台(Day 39 的 HTMX)。我們今天的測試要把這些全部串起來跑一遍,並寫出一份可在 Day 32 的 CI 環境直接吃的 pytest 測試套件。具體要做到的有三件事:用 fixture 把每個 test 的前置資料標準化、把核心 API 跑成一組可重現的腳本、最後把「上線前必查」的事項變成可勾選的 checklist。這三件事缺一不可,因為少了任何一件,「能不能上線」就只能憑感覺回答。

另外一個現實考量是時間。我們的測試套件要在 60 秒內跑完,否則會被開發團隊跳過。在 SQLModel 0.0.24 + SQLite 記憶體模式下,8 支 E2E 通常 6–10 秒;如果未來擴張到 50 支以上,就要考慮用 pytest-xdist 並行、或把慢的部分(migrations、PostgreSQL 連線)獨立成 nightly job。我們今天刻意把慢的部分(subprocess 跑 Alembic)獨立成一支測試,並在 CI 上加 timeout,避免它阻塞主流程。

今天的內容分四段:第一段說明測試金字塔、為什麼以端對端為主;第二段寫 conftest 共用 fixture、session 隔離、抽樣資料;第四段寫八支主要測試覆蓋預約流程、認證、後台、確認信、衝突、排程器、migration、healthcheck;第三段整理驗收清單與比對方法。讀完這篇你會了解:pytest --reuse-db、httpx 與 TestClient 差異、SQLite 跑測試與 PostgreSQL 跑測試的差別、怎麼寫一份「可被 checklist 化」的驗收文件。

今天的內容分四段:第一段說明測試金字塔、為什麼以端對端為主;第二段寫 conftest 共用 fixture、session 隔離、抽樣資料;第四段寫八支主要測試覆蓋預約流程、認證、後台、確認信、衝突、排程器、migration、healthcheck;第三段整理驗收清單與比對方法。讀完這篇你會了解:pytest --reuse-db、httpx 與 TestClient 差異、SQLite 跑測試與 PostgreSQL 跑測試的差別、怎麼寫一份「可被 checklist 化」的驗收文件。

原理解念:測試金字塔、fastapi 測試的雙路徑

FastAPI 提供兩種測試路徑:一是用 fastapi.testclient.TestClient 直接 in-process 呼叫(速度快、不需要真的伺服器);二是用 httpx.AsyncClient 搭配 ASGITransport(支援非同步端點)或者真的把 uvicorn 跑起來再用外部 client 戳。我們的測試以 TestClient 為主、非同步端點用 AsyncClient + ASGITransport,這樣能涵蓋 90% 的場景,又不必開多個 process。真的壓力測試則留給 Day 43 與 locust。

資料庫隔離的標準做法是「每個測試函式一份新 session」。具體來說:用 SQLModel 的 create_all 在測試開始時建 schema、用 SessionLocal 建立乾淨 session、在 fixture 結束時 drop_all。要注意:當測試覆蓋 9 個行程、每個跑 fixture 一次,總時間會很可觀。我們用 session-scoped 的「建 schema」+ function-scoped 的「清資料」最佳化:先建一次 schema、每個 test 開頭用 transaction begin、test 結尾 rollback,總時間可以壓在數秒內。我們也會在本篇附上 conftest.py 範例。

另一個關鍵觀念是「不要在測試裡跑真的 BackgroundTasks」。FastAPI 的 TestClient 會在 response 迴傳前同步執行 background task,這對於「驗證通知有寫進 DB」很方便,但要驗「排程器有觸發」就要直接呼叫 scheduler 函式,不可依賴實際時間。我們今天用「直接呼叫 send_upcoming_reminders()」的方式驗證排程邏輯,不真的等 09:00 來臨。

測試金字塔的頂端——端對端測試——是我們今天的主力,但它也最容易因為環境不一致而 flaky。常見的 flaky 原因有四:時間相關(用 datetime.now() 而非固定時間)、硬碟相關(寫到固定路徑但測試機沒有權限)、網路相關(測試打到外部 API)、亂數相關(測試順序影響結果)。對應處理:注入 clock、用 tmp_path、用 fake sender、設 random.seed。我們的測試套件會把這四件事都處理好。 下表整理常見的 flaky 原因與對應的處理姿勢,給後續維運者一個查閱依據:

# flaky 測試的成因與對策速查(給維運與新進工程師參考)
FLAKY_REMEDIES = {
    "時間漂移": "用 freezegun.freeze_time 凍結,或注入 clock 參數",
    "硬碟權限": "測試路徑一律用 tmp_path fixture,避免寫到固定位置",
    "外部 API": "用 respx / httpx_mock 攔截;或注入 fake client",
    "亂數順序": "fixture 內 random.seed(42);或在 conftest 固定隨機 state",
    "DB 殘留": "每個 test 開 transaction、結尾 rollback(SQLAlchemy ORM)",
    "時區誤差": "全部用 timezone.utc;UI 層再換算當地時區",
    "Port 衝突": "綁 0 讓 OS 隨機分,或用 unix socket",
    "檔案 lock": "用 pathlib.Path.write_text + 上 explicit flush + os.replace",
}
這張速查不是為了今天用,而是為了「三個月後有人跑測試遇到失敗時」省下 30 分鐘的查找時間。我們的 conftest.py 與測試檔只體現其中四項(時間、DB、隨機、時區),其餘留給 Day 43 的壓力測試與未來的維運手冊。 另一個容易被新手忽略的觀念是「測試不是越多越好」。一份專案有 5 支核心 E2E + 30 支單元,往往比 200 支重複的低價值測試更可靠。重複的測試(例如把同一段邏輯測 5 次)會延遲 CI、模糊焦點;真正有價值的測試是「邊界案例」與「整合介面」。今天的 8 支 E2E 每一支都對應一條 user story:建立預約、確認預約、衝突擋下來、通知建出來、排程器清理、admin 守門、健康檢查活著、OpenAPI 完整。每支測試的失敗訊息要能直接告訴 PM「哪條 user story 壞了」,這才是測試的最佳狀態。我們明天上 Docker Compose 時,這 8 支就會作為「合約」守住整個 stack:只要 CI 綠燈,就可以打包、部署、公告上線。

完整實作:conftest、八支 E2E 測試、驗收清單

先安裝測試依賴(補充到 pyproject.toml 的 dev 群組)。

# pyproject.toml(節錄 dev 依賴)
[project.optional-dependencies]
dev = [
    "httpx==0.28.0",
    "pytest==8.4.0",
    "pytest-asyncio==0.24.0",
    "ruff==0.7.0",
    "freezegun==1.5.1",   # 時間注入
]

然後是 conftest.py,把所有測試共用的 fixture 收在一處:

# tests/conftest.py
from datetime import datetime, timezone
from pathlib import Path
import os

import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, Session, create_engine

# 用 SQLite in-memory 加速測試;要驗 PostgreSQL 專屬行為時改 conftest 切換
os.environ["BOOKING_DATABASE_URL"] = "sqlite:///:memory:"

from booking_system.main import app  # noqa: E402
from booking_system.db import Base, engine  # noqa: E402
from booking_system.models import (  # noqa: E402
    Booking, BookingStatus, Resource, ResourceKind, User, UserRole,
)
from booking_system.auth import hash_password  # noqa: E402


@pytest.fixture(scope="session", autouse=True)
def _create_schema():
    SQLModel.metadata.create_all(engine)
    yield
    SQLModel.metadata.drop_all(engine)


@pytest.fixture(autouse=True)
def _isolate_each_test():
    """每個測試函式開頭清資料;用 transaction 隔離比 drop_all 快。"""
    with engine.connect() as conn:
        for table in reversed(SQLModel.metadata.sorted_tables):
            conn.execute(table.delete())
        conn.commit()
    yield


@pytest.fixture
def client():
    return TestClient(app)


@pytest.fixture
def admin_user():
    with Session(engine) as session:
        u = User(
            email="admin@example.com",
            password_hash=hash_password("admin-pass"),
            role=UserRole.ADMIN,
            display_name="管理員",
        )
        session.add(u)
        session.commit()
        session.refresh(u)
        return u


@pytest.fixture
def customer_user():
    with Session(engine) as session:
        u = User(
            email="customer@example.com",
            password_hash=hash_password("cust-pass"),
            role=UserRole.CUSTOMER,
            display_name="客戶",
        )
        session.add(u)
        session.commit()
        session.refresh(u)
        return u


@pytest.fixture
def resource():
    with Session(engine) as session:
        r = Resource(name="攝影棚 A", kind=ResourceKind.ROOM, description="室內")
        session.add(r)
        session.commit()
        session.refresh(r)
        return r


def _login(client: TestClient, email: str, password: str) -> str:
    r = client.post("/auth/login", json={"email": email, "password": password})
    assert r.status_code == 200, r.text
    return r.json()["access_token"]


@pytest.fixture
def admin_token(client, admin_user):
    return _login(client, "admin@example.com", "admin-pass")


@pytest.fixture
def customer_token(client, customer_user):
    return _login(client, "customer@example.com", "cust-pass")

這個 conftest 把「每個測試都會用到」的設定都集中起來:_create_schema 跑一次就好(session scope)、_isolate_each_test 每個 test 開頭清資料但不重建 schema(autouse)、登入、給 admin 與 customer 的 token、給 resource。測試函式就能專心寫「對 / 錯」邏輯,不被前置設定污染。

底下是八支 E2E 測試:

# tests/test_e2e.py
from datetime import datetime, timedelta, timezone

import pytest
from fastapi.testclient import TestClient


def _ts(hours_from_now: int) -> str:
    return (datetime.now(timezone.utc) + timedelta(hours=hours_from_now)).isoformat()


def test_health(client: TestClient):
    r = client.get("/health")
    assert r.status_code == 200
    assert r.json()["status"] == "ok"


def test_register_login_flow(client: TestClient):
    # 註冊
    r = client.post("/auth/register", json={
        "email": "new@example.com",
        "password": "p4ssw0rd-1234",
        "display_name": "新使用者",
    })
    assert r.status_code == 201
    # 登入
    r = client.post("/auth/login", json={"email": "new@example.com", "password": "p4ssw0rd-1234"})
    assert r.status_code == 200
    assert "access_token" in r.json()


def test_create_booking_then_confirm_via_admin(
    client: TestClient, customer_token: str, admin_token: str, resource
):
    headers = {"Authorization": f"Bearer {customer_token}"}
    r = client.post("/bookings/", json={
        "resource_id": resource.id,
        "start_at": _ts(24),
        "end_at": _ts(25),
        "customer_email": "cust@example.com",
    }, headers=headers)
    assert r.status_code == 201, r.text
    booking_id = r.json()["id"]

    # 管理員登入後台,確認它
    r = client.post(f"/admin/bookings/{booking_id}/confirm", headers={"Authorization": f"Bearer {admin_token}"})
    assert r.status_code == 200


def test_conflict_detection_blocks_overlap(client: TestClient, customer_token, resource):
    headers = {"Authorization": f"Bearer {customer_token}"}
    r1 = client.post("/bookings/", json={
        "resource_id": resource.id, "start_at": _ts(30), "end_at": _ts(31),
        "customer_email": "a@example.com",
    }, headers=headers)
    assert r1.status_code == 201
    # 第二筆時段重疊,應被擋
    r2 = client.post("/bookings/", json={
        "resource_id": resource.id, "start_at": _ts(30, ), "end_at": _ts(32),
        "customer_email": "b@example.com",
    }, headers=headers)
    assert r2.status_code == 409
    assert "conflict" in r2.json()["detail"].lower()


def test_notification_created_on_booking(client: TestClient, customer_token, resource):
    from booking_system.models import Notification
    headers = {"Authorization": f"Bearer {customer_token}"}
    r = client.post("/bookings/", json={
        "resource_id": resource.id,
        "start_at": _ts(48),
        "end_at": _ts(49),
        "customer_email": "notify@example.com",
    }, headers=headers)
    assert r.status_code == 201
    booking_id = r.json()["id"]
    from sqlmodel import Session, select
    with Session(__import__("booking_system.db", fromlist=["engine"]).engine) as session:
        notes = session.exec(select(Notification).where(Notification.booking_id == booking_id)).all()
    assert len(notes) >= 1


def test_scheduler_marks_old_queued_failed(client: TestClient, resource):
    """直接呼叫 scheduler 函式(不真的等到 03:00)。"""
    from datetime import datetime, timedelta, timezone
    from booking_system.db import engine
    from booking_system.models import Notification, NotificationStatus, Booking, BookingStatus
    from booking_system.scheduler import cleanup_stale_queued

    with Session(engine) as session:
        old = Notification(
            booking_id=1, channel="email", subject="舊通知", body="...",
            status=NotificationStatus.QUEUED,
            created_at=datetime.now(timezone.utc) - timedelta(days=30),
        )
        session.add(old)
        session.commit()

    # 模擬跳到排程時間
    import asyncio
    asyncio.run(cleanup_stale_queued())

    with Session(engine) as session:
        again = session.get(Notification, old.id)
        assert again.status == NotificationStatus.FAILED


def test_admin_dashboard_requires_admin(client: TestClient, customer_token):
    # customer 進後台 → 403
    r = client.get("/admin/dashboard", headers={"Authorization": f"Bearer {customer_token}"})
    assert r.status_code == 403


def test_openapi_schema_includes_all_routes(client: TestClient):
    r = client.get("/openapi.json")
    assert r.status_code == 200
    schema = r.json()
    paths = schema["paths"].keys()
    for endpoint in [
        "/health",
        "/auth/register",
        "/auth/login",
        "/bookings/",
        "/admin/dashboard",
        "/admin/bookings/search",
    ]:
        assert endpoint in paths, f"OpenAPI 缺少 {endpoint}"

這八支測試覆蓋了核心 API:健康檢查、註冊登入、建立預約、後台確認、衝突檢查、通知建立、排程器清理、後台權限、OpenAPI 完整性。每支都跑在 TestClient 內、不開 uvicorn、不連真實資料庫,整套大約 6–10 秒就會跑完(實際數字會略有不同)。

接下來做效能測試(mini 版本)。完整 locust 在 Day 43,今天只驗「健康檢查能在 50 ms 內回應」。

# tests/test_perf_health.py
import time

from fastapi.testclient import TestClient


def test_health_under_50ms(client: TestClient):
    durations = []
    for _ in range(30):
        start = time.perf_counter()
        r = client.get("/health")
        elapsed = (time.perf_counter() - start) * 1000
        durations.append(elapsed)
        assert r.status_code == 200
    durations.sort()
    p50 = durations[len(durations) // 2]
    p95 = durations[int(len(durations) * 0.95)]
    print(f"\n/health P50={p50:.1f}ms P95={p95:.1f}ms")
    assert p50 < 50, f"P50 {p50:.1f}ms 過高"

這支測試跑 30 次 /health 計算 P50 / P95。在本機 SQLite 上 P50 約 3–8 毫秒、P95 約 6–15 毫秒(實際數字會略有不同);如果哪天數字飆高,表示有人改了 middleware 或加了大成本裝飾器,要回頭檢查。

最後是 migration 測試。Alembic 1.16 提供 alembic check 與 alembic upgrade head,我們把它們包成 pytest:

# tests/test_migration.py
import subprocess
import sys
from pathlib import Path


def test_alembic_upgrade_head(tmp_path: Path):
    """用 scratch 資料庫跑 alembic upgrade head,最後檢查是否有 tables。"""
    db_path = tmp_path / "scratch.db"
    env = {"BOOKING_DATABASE_URL": f"sqlite:///{db_path}"}
    proc = subprocess.run(
        [sys.executable, "-m", "alembic", "upgrade", "head"],
        capture_output=True, text=True, env={**env, **__import__("os").environ},
        cwd=Path(__file__).resolve().parent.parent,
    )
    assert proc.returncode == 0, proc.stderr
    # 檢查核心表格存在
    import sqlite3
    conn = sqlite3.connect(db_path)
    names = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
    conn.close()
    for tbl in {"users", "resources", "time_slots", "bookings", "notifications", "audit_logs"}:
        assert tbl in names, f"缺少 {tbl}"

這支跑 alembic upgrade head 在臨時 SQLite 上,確認 migration script 可以一次跑完、核心表格都建起來。Day 41 的 Docker Compose 會用同一支 script,但目標換成 PostgreSQL。

第三段是驗收清單。我們用 Markdown 寫成 docs/acceptance-checklist.md,每項都有勾選欄位:

# 對應的 docs/acceptance-checklist.md(純文字示範)
ACCEPTANCE = """
# 預約管理系統 驗收清單(Day 41 上線前必跑)

## 環境
- [ ] Python 3.13 已安裝
- [ ] PostgreSQL 17 已啟動,連線 URL 與 .env 一致
- [ ] Alembic migration 已 upgrade head
- [ ] 環境變數 SECRET_KEY / BOOKING_DATABASE_URL 已設定
- [ ] Docker 與 docker compose v2 已安裝(若走 container)

## 測試
- [ ] `uv run pytest -v` 全部通過
- [ ] `uv run pytest tests/test_e2e.py` 端對端全部通過
- [ ] `uv run pytest tests/test_perf_health.py` 效能 smoke 通過
- [ ] `uv run pytest tests/test_migration.py` migration 通過

## 安全
- [ ] `/admin/*` 未登入回 401、非 admin 回 403
- [ ] JWT cookie 為 HttpOnly + SameSite=Lax
- [ ] CORS 白名單已設定(不是 wildcard)
- [ ] 密碼雜湊用 bcrypt 或 argon2

## API 規格
- [ ] OpenAPI 文件 `/openapi.json` 含所有路由
- [ ] 衝突檢查回 409,且 detail 帶 reason
- [ ] 錯誤回應結構一致(detail、code、request_id)
- [ ] 通知走模擬通道;環境變數可切到 SMTP

## 維運
- [ ] `/health` 與 `/ready` 兩端點存在且回 200
- [ ] log 是 JSON 格式(每行可被 log collector 解析)
- [ ] 備份腳本(Day 42)跑過一次且復原演練成功
- [ ] 監控與 metrics 端點(Day 42)已建立
"""

這份清單可以在 Day 41 上線前一一勾選;任何一項沒過都不能算「可上線」。CI 跑測試只是開始,這份 checklist 是人事上的最後一道防線。

常見錯誤與踩雷

第一個常見錯誤是「test_ 開頭的函式忘了放 fixture」。一旦忘記在某支 test 內放 customer_token 卻呼叫需要登入的 API,就會發生「AttributeError: 'function' object has no attribute 'get'」或「KeyError」。對應處理:把 API 呼叫包進輔助函式 auth_post(client, token, path, json),token 為 None 時就用 fixture 的 fallback;或用 pytest 的 @pytest.mark.usefixtures 自動注入。

第二個常見踩雷是「SQLite 不支援某些 PostgreSQL 專屬語法」。我們的測試用 SQLite in-memory 加速,但 SELECT FOR UPDATE、JSONB、ARRAY 都會爆炸。對應處理:寫測試時避開 PostgreSQL 專屬功能;只對 Alembic migration 用真的 PostgreSQL 測(CI 上有一個 test job 跑 postgres:17 容器,再跑一輪)。本日因為 SQLite 沒有 FOR UPDATE,所以衝突檢查的 race condition 測試要等 Day 43 或 CI 的 PostgreSQL 跑測時補上。

第三個是「time 與 datetime.now() 在測試裡變動」。如果你的業務邏輯用 datetime.now() 而測試又期望固定時間,會一夜之間測試全壞(夏令時間、跨日邊界)。對應處理:把 utcnow() 注入為可替換的依賴,或在測試裡用 freezegun.freeze_time("2025-07-15 09:00:00")。

第四個是「alembic 跑在錯誤的 cwd」。Day 9 我們把 alembic.ini 放在專案根目錄,如果測試從 tests/ 子目錄跑 alembic,會找不到 alembic.ini。對應處理:test_alembic_upgrade_head 內明確指定 cwd=Path(__file__).resolve().parent.parent。我們上面那段寫法已經處理好。

效能與實務提醒

測試跑太慢會讓開發者逃避測試。我們的策略:用 SQLite in-memory 讓大多數測試在數秒內跑完、用 freezegun 讓時間相關測試不 sleep、用 subprocess 跑 Alembic 一個 process。在本機約 6–10 秒跑完 8 支主要測試、CI 環境約 25–40 秒(網路 IO 較慢)。如果超過 60 秒就要警惕,表示有人寫了不必要的 I/O 或 sleep。

另一個實務提醒:當某支測試偶爾失敗但又看不出原因,先停下來重看測試本身,不要重寫 production code 來遷就測試。一個出現兩次的 flaky 通常表示測試隱含的假設出問題,例如依賴了測試之間的執行順序、把測試物件共用在全域等。我們的 conftest 用 autouse=True 強制每個 test 從乾淨的 session 開始,正是為了避免這種假設。

另一個關鍵是把測試分層:快測(單元 + 端對端,60 秒內)跑在每次 commit、慢測(PostgreSQL + 整合)跑在 merge 進 main 之後。我們沒有把這套機制寫進這一篇,是因為 Day 32 的 GitHub Actions CI 已經示範過;如果你的專案只有一支 CI job,也可以用 pytest -m fast 與 pytest -m slow 標籤分流。

另一個關鍵是把測試分層:快測(單元 + 端對端,60 秒內)跑在每次 commit、慢測(PostgreSQL + 整合)跑在 merge 進 main 之後。我們沒有把這套機制寫進這一篇,是因為 Day 32 的 GitHub Actions CI 已經示範過;如果你的專案只有一支 CI job,也可以用 pytest -m fast 與 pytest -m slow 標籤分流。

驗收清單的維護成本其實比程式碼低,但常被忽略。實務上要把驗收清單放進 PR 模板:每發一次 PR 都要更新對應的條目(新增的 API、要勾的新條款)。這樣驗收清單會跟程式碼一起長大,不會變成過期的文件。我們下週的 Day 44 會把這份清單與 README 一起寫進正式 docs。

最後,效能測試的「健康檢查 50 ms」只是一個起點。真實 production 的 SLA 應該寫成「P95 < 200 ms」並配上自動警報(Day 42)。今日的版本屬於「程式碼層級的 smoke 測試」,可以早點抓到回歸;如果要量化容量規劃,就要靠 Day 43 的 locust。

小結

今天把「測試」與「驗收」一次做齊。我們寫了八支 E2E 測試覆蓋預約流程、認證、衝突、通知、排程、後台權限、OpenAPI 完整性;一支 P50/P95 效能 smoke;一支 Alembic migration 測試;與一份「驗收清單」Markdown 範本。所有測試都能用 uv run pytest -v 一次跑完、CI 上 30 秒內結束。整個預約管理系統的「規格」就此變成可自動驗證的形式。明天 Day 41 會把這套測試 + 整套系統打包成 Docker Compose,達到「一鍵上線」。

結語

今天的重點是把「規格」變成「可跑的程式碼」。我們用 pytest 8.4 + httpx 0.28 + SQLModel 把預約、通知、後台、排程四條主線全部寫成可自動驗證的測試,並用一份 Markdown checklist 補上「人工事項」。八支 E2E 測試都跑通的瞬間,就是系統「可上線」的開始;checklist 全部勾完的瞬間,才是「真的可以上線」。明天,我們會把這套驗收完整的服務包進 Docker Compose v2,建立 PostgreSQL 17 + FastAPI + Nginx 的完整 stack,並驗證 healthcheck 真的能從外部打到。沒有 Docker 的讀者也不必擔心,明天會提供完整的「不用 Docker」替代流程,讓你用 uvicorn + 本機 PostgreSQL 一樣能跑通。

延伸資源

  • pytest 官方文件(8.4,2025):https://docs.pytest.org/en/stable/,fixture 與 parametrize 的標準寫法。
  • httpx TestClient(0.28,2025):https://www.python-httpx.org/async/#calling-into-python-web-apps,搭配 FastAPI 的標準測試介面。
  • Alembic 官方教學(1.16,2025):https://alembic.sqlalchemy.org/en/latest/cookbook.html,upgrade head、downgrade -1、autogenerate 的正確用法。
  • SQLAlchemy 交易與隔離等級(2.0.41,2025):https://docs.sqlalchemy.org/en/20/orm/session_transaction.html,測試環境的 transaction 回滾策略。
  • freezegun 時間注入(1.5,2025):https://github.com/spulec/freezegun,把 datetime.now() 凍結在測試需要的時間點。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...