Skip to main content
整合測試實戰:AI 時代自動化測試與驗收(四)如何驗證資料庫、交易、序列化、外部 API 與事件邊界?

Architecture Journal

TestingIntegrationTestAIEngineering

整合測試實戰:AI 時代自動化測試與驗收(四)如何驗證資料庫、交易、序列化、外部 API 與事件邊界?

系列定位:上一篇用同一張 case table,把 unit test 落到 Python、Java、C++。這一篇往上一層,處理 unit test 通常不負責驗證的系統邊界:HTTP、資料庫、交易、序列化和事件。重點在驗證兩個元件接起來後,仍然遵守同一份契約,不必把所有服務一起啟動。

本系列同時談測試與驗收;本篇只處理自動化測試中的 integration test。驗收會留到後續文章,因為它要回答的是「這次交付是否解決了要解的問題」,不是「元件接起來後是否依照既定規則運作」。

上一篇:AI 時代的自動化測試與驗收(三):Unit test(下)換一種實作,測試還能留下嗎?

unit test 能證明單一規則成立,卻無法證明這條規則真的接得上系統。資料庫欄位名稱可能和 ORM mapping 不一致,API 的 JSON 欄位可能和 domain model 對不起來,交易可能只包住一半的寫入,事件 payload 也可能和 consumer 的期待不同。每一個元件單獨測都能綠燈,接起來卻照樣失敗。

整合測試不是「把全部服務跑起來」

整合測試的核心在於測試「元件之間的整合點」,不在於範圍有多大。

Fowler 對 integration test 的描述很實用:它測試多個元件如何彼此合作,或者測試程式如何和外部服務溝通。這個定義沒有要求一定要跨整個系統,也沒有要求一定要連上 production 依賴。[1]

可以用被驗證的邊界來分:

測試種類主要問題常見依賴
Unit test單一行為規則對不對?純函式、domain object、少量 fake
Integration test目標整合點接起來後,資料和協議對不對?依風險選擇真實依賴或 faithful test double
End-to-end test使用者旅程從頭到尾能不能完成?多個服務與完整流程;依環境選擇真實或替代依賴

所以,一個只啟動 OrderService 和 PostgreSQL 的測試,可以是整合測試;一個把所有服務都啟動、卻只 mock 掉真正的資料庫,也不會因為範圍很大就自動變成高價值測試。

判斷標準是測試是否真的穿過目標整合點,而不是啟動了多少服務。若測試要驗證 PostgreSQL 的 dialect、constraint 或 transaction,卻換成 SQLite,就沒有覆蓋這些風險;若測試要驗證 HTTP client 的協議,則可以使用能重現協議的 fake server。替換依賴本身沒有問題,沒有驗證到目標邊界才是問題。

這裡先把三種測試的責任分開:

測試保護的風險
Integration test目標整合點接起來後能不能運作
Contract testConsumer 和 Provider 的 API 或事件約定是否相容
End-to-end test完整部署拓撲與使用者流程是否成立

契約測試不一定要同時啟動 Consumer 和 Provider;它可以讓雙方分別驗證同一份 API 或事件契約。整合測試則要確認選定的 runtime wiring、資料庫、交易或 broker 連線能運作。這些依賴不必全部是真實服務,但 test double 必須忠實重現測試要保護的協議。兩者可能使用相同的案例,但保護的風險不同。

第一篇用資料庫、queue、框架設定和實際序列化概括 integration test 的風險。本篇沿用這個分類,再把依賴選擇拆開:資料庫 dialect、migration 和 transaction 用 production 同款引擎;第三方 HTTP 則保留真實 client 與 serializer,接上可控制的 fake server。這是測試不同風險,不是和前篇改用另一套定義。

先定義元件接起來後的觀察結果

整合測試不應該從 controller 或 repository 的程式碼倒推案例。先從使用者或其他服務看得到的結果,寫出整合場景的觀察結果。

以下用一個簡化的下單流程示範:

POST /orders
    |
    v
Order API -> OrderService -> PostgreSQL
                         |
                         +-> outbox_events

需求看的是:

  1. 合法訂單會回傳 201 Created 和訂單編號。
  2. orders 會留下正確的金額與狀態。
  3. 同一個 Idempotency-Key 重送,不會產生第二筆訂單。
  4. 訂單和 outbox event 要一起提交,不能只成功一半。
  5. 非法輸入不會留下半成品資料。

把它整理成 case 表:

