</> 技術筆記Tech Notes

我把開發環境塞進 Docker,才敢讓 AI 自己跑測試

同一個 make test-all,在開發者電腦、AI Agent 與 CI 都走相同的 Docker 測試環境

我剛開始讓 AI Agent 幫忙改程式時,最常看到的不是 bug,而是一長串解釋。

測試失敗了,它會猜可能是 PostgreSQL 版本不同、Redis 有舊資料、環境變數沒設,或某個服務還沒起來。這些猜測有時候是對的,但它沒有辦法靠同一組指令把現場重建一次,只能繼續猜。

人碰到這種環境已經很煩,Agent 更糟。它會把環境問題當程式問題,然後很勤奮地改壞原本沒錯的程式碼。

我後來花時間把開發與測試環境放進 Docker Compose。目的不是追流行,也不是覺得所有東西都該容器化。我只想給人、Agent 和 CI 一個共同的重來按鈕。

我真正想消掉的是隱藏狀態

「在我電腦上會過」背後通常不是玄學,而是有東西沒寫進專案:

  • 某人的 PostgreSQL 是另一個版本。

  • Redis 留著昨天測試的 key。

  • .env 多了一個沒人記得的設定。

  • Migration 跑到一半,資料表狀態跟別人不同。

  • 測試順序剛好避開了殘留資料。

Docker Compose 沒有自動解決這些問題,但它逼我把資料庫版本、服務相依、環境變數和健康檢查寫下來。至少出錯時,大家查的是同一份設定。

我常用的重置流程大致如下:

docker compose -f compose.test.yml down -v
docker compose -f compose.test.yml up -d --wait
docker compose run --rm db-seeder
docker compose run --rm test-runner dotnet test

這裡最重要的是第一行。它把測試 volume 一起移除,確保下一輪不是沿用上次的資料。也因為如此,這份 compose 必須是測試專用,不能指到要長期保存的資料。

Agent 需要的不是完整說明書,是穩定入口

我一開始把啟動資料庫、跑 migration、匯入 seed、跑測試的步驟全部寫進說明文件。人看得懂,Agent 每次執行還是可能少一步。

後來我把入口縮成 Makefile:

test-unit:
	docker compose run --rm app dotnet test tests/Unit

test-e2e:
	docker compose -f compose.e2e.yml up -d --wait
	docker compose run --rm e2e-runner dotnet test tests/E2E
	docker compose -f compose.e2e.yml down -v

test-all: test-unit test-e2e

Agent 只需要知道 make test-all。底下換資料庫版本或多一個服務時,我改 Makefile 和 compose,不必期待每個 Agent Session 都重新讀懂一份十幾段的操作手冊。

AI Agent 修改程式後只呼叫 make test-all,再依測試輸出決定修正或停止

這個入口也讓 CI 用同一條路。差別只剩誰下指令,不是每個執行者各有一套環境。

單元測試其實不需要真的資料庫

把環境容器化後,我也重新整理了測試邊界。

單元測試要測的是 OrderService 的規則,就不該每次啟動 PostgreSQL。外部相依透過介面注入,測試換成 stub 或 mock:

public sealed class OrderService
{
    private readonly IOrderRepository _orders;
    private readonly ICacheService _cache;

    public OrderService(IOrderRepository orders, ICacheService cache)
    {
        _orders = orders;
        _cache = cache;
    }
}

需要確認 SQL、migration 和交易行為時,再跑使用真實 PostgreSQL 容器的整合測試。E2E 則把 API、資料庫、Redis、RabbitMQ 和瀏覽器測試一起啟動。

Docker 的價值不是讓每一條測試都變重,而是需要真實相依時,我可以用固定方式拿到它。

卡最久的是「服務起來了,但還不能用」

有次 E2E 在本機偶爾過、CI 常常失敗。畫面上看到的是 API 連資料庫失敗,重跑一次又可能正常。

原因是 Compose 只保證容器程序啟動,不代表資料庫已經接受連線。API 比 PostgreSQL 更早開始,第一次 migration 就撞牆。

我補了健康檢查和相依條件:

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 2s
      timeout: 3s
      retries: 20

  api:
    build: .
    depends_on:
      db:
        condition: service_healthy

這段在做的事很單純:PostgreSQL 回報可接受連線後,API 才啟動。--wait 也要搭配健康檢查才有意義,不是命令列多一個參數就會自動知道服務是否可用。

另一個問題是 teardown 沒有在測試失敗時執行。Makefile 中間一行回傳非零,最後的 down -v 就被跳過,下一輪吃到髒資料。後來我把啟動、測試與清理包進 CI 的 postfinally,確保失敗也會收尾。

Docker 只能縮小環境問題,不能消滅它

我不會把容器當成確定性的保證。映像如果只寫 latest,今天和下週拉到的內容可能不同;基底映像更新、CPU 架構、網路和 Docker Engine 版本也可能造成差異。

要讓測試訊號比較可信,我還會做這些事:

  • 映像釘住明確版本,重要時連 digest 一起固定。

  • Seed 可以重跑,不能依賴某個人的資料庫備份。

  • 測試不連正式服務,外部 API 用測試替身。

  • 每次測試輸出容器 Log,失敗後保留診斷資料。

  • 本機和 CI 使用同一個 compose 檔及同一個入口。

做到這裡,測試失敗仍可能是環境問題,但範圍已經小很多,而且有設定、Log 和重現步驟可以查。

我願意讓 AI Agent 自己跑「修改、測試、讀結果、再修正」的迴圈,不是因為它突然懂了整個系統,而是因為我把可做的動作縮成幾條明確指令,也把失敗變成比較可靠的訊號。

這些基礎工作不酷,花的時間也比 Demo 多。可是沒有共同的重來按鈕,Agent 跑得越勤快,我反而越不放心。