本文 AI 產出,尚未審核

FastAPI 測試與除錯:測試資料庫

簡介

在開發 FastAPI 應用程式時,資料庫是最常與外部系統互動的關鍵元件。若資料庫操作未經充分測試,就很容易在上線後出現 資料遺失、交易不一致效能瓶頸 等嚴重問題。
本篇文章將說明如何在 FastAPI 專案中建立可靠的資料庫測試環境,從 測試資料庫的建立、事務管理、測試資料的清理常見陷阱與最佳實踐,一步步帶你打造可自動化執行、易於維護的測試流程。文章適合剛接觸 FastAPI 的初學者,也能讓已有經驗的開發者快速回顧測試要點。


核心概念

1. 為什麼要使用獨立的測試資料庫?

  • 避免污染正式資料:測試過程中常會執行 INSERTUPDATEDELETE,若直接對正式資料庫操作,會造成不可逆的資料變更。
  • 可重現性:測試資料庫可以在每次測試前以相同的 schema 與初始資料重新建立,確保測試結果一致。
  • 平行測試:CI/CD 流程中會同時跑多個測試工作,獨立的測試資料庫讓每個工作執行環境互不干擾。

2. 測試資料庫的常見類型

類型 說明 優點 缺點
SQLite in‑memory sqlite:///:memory:,僅在記憶體中運行 設定簡單、速度快 不支援所有 SQL 功能,與正式 DB 差異較大
Dockerized PostgreSQL/MySQL 透過 Docker 建立臨時容器 與正式環境最相近 需要 Docker 環境、啟動較慢
測試用資料庫 (Test DB) 於同一 DB 伺服器上建立獨立 schema 免 Docker、可使用正式 DB 功能 需要清理資料、可能受其他測試影響

3. 事務 (Transaction) 與測試的關係

在測試中最常見的做法是 在每個測試函式開始時開啟事務,測試結束後回滾,這樣即使測試失敗也不會留下痕跡。FastAPI 與 SQLAlchemy 搭配時,可利用 session.begin_nested() 以及 session.rollback() 來實現。

4. 測試框架與工具

  • pytest:最受歡迎的 Python 測試框架,支援 fixture、參數化等功能。
  • pytest‑asyncio:提供 async 測試支援,適用於 FastAPI 的非同步端點。
  • TestClient:FastAPI 內建的測試客戶端,基於 starlette.testclient,可直接呼叫 API。

程式碼範例

以下範例以 PostgreSQL 為例,展示如何在 FastAPI 中配置測試資料庫、使用 fixture 管理事務、以及撰寫測試案例。所有程式碼均加上說明註解,方便閱讀。

1️⃣ 建立 database.py – 共用的 DB 連線設定

# database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base

# 正式環境的資料庫 URL
SQLALCHEMY_DATABASE_URL = "postgresql://user:password@localhost/prod_db"

# 測試環境的資料庫 URL(使用測試專用 schema)
SQLALCHEMY_TEST_URL = "postgresql://user:password@localhost/test_db"

# 依據環境切換
engine = create_engine(
    SQLALCHEMY_DATABASE_URL,  # 會在測試時被覆寫
    pool_pre_ping=True,
)

# Session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base = declarative_base()

重點:測試時會在 conftest.py 中把 engine 重新指向 SQLALCHEMY_TEST_URL,確保不會連到正式資料庫。

2️⃣ 建立 models.py – 簡易的 User 模型

# models.py
from sqlalchemy import Column, Integer, String
from .database import Base

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    username = Column(String, unique=True, index=True, nullable=False)
    email = Column(String, unique=True, index=True, nullable=False)

3️⃣ 建立 FastAPI 路由 routers/user.py

# routers/user.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from .. import models, schemas, database

router = APIRouter(prefix="/users", tags=["users"])

def get_db():
    db = database.SessionLocal()
    try:
        yield db
    finally:
        db.close()

