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()凍結在測試需要的時間點。
留言
張貼留言