Case輸入觀察結果
建立訂單合法商品與數量HTTP 201、資料庫有一筆 pending 訂單
重複請求相同 Idempotency-Key回傳同一個訂單,不新增資料
不存在商品product_id 不存在HTTP 404,資料庫沒有訂單
非法數量quantity = 0HTTP 400,資料庫沒有訂單
事件同步寫入合法訂單orders 與 outbox_events 各有一筆且使用同一交易

這張表就是整合測試的規格。API status code、資料庫狀態和事件 payload 都是可觀察行為;repository 使用哪個 ORM method 則不是。這裡的「契約」是一般意義的業務與技術約定,不等於下一篇要談的 Contract Testing workflow。

測資料庫整合時,優先使用 production 同款引擎

整合測試最常見的捷徑,是用記憶體資料庫或假的 repository 取代正式資料庫。這樣測試很快,但也可能把最重要的差異藏起來:

  • SQL dialect 不同,production 的 PostgreSQL 能執行的查詢,SQLite 不一定能執行。
  • constraint、foreign key、unique index 或 transaction isolation 在假的資料庫裡可能沒有同樣的行為。
  • migration 沒有真的跑,欄位預設值和索引問題會等到部署才出現。
  • ORM mapping 看起來正確,但實際讀寫型別或 nullable 行為不一致。

因此,當測試目標包含 SQL dialect、schema、migration、constraint 或 transaction 行為時,應優先使用和 production 相同的資料庫引擎。資料量不需要大,重點是讓真正的 schema、migration、constraint 和 transaction 參與測試。

測試資料庫不代表要共用開發者本機的一個長駐資料庫。比較穩定的選擇是每次測試工作建立隔離的 PostgreSQL container,測試結束後銷毀。Testcontainers 的用途就是用程式管理這類短生命週期的真實依賴;Java、Python 和其他語言都有對應支援。[2]

Python:用 pytest fixture 啟動 PostgreSQL

pytest fixture 適合管理整合測試的環境。fixture 可以把 container、schema、client 和清理動作組合起來,也能用不同 scope 控制資源要共用多久。pytest 官方文件也把 fixture 定位成可擴展的測試上下文,從簡單 unit test 一直到複雜的功能測試都適用。[3]

下面保留一段 Python 示意碼,目的是展示 fixture lifecycle 和「從 API 進入、從資料庫觀察」的測試形狀,不是可以直接複製執行的完整專案。真正專案仍要把 create_app、migration runner、database adapter 和 reset_database 換成自己的實作;其中 reset_database 代表清理測試資料或建立隔離 schema:

# tests/integration/conftest.py
import pytest
from testcontainers.postgres import PostgresContainer
from fastapi.testclient import TestClient

from app import create_app
from app.db import open_connection, run_migrations

@pytest.fixture(scope="session")
def postgres():
    with PostgresContainer("postgres:16-alpine") as container:
        yield container

@pytest.fixture(scope="session")
def database_url(postgres):
    return postgres.get_connection_url()

@pytest.fixture(scope="session")
def app(database_url):
    run_migrations(database_url)
    return create_app(database_url=database_url)

@pytest.fixture
def client(app):
    with TestClient(app) as test_client:
        yield test_client

@pytest.fixture
def db(database_url):
    connection = open_connection(database_url)
    try:
        yield connection
    finally:
        connection.close()

@pytest.fixture(autouse=True)
def isolated_database(db):
    reset_database(db)
    yield
    reset_database(db)

這裡的 postgres 用 session scope,整個測試 session 只啟動一個 container;client 和 db 則是每個測試重新建立。isolated_database 代表每個測試前後都清理資料,實際專案也可以改成唯一 namespace 或獨立 schema。scope 不是越大越快越好:共用越多,測試越容易互相污染;隔離越細,啟動和清理成本越高。

測試本身應該從 API 進入,再從資料庫觀察結果:

# tests/integration/test_orders.py
import pytest

pytestmark = pytest.mark.integration

def test_create_order_persists_order_and_outbox(client, db):
    response = client.post(
        "/orders",
        headers={"Idempotency-Key": "order-001"},
        json={
            "customer_id": "customer-001",
            "items": [
                {"product_id": "book-001", "quantity": 2, "unit_price": 100}
            ],
        },
    )

    assert response.status_code == 201
    order_id = response.json()["order_id"]

    order = db.fetch_one(
        """
        SELECT customer_id, status, total_amount
        FROM orders
        WHERE id = %s
        """,
        (order_id,),
    )
    assert order == {
        "customer_id": "customer-001",
        "status": "pending",
        "total_amount": 200,
    }

    event = db.fetch_one(
        """
        SELECT event_type, aggregate_id, payload
        FROM outbox_events
        WHERE aggregate_id = %s
        """,
        (order_id,),
    )
    assert event["event_type"] == "OrderCreated"
    assert event["aggregate_id"] == order_id

