← 所有文章2026.10.24 · 資料系統

用真實 PostgreSQL 驗證系統邊界

從 migration 建立隔離測試庫,以受限角色與實際寫入結果,驗證 mock 難以涵蓋的權限與交易行為。

函式回傳正確的物件,不代表資料庫留下正確的結果。查詢可能因為加對了條件,只回傳目前客戶的資料,但連線角色其實仍能讀取所有客戶的紀錄。付款操作也可能回傳成功,卻改到了同一個人的另一個餘額帳戶。

在我維護的平台裡,我用真實 PostgreSQL 的整合測試檢查這些邊界,讓資料函式、SQL、約束與權限一起接受驗證。單元測試負責計算與條件分支;需要資料庫實際執行或拒絕某個操作的問題,則交給資料庫測試。

以下使用通用名稱與虛構數值,說明已實作的測試方式,不包含客戶資料或部署細節。

從 migration 建立測試庫

整合測試的共用環境透過 Testcontainers 啟動可拋棄的 PostgreSQL 16 容器,建立連線池,再執行專案的 Drizzle migrations。每組測試建立需要的資料、執行案例,結束後關閉連線並移除容器。RLS 測試還會套用資料庫角色的授權腳本,再以受限角色建立連線。

flowchart TD
  A[啟動可拋棄的 PostgreSQL] --> B[執行 migrations]
  B --> C[依測試需要套用角色授權]
  C --> D[建立少量虛構測試資料]
  D --> E[執行資料函式與 SQL]
  E --> F[驗證寫入結果與被拒絕的存取]
  F --> G[關閉連線並移除容器]

這讓 migration 本身成為測試準備的一部分。少了一個函式,或某個約束無法建立,可能在業務測試開始前就被發現。如果另外手寫一份簡化版測試 schema,這些差異反而容易被藏起來。容器的啟動與連線方式可參考 Testcontainers 的 PostgreSQL 文件。

多數 fixture 只新增情境需要的資料,並產生獨立 ID,讓案例不必依賴開發者資料庫裡剛好存在什麼。這不等於每個測試都清空資料庫:同一組測試可以共用容器,因此斷言要依自己的 fixture ID 查詢,避免把其他案例的資料一起算進去。

測權限時,真的以受限角色連線

建立測試環境需要較高權限;驗證權限時,則要使用被限制的角色。

PostgreSQL 的 superuser、具有 BYPASSRLS 的角色會繞過列級安全性,資料表擁有者通常也會。因此,只用建置環境的連線測試資料歸屬,無法證明應用程式的 RLS 邊界有效。PostgreSQL 官方文件有明確說明這些例外。

測試建立兩位虛構客戶,並為客戶端、員工端與不相關的應用角色各自建立連線池。客戶測試的 helper 會開啟交易、設定只在該交易有效的客戶身分,再透過同一條連線執行查詢。

有用的測試矩陣,需要同時包含合法操作與越界嘗試:

情境 預期的資料庫結果
客戶 A 不加資料歸屬條件,直接查詢受保護紀錄 只能看到 A 的資料
A 明確指定 B 的紀錄查詢 不回傳資料
A 嘗試更新 B 的紀錄 更新筆數為零
A 嘗試新增一筆歸屬 B 的紀錄 Policy 拒絕寫入
沒有設定客戶身分 看不到客戶紀錄
不相關的應用角色查詢受保護資料 權限被拒絕

第一個案例刻意拿掉應用層的查詢條件,避免正確的 WHERE 掩蓋缺少 policy 的問題。更新與新增也要分開看:操作被限制,不一定都以拋出例外呈現。

下面是簡化的示意程式,helper 負責設定交易範圍內的身分:

await asCustomer(customerA, async (connection) => {
  // Intentionally omit WHERE owner_id = ... to test the policy itself.
  const result = await connection.query(
    'SELECT id FROM protected_records',
  );

  expect(result.rows.map((row) => row.id)).toEqual([recordA.id]);
});

實際測試還會檢查員工角色可以新增存取紀錄,但不能修改或刪除紀錄。這是在驗證角色真正取得的能力,而非某個按鈕是否隱藏。應用層如何搭配這些限制,可參考共用帳號,分開資料存取權限。

重新讀取操作改變的資料

對餘額操作而言,回傳金額只是其中一個檢查點。現有測試會重新讀取餘額與異動紀錄,檢查儲存的金額、異動類型與狀態,也涵蓋餘額不足、帳戶不存在,以及呼叫端需要分別處理的錯誤型別。

例如,一個簡化的入帳情境從 240.00 開始,增加 60.00,應分別檢查:

  • 資料庫的餘額是 300.00。
  • 函式回傳的異動前後值是 240.00 與 300.00。
  • 正確的帳戶擁有者留下金額為 60.00、狀態為完成的異動紀錄。

這些數值都是示意。重點在於:回傳結果正確,以及儲存結果正確,是兩件需要分別驗證的事。

另一個測試會替同一位客戶建立兩種單位的餘額帳戶,對選定的帳戶執行操作,再檢查兩邊的餘額。連「不該被改到的帳戶」都一起確認,才能抓出只依客戶 ID 查詢、意外選到另一個帳戶的錯誤。如果 mock 永遠回傳預期中的那一筆,這個問題就可能漏掉。

驗證下一次請求真正會讀到的查詢

有些結果來自計算,而非單一欄位。以可售庫存為例,它由實體庫存與待付款訂單的預留量推導。庫存測試在改變訂單狀態後,會執行實際的可售量查詢。

用虛構數量表示,預期的變化如下:

狀態 實體庫存 待付款預留量 可售量
下單前 12 0 12
未付款訂單預留 3 件 12 3 9
付款交易提交庫存扣減與訂單狀態變更 9 0 9

取消則是另一條路徑:取消未付款訂單後,預留量消失,但實體庫存不應被扣除。現有測試涵蓋這兩種變化,檢查寫入邏輯與下一次頁面使用的 SQL 是否一致,而不是只確認各個函式單獨看起來合理。

結帳併發說明了鎖定設計。這裡的循序狀態測試證明的是較窄的範圍:在測試情境下,操作完成後的資料與可售量查詢結果相符。

測到什麼,就主張到哪裡

真實資料庫讓這些測試具有實際的約束與權限檢查,但不代表它們涵蓋整個系統。

  • 從空庫重播 migration,驗證的是全新建置,不能涵蓋所有既有資料的升級路徑。
  • 循序執行的案例,無法證明交易重疊時仍然正確;那需要多條競爭連線與可控制的執行順序。
  • SQL 測試不會一併驗證瀏覽器登入流程、外部金流通知或正式環境設定。
  • 這組整合測試有獨立的本機執行方式與容器需求,與現有 CI 的單元測試工作分開。

它提供的是一個具體、可重複驗證的結果:在指定角色、資料與操作下,PostgreSQL 留下預期狀態,並拒絕不應允許的存取。修改資料程式時,這些邊界就成為可以直接重跑的回歸檢查。