問題

各框架的「官方測試路徑」(make test 之類)常常是壞的,或者跟 CI 不一致。結果一堆環境性失敗被誤判成程式碼問題,時間都花在跟環境纏鬥。

下面按語言記下我實測踩到的坑。每條我會標來源:有官方文件佐證的附連結,只有實測的標「實測」,兩者都沒有的標「未查證」。

Django(Python/pytest/Docker)

  • host port 被別的行程佔用,測試容器起不來。單元測試把 cache mock 掉、根本不需要真的 redis,所以改用:

    docker compose run --rm --no-deps web pytest

    docker compose run 預設不建立 service 設定裡的 ports,--no-deps 不會順帶啟動相依的容器(docker compose run 文件)。實測整套測試在這個指令下全綠。

  • 需要 redis 的 integration test:REDIS_URL=redis://redis:6379 走 compose 的服務名,host port 被誰佔都不影響。如果是 host port 衝突讓 redis 起不來,在 override 檔用 ports: !reset [] 讓 redis 只在內部網路可見。注意 compose 對 list 欄位是附加合併而不是取代,所以一定要用 !reset/!override(Compose 合併規則)。實測可行。

  • CACHE_BACKEND 設成 locmem 時的陷阱:.env 裡 CACHE_BACKEND=locmem 時,Redis client 的測試會拿到 locmem 的 LOCATION('unique-snowflake')當成 redis URL,直接 ValueError。跑 redis integration test 要加 -e CACHE_BACKEND=redis。(從設定檔讀出來的結論,未另外查證)

  • MySQL 測試庫要授權:Django 建測試庫時,用的就是 DATABASES 裡設定的那個帳號,所以帳號要有建庫的權限;測試庫名預設是在 NAME 前面加 test_(Django 測試文件)。沒授權的話 @django_db 一建庫就 Access denied。

    我原本的筆記寫的是 GRANT ALL ON test_*.*,回頭查 MySQL 文件發現這個寫法不對:資料庫層級的 GRANT 認的萬用字元是 % 和 _,不是 *;而且 _ 本身就是萬用字元,要當字面值得寫成 \_。另外,這種萬用字元用法從 MySQL 8.0.35 起已標為 deprecated(MySQL GRANT 文件)。所以比較穩的寫法是:

    -- 只授權給測試庫;'app' 換成你的應用帳號
    GRANT ALL ON `test\_%`.* TO 'app'@'%';
    -- 或者不用萬用字元,直接點名 Django 會建的那一個測試庫
    GRANT ALL ON `test_myapp`.* TO 'app'@'%';

    (未查證:修正後的語法我沒有在原環境重跑過)

.NET(.NET 8/xUnit/Docker)

  • web 容器是 target: production(沒有 SDK),所以走 web 容器的 make test/make lint(dotnet test、dotnet format)全部跑不動——明明 Dockerfile 裡就有 development stage。修法是 compose 的 web 改成 target: development。(實測)

  • development target 沒有掛 source volume:程式碼是 build 時 COPY 進 image 的,改了 code 根本進不了容器。症狀超騙人:改了測試,跑出來還是舊結果,stack trace 指向舊的行號。加上

    volumes:
      - .:/app
      - /app/bin
      - /app/obj
      - /app/tests/bin
      - /app/tests/obj

    用匿名 volume 蓋掉 bin/obj,避免容器裡產生的檔案(root 擁有)污染 host。加完之後整套測試全綠。(實測)

  • pre-commit 會先把未 stage 的改動 stash 起來再跑 hook。官方原話是 pre-commit「只對 staged 內容執行,執行 hook 期間會暫時 stash 未 staged 的改動」(pre-commit.com)。所以只 commit 一個檔時,其他還沒 stage 的修正會被暫時收走,hook 檢查的樹又變回舊狀態。多檔修正要一起 stage 進同一個 commit,讓 commit 出去的樹一次就是綠的(例如測試修正和 lint 修正放同一個 commit)。

  • dotnet format --verify-no-changes 被命名規則擋下:private static readonly 欄位用 PascalCase、缺 _ 前綴,觸發 IDE1006(Naming rule violation),format 也不會自動幫你改名,只能手動 rename 成 _camelCase。

    文件能佐證的部分:--verify-no-changes 在有檔案會被改動時以非零 exit code 結束(dotnet format 文件);IDE1006 是所有命名規則共用的 rule ID,要在 build 時強制需要把嚴重度設成 warning 或 error(.NET 命名規則文件)。「_ 前綴」是該 repo .editorconfig 的慣例,不是 .NET 預設;exit code 剛好是 2 則是實測觀察,文件只寫「非零」。

通用:SSE 要排除在壓縮和 request timeout 之外

我在三個語言的服務上都遇到同一件事(實測):壓縮會把 text/event-stream 緩衝住、事件不即時送到前端;request timeout 會把 SSE 長連線砍斷。

  • Django:GZip middleware 寫個子類,response.streaming 為真就跳過。(實測;Django 文件沒有寫到串流回應的壓縮行為,未查證)
  • NestJS:compression 的 filter 濾掉 text/event-stream,timeout interceptor 排除 /sse 路徑。Node 的 compression 套件 README 自己就寫了它「無法直接搭配 server-sent events」,因為壓縮需要先緩衝一段輸出(expressjs/compression)。
  • .NET:內建 ResponseCompression 的預設 MIME 清單(application/javascript、application/json、application/xml、text/css、text/html、text/json、text/plain、text/xml)本來就不含 text/event-stream(ASP.NET Core Response compression);自製的 timeout middleware 則用 ExcludedPaths 排除 SSE 路徑。

為什麼這樣解

這些坑的共同根源是:dev 環境的 compose/設定本身有 regression,或者跟 CI 不一致——跟「文件沒跟上程式碼」是同一種病。先花時間把環境弄對,比在錯的環境上瞎猜快得多。

怎麼預防再次發生

  • 接手一個框架的第一件事:確認官方的 make test 真的能跑。不能跑就先修環境,多半是 compose 的 target、volume 或 .env。
  • 同一個環境問題試了兩種方法都沒進展,就停下來換個角度(查文件、縮小重現範圍、找人問),不要硬鑽。我在 .NET 環境上就是纏鬥太久才回頭,那段時間幾乎都是白費的。
  • 修好的環境 regression(例如 compose target)值得 commit 回 repo,但要在 commit 訊息講清楚「順手改了共用設定」。