這個測試沒有驗證 OrderService 呼叫了哪個 method,也沒有 mock repository。它讓 HTTP routing、JSON parsing、service、SQL、migration、資料庫 constraint 和 outbox mapping 一起工作,最後只檢查外部看得到的結果。

需要一致性的流程,要驗 transaction 邊界

下單流程最危險的錯誤,是只提交了一半,而不是回傳錯一個欄位:

orders INSERT 成功
outbox_events INSERT 失敗
transaction 沒有 rollback

此時 API 可能回傳 500,但資料庫已經留下沒有事件的訂單。之後沒有 worker 會處理這筆訂單,系統就進入半完成狀態。

如果訂單和事件必須同時成立,程式應該把兩次寫入放在同一個 transaction。整合測試可以用故障注入讓第二次寫入失敗,再驗證:

  • API 回傳產品契約定義的錯誤狀態。
  • 使用這個 Idempotency-Key 的訂單不存在。
  • 對應的 outbox event 也不存在。

這裡不再放完整測試碼,因為故障注入點和 OutboxRepository.insert 的參數會依專案實作不同;重點是驗證兩筆資料是否一起提交或一起 rollback。

這裡的替換不是在 mock 受測邏輯,也不是驗證 repository 的呼叫次數;它是刻意製造一個可重現的故障,觀察 transaction boundary 是否真的涵蓋兩個寫入。整合測試仍然使用真實資料庫,只有故障來源被控制。範例假設 API 將這個未處理的 persistence failure 映射成 500;若產品契約採用其他 status code,測試應以契約為準。

測試交易時要注意一個常見誤區:如果測試用自己的 transaction 包住 API 呼叫,但 API 使用另一條 connection,測試外層的 rollback 不一定能撤銷 API 已提交的資料。尤其是 HTTP server、背景 worker 或非同步任務,通常會使用不同 connection。不要把「測試最後 rollback」當成萬用清理策略;要確認 transaction 的生命週期和 connection ownership。

Idempotency 是整合行為,不只是 if 判斷

上一篇的 unit test 可以測:

如果 key 已存在,就回傳既有結果。

但它測不到兩個 request 同時抵達時,資料庫 unique constraint 和 transaction 是否真的擋住重複資料。這是整合測試該保護的邊界。

可以先驗證最基本的重送情境:第一次 request 成功後,第二次帶著相同的 Idempotency-Key 和相同 payload 重送;測試應確認兩次 response 的訂單編號相同,資料庫只有一筆對應資料。這裡的 status code 要依產品契約決定,不要把 201 當成所有系統的固定答案。

若要測競爭條件,則需要讓兩個 request 真正並行,並確認資料庫有 unique index。這種測試可能受執行環境影響,應把它和一般 deterministic integration test 分開,避免每次 PR 都被不穩定的 thread timing 綁架。重要的是,競爭安全最後要由資料庫 constraint 和 transaction 提供保證,不能只靠 application-level 的「先查再新增」。

此外,還要定義相同 Idempotency-Key 搭配不同 payload 的行為。常見做法是回傳 409,並保留第一次請求的結果;這個案例也應該獨立測試,避免第二次 request 意外修改原本的訂單。

Serializer、migration 和資料庫 schema 都是契約

很多整合錯誤發生在「資料看起來一樣」的地方:

  • API 接受的是 unitPrice,domain object 讀的是 unit_price。
  • JSON 沒有 total_amount,程式卻默默使用 0。
  • migration 新增欄位,但 production 的舊資料沒有正確 default。
  • Python 的 datetime 帶 timezone,資料庫欄位卻用 naive timestamp。
  • 金額在 API 是 decimal string,資料庫讀回來變成 binary float。

這些問題不應該只靠 unit test 驗證 serializer 函式。整合測試至少要走一次真實的 request 和 response,並使用正式 migration 建立 schema。

一個有價值的 migration test,不會只重複檢查每個欄位名稱;它會從空資料庫跑完整 migration,再執行一個最小業務流程:

建立空 PostgreSQL
  -> 執行全部 migration
  -> 啟動 application
  -> 建立一筆訂單
  -> 讀回訂單與事件

如果 migration 沒有跑、schema 版本不對或 mapping 錯誤,這條路徑就應該失敗。整合測試的價值正是把「各自看起來合理」的零件,放回它們實際工作的組合裡。

外部服務:真實協議,假的世界