@router.post("/", response_model=schemas.UserOut, status_code=status.HTTP_201_CREATED)
def create_user(user_in: schemas.UserCreate, db: Session = Depends(get_db)):
    db_user = db.query(models.User).filter(models.User.email == user_in.email).first()
    if db_user:
        raise HTTPException(status_code=400, detail="Email already registered")
    new_user = models.User(**user_in.dict())
    db.add(new_user)
    db.commit()
    db.refresh(new_user)
    return new_user

4️⃣ 測試設定 conftest.py – 使用 pytest fixture 管理測試 DB

# conftest.py
import os
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app   # 假設 FastAPI 實例在 main.py
from app import models, database

# -------------------------------------------------
# 1️⃣ 取得測試用資料庫 URL(可從環境變數或 .env 讀取)
TEST_DATABASE_URL = os.getenv(
    "TEST_DATABASE_URL",
    "postgresql://user:password@localhost/test_db"
)

# 2️⃣ 建立測試用 engine、Session
engine = create_engine(TEST_DATABASE_URL)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

# -------------------------------------------------
@pytest.fixture(scope="session")
def db_engine():
    """在測試 session 開始前,建立測試資料庫的 schema。"""
    # 建立所有表格(若已存在會自動跳過)
    models.Base.metadata.create_all(bind=engine)
    yield engine
    # 測試結束後,刪除所有表格,保持乾淨
    models.Base.metadata.drop_all(bind=engine)

@pytest.fixture(scope="function")
def db_session(db_engine):
    """每個測試函式使用獨立的事務,測試完自動 rollback。"""
    connection = db_engine.connect()
    transaction = connection.begin()
    session = TestingSessionLocal(bind=connection)

    # 讓 SQLAlchemy 使用 nested transaction(支援 rollback)
    nested = session.begin_nested()

    yield session

    # 結束測試:回滾事務,關閉連線
    session.close()
    transaction.rollback()
    connection.close()

@pytest.fixture(scope="function")
def client(db_session):
    """為 FastAPI TestClient 注入測試用的 DB session。"""
    # 依賴注入:覆寫 get_db 讓它回傳測試用 session
    def _get_test_db():
        try:
            yield db_session
        finally:
            pass

    app.dependency_overrides[database.get_db] = _get_test_db
    with TestClient(app) as c:
        yield c
    # 清除覆寫
    app.dependency_overrides.clear()

說明

  • db_enginesession 級別只執行一次,建立/銷毀測試 schema。
  • db_sessionfunction 級別使用事務,確保每個測試結束後自動 rollback,不留下任何資料。
  • client 把 FastAPI 的 get_db 依賴改寫為測試用 session,讓路由在測試時操作的是測試資料庫。

5️⃣ 撰寫測試案例 tests/test_user.py

# tests/test_user.py
import pytest
from fastapi import status

def test_create_user_success(client):
    """測試正常建立使用者的流程"""
    payload = {
        "username": "alice",
        "email": "alice@example.com"
    }
    response = client.post("/users/", json=payload)
    assert response.status_code == status.HTTP_201_CREATED
    data = response.json()
    assert data["username"] == "alice"
    assert data["email"] == "alice@example.com"
    assert "id" in data   # 由 DB 自動產生

def test_create_user_duplicate_email(client, db_session):
    """測試重複 email 時會回傳 400 錯誤"""
    # 先手動插入一筆資料
    from app.models import User
    user = User(username="bob", email="bob@example.com")
    db_session.add(user)
    db_session.commit()

    # 再嘗試建立相同 email 的使用者
    payload = {
        "username": "bob2",
        "email": "bob@example.com"
    }
    response = client.post("/users/", json=payload)
    assert response.status_code == status.HTTP_400_BAD_REQUEST
    assert response.json()["detail"] == "Email already registered"

@pytest.mark.asyncio
async def test_async_endpoint_example(client):
    """示範如何測試 async 路由(若有)"""
    response = await client.get("/async-endpoint")
    assert response.status_code == status.HTTP_200_OK
    assert response.json()["msg"] == "hello async"

