英文版在 dev.to。
九月某天在公司的 NestJS 專案跑了一次 yarn test,結果 87 支測試檔有 71 支是紅的。
說實話會壞成這樣我一點都不意外。因為公司的工程師只有我一個,前台、後台、API 到部署都是我在顧,需求一來通常就是先把功能做出來,測試只能排在後面;再加上 CI 從來沒跑過測試,所以壞了自然也沒人知道。
但也因為只有我一個人,沒有人會幫我 review code,測試其實是我唯一的安全網。所以後來我就利用需求之間的空檔,一點一點把測試補回來,最後讓測試全部通過了,執行時間也從 335 秒降到 95 秒,然後設好了測試沒過就不能部署的關卡。這篇文章主要就是記錄一下,在人力吃緊的情況下,我是怎麼決定先做什麼、哪些乾脆不做。
API 跑在 Cloud Run,部署是用 Cloud Build,流程只有 build image 和 deploy 兩步。中間完全沒有跑測試,測試壞了也不會擋到任何東西,就這樣一路累積下來。
打開失敗訊息一看,幾乎都是同一種錯,連 assertion 都還沒跑到就掛了:
Nest can't resolve dependencies of the RentalService
(PrismaService, GoogleService, FormatHelperService, GoogleMapService, ?, UserService).
Please make sure that the argument SystemService at index [4] is available...
其中最可惜的是租屋服務的測試。那支檔案裡有 19 個測試是真的在驗證商業邏輯,以前也都會過,直到 constructor 多注入了一個 SystemService,19 個一起壞掉。但當時旁邊已經有 70 個紅燈,多一個根本不會有人注意,包括我自己。
因為能用的時間不多,我沒辦法把 71 支一支一支修好,所以第一步是先分類,看哪些值得花時間。
仔細看了一輪,71 支失敗的測試裡有 69 支是 Nest CLI 自動產生的。用 nest g service 建 service 時會順便產一支 spec,裡面只有一個 it('should be defined'),testing module 也只提供 service 本身,沒有提供它的相依。所以只要 constructor 有注入任何東西,這支測試從產生那天起就是紅的,這 69 支也確實從來沒過過。
緊接著遇到的問題就是:「這些要不要留?」我的判斷標準很簡單:刪掉這支測試,會不會少掉任何關於程式行為的資訊?should be defined 只是在確認 Nest 能不能把 class 組起來,但 app 每次啟動本來就會檢查這件事。而留著它們,就得替每個 service 維護一份 mock 清單,constructor 一改就要跟著改。對一個人的團隊來說,這種維護成本根本扛不起來,過去 69 次都沒人做就是最好的證明。
所以我先修真正有價值的 21 個測試。而租屋服務那支只要補上新相依的 mock 就好;物件 controller 那支則是因為 AuthGuard 需要 UserService 才壞掉,但我的解法是,不去補 UserService,而是直接把 guard 覆寫掉:
const module = await Test.createTestingModule({
controllers: [ListingController],
providers: [{ provide: ListingService, useValue: listingServiceMock }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
因為 guard 的行為本來就該在 auth 模組自己的測試裡驗證,而硬要每支 controller 測試都處理它的相依,就是這支測試會壞的原因。這樣改完之後,guard 之後再怎麼調整,也不會連帶把 controller 的測試弄壞,我也不用再回頭修。
剩下的 stub 裡,我保留了 listing、rental、users、webhook、auth 這五個核心模組。因為它們管的是資料跟權限,萬一 constructor 被改壞影響最大,所以這幾支的維護成本我願意付;其他 62 支就全部刪掉。也考慮過用 describe.skip 把它們跳過,但那樣只是讓畫面變綠,問題還是在,以後還得再處理一次。
最後我把資料庫連線字串和 LINE 的金鑰都清空再跑一次,測試照樣全過,代表每個測試都有確實 mock 乾淨,CI 也不用額外設定任何機密。
測試修好只是第一步。因為沒有人會幫我 review,如果沒有東西強制擋著,下次趕需求的時候,測試一定又會慢慢壞掉。所以測試全綠之後,我在 Cloud Build 最前面加上跑測試的步驟。Cloud Build 只要某一步失敗就會整個停掉,所以測試沒過就不會 build image,更不會部署。
加上去之後,staging 前三次 build 都被擋下來,而且三次原因都不一樣,在我自己電腦上也都重現不出來:
prisma generate 就好。--workerIdleMemoryLimit 讓 worker 用完就回收。放在 Cloud Build 雖然有用,但每次 build 都要重新安裝一次 Dockerfile 早就有快取的相依套件,而且失敗訊息埋在 Cloud Build 的 log 裡,每次都要點進去翻,對一個人來說蠻花時間的。所以後來我把測試搬到 GitHub Actions,測試過了才由 workflow 去觸發 Cloud Build,失敗的話直接在 commit 上就看得到紅叉。
但這樣做有個風險:測試不在 build 裡面了,build 本身就沒有保護,只能靠 workflow 確保部署上去的一定是測過的 commit。關鍵的設定大概長這樣:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
test:
strategy:
fail-fast: true
matrix:
shard: [1, 2, 3]
steps:
# checkout, setup-node, yarn install, prisma generate
- run: yarn test --ci --shard=${{ matrix.shard }}/3
deploy:
needs: test
permissions:
id-token: write
steps:
# 用 Workload Identity Federation 取得 GCP 權限
- run: gcloud builds triggers run "$TRIGGER" --sha="${{ github.sha }}" ...
幾個重點:
--sha:讓 Cloud Build 建置剛剛測過的那個 commit。因為沒帶的話,triggers run 會 build 當下分支最新的 commit,如果測試期間我又 push 了新的東西,就會部署到沒測過的版本。concurrency:同一個分支一次只跑一個流程,而 deploy job 也會等 build 跑完才結束,這樣就不會發生舊的 build 比較晚完成、把新版本蓋掉的狀況。staging 和 main 分支取得權限。另外,觸發 build 用的是 triggers run 而不是 gcloud builds submit。因為 submit 會把目前的工作目錄打包上傳,而跑完測試之後那個目錄裡滿滿都是 node_modules。
設定好之後,我故意推了一個會失敗的測試上 staging。結果其中一個 shard 失敗,deploy job 被跳過,Cloud Build 完全沒有收到這個 commit,這樣才算真的放心。
第一次計時也抓到我自己的失誤:從 push 到部署花了 18 分鐘以上,查了一下才發現 Cloud Build 裡舊的測試步驟忘了拿掉,每個 commit 都被測了兩次。拿掉之後就回到 12 分鐘左右。
| 整理前 | 整理後 | |
|---|---|---|
| 失敗的測試檔 | 87 支中 71 支 | 25 支中 0 支 |
| 測試執行時間 | 335 秒 | 95 秒 |
| 測試擋部署 | 否 | 是 |
| 在哪裡看到失敗 | 看不到 | commit 上的紅叉 |
| push 到上線 | 約 10 分鐘,沒測過 | 約 12 分鐘,測過 |
部署時間跟以前差不多,但現在每一次部署都是測過的,而且整套流程不用我盯著。對一個人顧整個系統的我來說,這比省下幾分鐘更重要。如果重來一次,我會在專案一開始就把測試關卡加上去,因為這篇整理的東西,其實都是在補當初沒做的事。