整合測試不等於把 payment provider、物流服務或第三方 API 真的打出去。那會帶來費用、速度、網路不穩定和不可控資料,而且測試失敗時很難判斷是自己的程式還是外部服務出了問題。這裡的 fake server 用可控制的替代依賴保留 HTTP 協議這個整合點,沒有把整合拿掉。

比較好的分界是:

  • 自己的 HTTP client、request serializer、retry policy:使用真實程式碼。
  • 第三方 API 的回應:用可控制的 fake server 或 stub server。
  • 第三方服務本身的 availability 和實際付款結果:交給 sandbox smoke test 或 end-to-end test。

例如 payment adapter 的整合測試可以驗證:

PaymentClient
  -> HTTP method 是 POST
  -> path 是 /payments
  -> JSON 欄位名稱正確
  -> timeout 和 retry 行為符合規則
  -> 200、409、500、timeout 各自轉成正確的 domain 結果

這和上一篇批評的「mock 某個 method 有沒有被呼叫」不同。這裡保護的是對外 HTTP 協議:method、path、header、payload 和錯誤分類都是整合測試要觀察的結果。實作可以從 requests 換成另一個 HTTP library,只要對外協議不變,測試就不應該跟著碎。

這裡仍然要和契約測試分開。整合測試驗證我們的 HTTP client、serializer 和 fake server 接起來後能不能運作;契約測試則驗證 Consumer 和 Provider 是否各自遵守同一份 API 契約,通常不需要把雙方同時啟動。

Message broker 和 outbox 要拆成三種測試

事件系統通常有三個不同的風險,不要混成一個巨大測試:

  1. Application 和 database 的整合:訂單資料和 outbox event 是否在同一交易寫入。
  2. Publisher 和 broker 的整合:outbox worker 是否能正確序列化、發布、處理 ack 和 retry。
  3. Event contract 的相容性:Producer 發出的事件是否符合 Consumer 依賴的 schema 和欄位語意。

第一類可以在每次 PR 跑,使用真實資料庫但不一定啟動 broker。第二類則可使用短生命週期的 broker container,或在較慢的 CI job 執行。

第三類屬於契約測試,不應該硬塞進每一個 database integration test。它可以用 Spring Cloud Contract、Pact 或其他 schema/contract tooling,讓 Producer 和 Consumer 分別驗證事件格式;是否真的需要導入,要看服務是否獨立演化、跨團隊協作和事件相容性風險。

outbox worker 的測試應該觀察可重試行為:

讀取 pending event
  -> 發布成功
  -> 收到 broker ack
  -> 將 event 標記 published

還要有失敗 case:

發布失敗
  -> event 仍然是 pending
  -> retry_count 增加
  -> next_attempt_at 被設定

不要在測試裡用 sleep(5) 等待背景 worker。這通常代表測試沒有掌握系統的同步點。改用可觀察的 polling with timeout、注入 clock,或直接把 worker 的一次處理動作暴露成可呼叫的 application operation。非同步系統需要等待,但等待應該有明確條件,不是任意延遲。

測試資料隔離:速度和可信度的取捨

整合測試最容易變 flaky 的地方,通常不是 assertion,而是資料互相污染。

常見做法有三種:

做法優點代價
每個測試重建 container隔離最乾淨
共用 container、每個測試清資料速度較快清理不完整會互相影響
共用 schema、每個測試 transaction rollback無法涵蓋跨 connection 或已提交的背景工作

實務上可以採混合策略:container 以 session scope 共用,migration 只跑一次;每個測試使用唯一的 tenant 或 namespace,測試後清理自己的資料;需要驗證 transaction、worker 或 connection pool 的案例,再使用獨立 schema 或獨立 database。

清理方式也要有失敗安全性。pytest fixture 的 teardown 不只是整理檔案;它還負責關閉 connection、停止 container 和釋放 port。測試在 setup 失敗時,也要能把已建立的資源清掉。[3]

另外,Docker Compose 只保證服務依照依賴順序啟動,不代表資料庫已經 ready。若用 Compose 啟動整合環境,應配置 database healthcheck,並讓 application 依賴 service_healthy;Docker 官方文件明確區分了「container 正在執行」和「服務已經可以接受請求」。[4]

整合測試最常見的五個假象

第一個,SQLite 綠燈、PostgreSQL 紅燈。測試使用不同資料庫,實際上沒有覆蓋 production dialect 和 constraint。

第二個,只測 HTTP status。201 不代表資料真的提交,也不代表 event payload 正確。至少要檢查一個真實持久化結果和一個重要協議欄位。