重點

  • client 直接呼叫 API,測試過程不需要手動建立 Session
  • 第二個測試示範 先寫入資料 再測試重複檢查,利用 db_session 直接操作 DB,保證測試資料在同一事務中。
  • 使用 pytest.mark.asyncio 測試 非同步 路由,展現 pytest‑asyncio 的配合方式。

6️⃣ 使用 SQLite in‑memory 進行快速測試(可選)

# conftest.py(簡化版,僅用於快速本機測試)
@pytest.fixture(scope="function")
def db_session():
    engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
    models.Base.metadata.create_all(bind=engine)
    TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    session = TestingSessionLocal()
    yield session
    session.close()
    models.Base.metadata.drop_all(bind=engine)

提醒:SQLite 只適合驗證 ORM 的基本行為,若有 Postgres 專屬的功能(如 JSONBEXCLUDE 約束),仍須使用 Dockerized PostgreSQL 進行完整測試。


常見陷阱與最佳實踐

陷阱 可能的後果 解決方案 / 最佳實踐
直接使用正式資料庫 資料被測試程式改寫、刪除,甚至造成服務中斷 永遠conftest.py 中把 engine 換成測試用 URL
測試不回滾事務 測試之間相互污染,導致測試結果不一致 使用 session.begin_nested() + session.rollback(),或在每個測試結束後手動 drop_all
測試依賴外部服務(如 Redis、第三方 API) CI 執行失敗、測試速度變慢 使用 mocktestcontainers 產生臨時容器,僅在需要時才啟動
忘記清除依賴覆寫 之後的測試仍使用測試 DB,或產生奇怪的錯誤 在 fixture 結束時 app.dependency_overrides.clear()
使用硬編碼的資料 測試不具彈性、難以維護 使用 factory_boypytest‑factoryboy 產生測試資料,保持可讀與可重用性

其他最佳實踐

  1. 測試資料的可預測性:使用 factory 產生唯一的 usernameemail,避免因唯一鍵衝突導致測試失敗。
  2. CI 整合:在 GitHub Actions、GitLab CI 中加入 services: postgres,自動啟動測試資料庫容器。
  3. 測試效能:若測試套件過大,可將 快照測試(snapshot)與 單元測試 分開執行,減少每次 CI 執行時間。
  4. 文件化測試流程:在 README.md 或專案的 docs/ 中說明如何建立本機測試環境,降低新成員的上手門檻。

實際應用場景

場景 需求 方案
新功能上線前的回歸測試 確認 CRUD 操作在資料庫層面仍正確 使用上述 pytest + TestClient 的全套流程,於 PR 合併前自動跑完整測試
多租戶 SaaS 系統 每個租戶都有獨立 schema,需要在測試中驗證切換正確性 在測試 fixture 中動態建立臨時 schema,測試完即刪除
資料遷移腳本 (Alembic) 確認 migration 在不同 DB 版本間不會破壞資料 在測試中使用 alembic.command.upgradedowngrade,搭配測試 DB 進行驗證
高併發寫入測試 模擬大量同時寫入,檢查 transaction dead‑lock 使用 asyncio.gatherpytest‑asyncio 中同時發送多筆請求,觀察 DB log 是否有 lock 錯誤

總結

測試資料庫是 FastAPI 應用程式品質保證的重要環節。透過 獨立的測試資料庫、事務回滾、以及 pytest 的 fixture 結構,我們可以在不影響正式環境的前提下,快速且可靠地驗證所有 CRUD、business logic 與資料遷移的正確性。
本文提供了從 環境配置 → 測試程式碼 → 常見問題 的完整示範,並加入 Docker、SQLite in‑memory、factory 等多種實務技巧,讓讀者能依照專案規模與需求選擇最合適的方案。

記住:測試不只是找錯,更是一種 防止錯誤再次出現 的機制。只要在開發流程中持續執行測試資料庫的自動化檢測,就能大幅降低上線風險,提升團隊對產品的信心。祝開發順利,測試無礙!