iT邦幫忙

0

一個人顧整個後端,我怎麼抓空把壞掉的測試救回來

  • 分享至 

  • xImage
  •  

英文版在 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 都被擋下來,而且三次原因都不一樣,在我自己電腦上也都重現不出來:

  • CI 環境沒有先產生 Prisma client,所有 model 的型別都找不到。補上 prisma generate 就好。
  • Jest 在對 Prisma client 做型別檢查時記憶體爆掉。調高 Node 的 heap 上限,再加上 --workerIdleMemoryLimit 讓 worker 用完就回收。
  • 有個模組在 import 時就拿金鑰建立 SDK client,測試還沒開始就先掛了。在 CI 給它測試用的假值解決。

把測試搬到 GitHub Actions

放在 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 比較晚完成、把新版本蓋掉的狀況。
  • Workload Identity Federation:GitHub 上不用存任何 service account 金鑰,也就少了一把要管理、外洩還得處理的金鑰。而且只允許這個 repo 的 staging 和 main 分支取得權限。
  • 拆成 3 個 shard:GitHub 的 runner 比 Cloud Build 慢不少,只用一個 job 跑的話,部署反而會比以前還慢,所以拆成 3 份同時跑。

另外,觸發 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 分鐘,測過

部署時間跟以前差不多,但現在每一次部署都是測過的,而且整套流程不用我盯著。對一個人顧整個系統的我來說,這比省下幾分鐘更重要。如果重來一次,我會在專案一開始就把測試關卡加上去,因為這篇整理的東西,其實都是在補當初沒做的事。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言