第三個,所有 collaborator 都被 mock。測試名稱叫 integration,實際上只測 controller 把參數傳給 service。

第四個,共用固定測試資料。單獨跑會綠,整個 suite 或平行執行就互相污染。

第五個,用任意 sleep 等非同步結果。CI 變慢時可能不夠等,CI 變快時又浪費時間;測試仍然沒有真正知道系統何時完成。

這些問題的共同點是:測試看起來有跨元件,卻沒有真的把最有風險的邊界納入驗證。

AI 可以補測試,但不能猜環境契約

AI 很適合協助整合測試的機械工作:

  • 依照 case 表產生 request payload 和 fixture。
  • 把既有 unit case 轉成 API-level scenario。
  • 產生成功、validation error、conflict、timeout 的測試骨架。
  • 根據 migration 找出可能缺少的欄位或 constraint case。

但有三件事不能讓 AI 自己猜:

  • production 使用哪個資料庫和 transaction isolation。
  • 哪些事件或 API 欄位是跨服務契約。
  • 失敗時哪些資料必須存在、哪些資料絕對不能存在。

給 AI 的 prompt 應該先提供環境契約,再要求它產生測試:

請為 POST /orders 設計 integration tests。

真實依賴:
- PostgreSQL 16
- repository 使用正式 migration 建立 schema
- orders 與 outbox_events 必須在同一 transaction

可替換依賴:
- payment provider 使用 fake HTTP server
- clock 使用固定時間
- broker 不在這組測試啟動,改驗證 outbox row

必須驗證:
- 合法 request 回傳 201 且資料庫有正確訂單
- 相同 Idempotency-Key 不會產生第二筆訂單
- 非法 request 不留下資料
- outbox event 和 order 一起提交或一起 rollback

不要驗證:
- service 內部 method 呼叫次數
- ORM 使用哪個 method
- private method 名稱

這種 prompt 的順序很重要:先說哪些整合點要用真實依賴,再說哪些可以替換,最後才要求產生測試。否則 AI 很容易把 integration test 寫成一堆 mock interaction test。

放進 CI:按風險分層,不要每次都跑全部

整合測試比 unit test 慢,反而更需要把執行層級設計清楚:

每次儲存檔案:
  unit test

每次 pull request:
  unit test
  database integration test
  API adapter integration test

合併後或 nightly:
  message broker integration test
  多服務 end-to-end test
  sandbox smoke test

本機可以先用 pytest -m unit,需要真實依賴時用 pytest -m integration。這些命令的前提是測試檔已加上對應 marker;例如整合測試檔可以在模組層級寫 pytestmark = pytest.mark.integration,unit test 則使用 pytest.mark.unit。同時要在 pytest 設定中註冊 marker,避免拼錯名稱卻沒有收到警告:

# pyproject.toml
[tool.pytest.ini_options]
markers = [
    "unit: fast tests without external services",
    "integration: tests with real or faithful external boundaries",
]

命令如下:

$ python -m pytest -m unit -q
$ python -m pytest -m integration -q

整合測試若依賴 Docker,CI runner 必須明確提供 Docker;不能只在開發者本機驗證過,就假設 pipeline 也有同樣環境。若採 Docker Compose,將 healthcheck、migration 和 startup timeout 寫進可版本控制的設定;不要靠人工先啟動資料庫。

CI gate 至少要檢查:

  • 測試是否真的使用 production 同款資料庫。
  • migration 是否從空資料庫成功執行。
  • 測試資料是否能平行執行而不互相污染。
  • 失敗時是否能指出是 schema、協議、transaction 還是環境問題。

結論

unit test 保護單一規則,整合測試保護規則穿過邊界之後仍然成立。

它不需要把整個 production 搬進 CI,也不該用 mock 把所有整合點拿掉。好的整合測試會依風險選擇依賴:資料庫 dialect、正式 migration 和 transaction 使用同款資料庫;HTTP client 則保留真實 serializer、retry 與協議,接上可控制的 fake server;昂貴、不可控的第三方服務再用 sandbox 測試補上。

AI 可以很快把 case 表翻成 fixture、request 和 assertion,但它不能替團隊決定「失敗時資料庫應該長什麼樣子」。這個判斷仍然來自業務流程、資料一致性和跨服務契約。

下一篇轉向另一種跨服務邊界測試:契約測試。它和整合測試的責任不同,用來驗證 Consumer 和 Provider 的 API 或事件約定是否相容。當服務數量增加、團隊開始獨立演化時,這種驗證可以避免每次都把所有 Consumer 和 Provider 一起啟動。

參考資料