iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Vibe Coding

《我與 AI 的奇幻漂流:30 天,把「能跑」變成「能上線」》系列 第 25 篇

【Day 25|每日作息】CI/CD 與自動化測試:怎麼知道我沒有改壞東西

  • 分享至 

  • xImage
  •  

到昨天為止,每請 AI 做完一個功能,它有時會自己跑一次 lint、build,甚至打開幾個頁面點點看。

問題是,這些檢查通常只針對它剛改的地方,不一定會把整個網站的重要流程都重新走一遍,也不一定抓得到藏在其他地方的 bug。而且 AI 這次有檢查,下次不一定記得。也有人會選擇每改一次,就自己手動測一輪。但功能一多,要測的項目越來越多,越測越累,藏在角落的問題還是可能漏掉。

不管是 AI 檢查,還是自己手動測,都有一個共同的問題:要有人記得做,而且每次做的不一定一樣。

今天要把這些檢查,變成每次修改都會自動發生、而且每次都一樣的作息。


一、什麼是 CI/CD?

CI/CD 是兩個縮寫,講的是程式碼從「寫好」到「上線」中間的兩段路:

全名 意思
CI Continuous Integration,持續整合 每次要把修改放進主線之前,先自動檢查、測試,確認沒有壞掉
CD Continuous Deployment,持續部署 通過檢查之後,自動送上線

「整合」指的是把修改放回主線(main)。不管是多人協作,還是我和 AI 一天改好幾次,每次放回去之前都先檢查一遍,問題就會在剛發生的時候被抓到,而不是累積到上線才爆開。

如果沒有 CI/CD,流程大概是這樣:

寫完 → 自己想起來才測 → 手動上傳到主機 → 上線後才發現壞了

有了 CI/CD:

寫完 → 推上去 → 自動檢查、測試 → 通過才自動上線

差別不只是省事,而是每一次都會經過同樣的關卡,不管那天記不記得、忙不忙。

CD 有時也指 Continuous Delivery(持續交付):程式隨時保持可以上線的狀態,但最後一步由人按下去。這篇講的是自動部署的那一種。


二、AI 已經測過了,為什麼還要 CI?

現在的 AI 很會檢查:跑 lint、跑 build,甚至自己開瀏覽器點一輪。那還需要什麼?

AI 幫我檢查 CI(持續整合)
什麼時候跑 我或 AI 想到的時候 開 PR、更新 PR、合併到 main 時,自動跑
在哪裡跑 我的電腦,有快取、有 .env GitHub 提供的虛擬機,每次都從新的環境開始
每次檢查什麼 當下決定,每次可能不一樣 寫好的步驟,每次都一樣
怎麼判斷對錯 AI 看了覺得沒問題 寫好的條件,不符合就失敗
失敗會怎樣 AI 告訴我 留下明確的失敗紀錄;搭配門禁,才能擋住上線(5.5)

AI 可以幫我做檢查;CI 是確保這些檢查一定會發生。

Vibe Coding 還有兩個特別要小心的地方:AI 常常只檢查它改的那一塊;測試失敗時,AI 也可能改的是測試,而不是程式。反過來也成立:我實際操作時,E2E 第一次就失敗了兩次,但網站其實沒壞,是測試寫錯了畫面元素的名稱。所以測試失敗時,第一件事是判斷:錯的是程式,還是測試?測試本身,也要自己看過。

兩者其實可以接起來:讓 AI 開瀏覽器走一遍、找出問題;修好之後,請它把這個流程寫成測試,放進 CI。AI 的探索只有一次,寫成測試之後,就變成每天的作息。


三、CI 和 CD,誰負責哪一段?

https://ithelp.ithome.com.tw/upload/images/20261009/201780172Rdd3ALfb3.png
【圖 1|從開分支到上線】

  • CI 這次交給 GitHub Actions。
  • CD 在 Day 23 已經交給 Vercel 了。
    (可以自行調整,預設是分支部署到 Preview 環境;main 部署到 Production 環境)

https://ithelp.ithome.com.tw/upload/images/20261009/20178017LOjBf9kLFS.png
【圖 2|Vercel 部署列表:分支是 Preview,main 是 Production】

GitHub Actions 不是在我的電腦上跑。GitHub 有提供虛擬機:每次推程式碼,GitHub 都會臨時開一台新的,下載程式碼、安裝套件、跑完所有檢查,然後整台刪掉。我的電腦關著也沒關係;而且因為每次都從零開始,「在我電腦上可以跑,但其他電腦會出問題」這種問題,在這裡就很容易現形。


四、要檢查什麼?

4.1 測試的種類

測試可以照「一次測多大的範圍」來分。用這個系列一直在用的餐廳來比喻:

種類 測什麼 餐廳的比喻
單元測試 一小段邏輯的輸入輸出,前端、後端都可以有 在廚房單獨試一口醬汁
API、資料庫測試 後端的一個入口,或一條權限規則 檢查點餐單送進廚房有沒有照規定處理;沒權限的人進不進得了倉庫
E2E(End-to-End,端到端)測試 從瀏覽器出發,經過前端、伺服器、資料庫,再回到畫面,整條路走一遍 假扮客人,從進門、點餐、上菜到結帳,整個走一遍

越往下,越接近真實狀況,也抓得到「每一段單獨看都沒問題,接起來卻壞了」的問題;但也越慢,中間任何一段不穩,測試就可能失敗。所以單元測試可以多寫,E2E 只挑最重要的幾條流程。

另外,程式碼檢查、型別檢查、打包,嚴格來說不算「測試」,但它們最便宜、最快,一樣放進每次的檢查裡。

4.2 Biashelf 預計檢查什麼

檢查 抓什麼 Biashelf 的例子
程式碼檢查 寫法不規範、明顯的錯誤 用了沒宣告的變數
型別檢查 資料的形狀對不上 把字串傳給要數字的地方
打包 能不能成功建置 引入了不存在的檔案
單元測試 一小段邏輯 最近瀏覽:去除重複、最新的在前、最多 10 筆
權限測試 該擋的有沒有擋住 沒登入的人,讀不到任何錯誤回報(Day 19、21 的 RLS)
E2E 測試 使用者的完整操作 從首頁進到搜尋 → 搜尋 → 打開專輯;直接打開專輯網址

權限測試特別值得放進來:它守的是 Day 19、21 花最多力氣做的東西。一個 migration 改錯,就可能讓別人看到不該看的資料,而畫面上完全看不出來。這次只做「讀」的檢查,因為只讀不會改到正式資料。

權限測試不是另一種測試,而是測試的目的;這裡用的是 API 測試的方式,直接用不同身分去存取資料。

4.3 工具怎麼選

類型 常見工具 這次選 理由
程式碼檢查 ESLint、Biome、Oxlint ESLint Next.js 官方支援;next lint 在 Next.js 15 被標示淘汰、16 已移除,改成直接執行 ESLint。這個專案原本沒有完整的設定,所以這次補上
型別檢查 TypeScript TypeScript 專案本來就用
單元測試 Vitest、Jest、Node 內建的測試工具 Vitest 速度快、設定少,寫法跟 Jest 幾乎一樣
API 測試 Vitest+fetch、Supertest、Postman/Newman Vitest 權限測試直接用 Vitest 寫,不用另外加工具
資料庫測試 pgTAP(Supabase 內建支援) 這次不做 需要測試資料庫
E2E 測試 Playwright、Cypress、Selenium Playwright 能開多種瀏覽器、會自動等畫面載入、官方有提供 GitHub Actions 的使用方式和範例
跑版比對 Playwright 截圖、Chromatic、Percy、Applitools 這次不做 基準圖要維護
CI 平台 GitHub Actions、GitLab CI、CircleCI、Bitbucket Pipelines GitHub Actions 程式碼本來就在 GitHub,私人 repo 也有免費額度

4.4 還可以測什麼(這篇文章不做)

想測的東西 可以怎麼做 這次為什麼不做
登入後的功能(網站有帳號密碼登入) 開一個權限最小的測試帳號,帳密放在 GitHub Secrets;測試前登入一次,把登入狀態存起來給每個測試用(這個檔案不能 commit) Biashelf 沒有密碼登入
登入後的功能(只有驗證碼、Google 登入) 在測試資料庫裡建立測試帳號和登入狀態 需要測試資料庫;不要為了測試多開密碼登入,或做「輸入 999999 就能登入」這種後門
會寫入資料的流程 在測完就丟的測試資料庫裡寫,例如在 CI 裡跑本機版 Supabase 目前開發和正式共用同一個資料庫(Day 23)
畫面跑版 Playwright 截圖,跟「基準圖」逐像素比對,失敗時產生用紅色標出差異的比較圖;可以設定容許的差異比例,把時間這類會變動的區塊遮掉 基準圖要維護;Mac 和 CI 的 Linux 字型渲染不同,基準圖要在 CI 裡產生
手機版 同一條 E2E,換成手機尺寸再跑一次 可以很快加上,這次先控制篇幅
第三方服務 Google 登入、寄信,用手動驗收確認串接(Day 18、24 都做過) 對方的服務穩不穩,不該決定我的 CI 會不會過

不管哪一種,能登入之後,會寫入資料的測試,建議還是需要一個獨立的測試資料庫。這也是 Day 23 說「先知道風險」的那件事,今天正式碰到了。

測試的種類還很多:資安掃描、上線後的巡檢、效能檢查,有機會會在後續的文章出現;無障礙、跨瀏覽器、壓力測試,則等網站規模更大再說。重點不是一次做齊,而是知道有哪些門,再依照風險決定先守哪一扇。


五、實作

5.0 前置設定

大部分只要設定一次,之後推程式碼,CI 就會自動跑:

在哪裡 設定什麼 誰來做
專案 固定 Node.js 版本(.nvmrc、package.json 的 engines),跟 Vercel 一致 交給 AI
專案 補上 ESLint 設定;安裝 Vitest、Playwright;加上 typecheck、test、test:permissions、test:e2e 指令;把測試報告資料夾加進 .gitignore 交給 AI
專案 新增 .github/workflows/ci.yml 交給 AI
GitHub 後台 Settings → Secrets and variables → Actions:NEXT_PUBLIC_SUPABASE_URL 放 Variables,NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY 放 Secrets 自己
Vercel 後台 設定 Deployment Checks(5.5) 自己
工作方式 改成開分支 → 推上去 → 開 PR → 合併到 main 自己

先將測試過程中可能會用到的變數加入 Github Actions 中:
https://ithelp.ithome.com.tw/upload/images/20261009/20178017bbW59pcKcJ.png
【圖 3|GitHub 的 Settings → Secrets and variables → Actions】

(因為我有測資料庫相關的,所以添加資料庫 URL 跟 publishable key)

5.1 單元測試:Vitest

最適合當第一個測試的,是 Day 22 的最近瀏覽紀錄:不需要資料庫,做了什麼、應該得到什麼,都很明確,例如:

// tests/unit/local-history.test.ts
// @vitest-environment jsdom
test("重複瀏覽會移到最前面,而且不會重複", () => {
  recordRecentlyViewed("album-a");
  recordRecentlyViewed("album-b");
  recordRecentlyViewed("album-c");
  recordRecentlyViewed("album-b");

  expect(readRecentlyViewed()).toEqual(["album-b", "album-c", "album-a"]);
});

最近瀏覽是存在瀏覽器的 localStorage 裡,所以第一行的 jsdom 是請 Vitest 模擬一個瀏覽器環境。實際一共寫了 6 個案例:新的在最前面、重複的移到最前面、不會重複、最多 10 筆、不合法的 ID 不寫入、localStorage 被塞了壞掉的資料也不會出錯。

5.2 權限測試:沒登入的人,讀不到回報

用 Vitest 加上 Supabase 的 publishable key,模擬一個「沒登入的訪客」,直接去讀錯誤回報的資料表:

// tests/integration/data-reports-permissions.test.ts
test("沒登入的人,讀不到任何錯誤回報", async () => {
  const { error } = await anon.from("data_reports").select("id").limit(1);

  if (!error) throw new Error("Anonymous read unexpectedly succeeded");
  expect(error.code).toBe("42501"); // 沒有權限
});

「讀不到」有兩種:RLS 把資料藏起來(查詢成功但 0 筆),或根本沒有讀取權限(查詢直接失敗)。Biashelf 是後者,Day 21 沒有開放訪客讀這張表,所以預期拿到權限不足的錯誤碼 42501;寫死其中一種,權限被改動時才分得出來。
測試也刻意不印出查到的資料,萬一權限真的被打開,回報內容也不會印進 CI 紀錄。它只讀不寫,所以目前先在正式資料庫上跑,代價是正式的 Supabase 暫時連不上時,CI 也會變紅;長期還是應該有獨立的測試環境。

5.3 E2E 測試:Playwright

Playwright 會啟動真正的瀏覽器,預設在背景、沒有畫面的模式(headless)執行,照著腳本點擊、輸入,再檢查畫面上該出現的東西有沒有出現。這篇先挑兩條不需要登入、也不會寫入資料的流程為例:

  1. 首頁 → 點「搜尋專輯配置」→ 輸入 aespa 按 Enter → 網址變成 ?q=aespa → 點開《Armageddon》→ 專輯詳情的抽屜打開
  2. 直接打開 /albums/aespa-armageddon → 正常顯示專輯名稱和版本列表
// tests/e2e/catalog.spec.ts
test("searches from the home page and opens the album drawer", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("link", { name: "搜尋專輯配置" }).click();

  await page.getByRole("textbox", { name: "搜尋專輯" }).fill("aespa");
  await page.getByRole("textbox", { name: "搜尋專輯" }).press("Enter");
  await expect(page).toHaveURL(/\/albums\?q=aespa$/);

  await page.getByRole("link", { name: /查看 .*《Armageddon》完整專輯配置/ }).click();
  const drawer = page.getByRole("dialog", { name: "專輯配置詳情" });
  await expect(drawer.getByRole("heading", { name: "Armageddon", level: 1 })).toBeVisible();
});

找元素時用的是「角色加名稱」,例如「名字叫搜尋專輯的輸入框」,跟使用者看畫面的方式比較接近,改樣式也不容易壞。網站不用自己先開:Playwright 的設定檔寫好啟動指令,它會自己把網站開起來、測完再關掉;在 CI 裡就啟動前一步 build 好的版本,跑在虛擬機自己的 localhost 上。想看它實際怎麼點,可以在本機用 npx playwright test --ui 一步步看畫面。

5.4 把檢查串起來:GitHub Actions

在 .github/workflows/ 放一份設定檔,告訴 GitHub:開 PR 和合併到 main 時,就依序做這些事。

# .github/workflows/ci.yml(節錄)
name: CI
on:
  pull_request:      # 開 PR、更新 PR 時
  push:
    branches: [main] # 合併到 main 時
permissions:
  contents: read   # 讀取程式碼
  actions: read    # 讀取這次執行的結果(回報給 Vercel 用)
  statuses: write  # 把結果回報成 commit 狀態(5.5)
jobs:
  check:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    env:
      NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL }}
      NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test                 # 單元測試(vitest run)
      - run: npm run test:auth        # 既有的登入相關測試
      - run: npm run test:permissions # 權限測試
      - run: npm run build
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e
      - name: Notify Vercel           # 5.5
        if: always()
        uses: vercel/repository-dispatch/actions/status@v1
        with:
          name: "Vercel - biashelf: ci"

開 PR 或更新 PR 時檢查一次;合併到 main 後再檢查一次,結果交給 Vercel(main 可能混進了別的修改,而且 Vercel 看的是 main 上那個 commit)。permissions 只開這次真正需要的三項,Node 版本則跟本機、Vercel 統一成 24。這次用不到 secret key,就不提供。

備註:AI 最開始的寫法是 push 和 pull_request 都會觸發,同一個 commit 跑了兩次,後來才改成現在的寫法(只有 PR 才觸發)。publishable key 一開始放在 Variables,結果值會直接印在執行紀錄裡,最後改放 Secrets。能公開,不代表適合到處出現。

https://ithelp.ithome.com.tw/upload/images/20261009/20178017HvWQf5ZOGW.png
【圖 4|PR 頁面:同一個 commit 跑了 pull_request 和 push 兩次 CI】

https://ithelp.ithome.com.tw/upload/images/20261009/20178017Q1UNOhcGwt.png
【圖 5|GitHub Actions 執行列表:一筆是 PR,一筆是合併到 main】

以上 5.1 ~ 5.4 都可以透過 AI 輔助,可以詢問他建議要測什麼,或是說你自己測試過哪邊比較容易出錯,跟他說明,交給他產生相關的測試程式碼。但也要記得有時候他寫的測試流程如果測試失敗,不一定代表程式碼有錯,也有可能是測試的內容寫錯。

5.5 測試不是提醒,是門禁

只有 CI 還不夠。如果 CI 紅了還能照樣合併、上線,它就只是提醒。最理想的門禁在合併之前,但我目前的 GitHub 免費方案私人 repo,沒辦法把檢查設成合併的強制門檻。所以這次退一步,把硬門檻放在 Vercel 的 Deployment Checks:合併到 main 後,Vercel 一樣會建立正式版,但要等指定的檢查通過,才換到正式網址。擋不住合併,就擋住上線。

https://ithelp.ithome.com.tw/upload/images/20261009/20178017JVEGlmP5ow.png
【圖 6|Vercel 的 Add checks:選擇 Import from GitHub】

這次設定時,Vercel 沒有列出我要的檢查,所以我先在 workflow 最後加上一步,主動把結果回報給 Vercel(5.4 的 Notify Vercel)。這一步一開始還因為權限不足失敗了兩次,補上權限、成功回報之後,清單裡還是沒有自動出現;最後是在 Import from GitHub 裡輸入那次 commit 的完整 SHA,才找到並加入這個檢查。

回報那一步要加上 if: always(),不然測試一失敗,這一步就被跳過,Vercel 永遠收不到「失敗」。依照 Vercel 的設計,必要的檢查沒通過時,這個版本會停在 Deployment Checks,使用者看到的還是上一個版本。

https://ithelp.ithome.com.tw/upload/images/20261009/20178017bpcNgEZAOv.png
【圖 7|Vercel 正式版部署:必要檢查通過後,才進到 Assigning Custom Domains】

我用的是 Vercel 的 Pro 方案;免費的 Hobby 方案能不能使用 Deployment Checks,需要以 Vercel 的官方說明為準。理想的架構不一定要現在花錢做到,但要知道門在哪裡、目前擋得住什麼。

之後改 workflow,要重新設定 Vercel 嗎?

不用。Vercel 認的是回報的名稱(Vercel - biashelf: ci),不是 workflow 的內容;增減測試、改觸發條件、改 Node 版本都不影響。只有改了這個名稱,才要回 Vercel 新增檢查,而且要先加新的、確認可以用,再刪舊的,中間才不會沒有門禁。
一個專案也可以有好幾份 workflow,但各自要用不同的名稱回報,不然結果會互相覆蓋。

5.6 實作時踩到的其他坑

坑 怎麼解決
E2E 失敗,但網站沒壞:測試裡寫的元素名稱跟畫面不一樣 看 Playwright 失敗時產生的報告,裡面列出畫面上實際的元素名稱
權限測試讀不到環境變數 Vitest 不會自動讀 .env.local,要在設定檔裡載入
npm run lint 跳出互動選單,放進 CI 會卡住 next lint 已被淘汰,改用 ESLint 並補上設定檔
第一次加 lint,冒出 44 個問題 先修錯誤,警告留著之後處理。CI 一上線,過去欠的債都會被翻出來
本機、CI、Vercel 的 Node 版本不一樣 全部固定成同一個版本
Vercel 找不到 GitHub Actions 的檢查 在 workflow 主動回報,並加上 if: always()
回報給 Vercel 時出現 Resource not accessible by integration 權限不夠,依錯誤訊息補上 actions: read、statuses: write
publishable key 出現在執行紀錄和截圖裡 改放 Secrets;舊紀錄要自己處理
同一個 commit 跑了兩次 CI push 和 pull_request 同時觸發;改成 PR 時跑,push 只限 main

還有一個插曲:build 跑到一半時,Codex 一度判斷「Node 24 跟 Next.js 不相容,要把 Vercel 降回 22」。持續追下去才發現,只是工具先回傳了結果,build 其實還在背景跑,最後是成功的。AI 說它測過了要看證據,AI 下的結論也一樣。


驗收

測試 預期結果
開 PR,看 GitHub Actions lint、型別、單元測試、權限測試、build、E2E 全部通過
權限測試 訪客讀取錯誤回報,拿到 42501 權限不足(不在正式資料庫故意打開權限來測試)
合併到 main,看 Vercel 正式版 Vercel - biashelf: ci 通過之後,才換到正式網址
在分支上故意拿掉最近瀏覽的「去除重複」 單元測試失敗,CI 變紅
在分支上故意改錯 E2E 要找的元素名稱 E2E 失敗,CI 變紅

換個領域

產品 最常用 E2E 守住的流程
電商 加入購物車 → 結帳
SaaS 註冊 → 付款 → 開通功能
線上課程 報名 → 進入課程頁

每個產品都有幾條「一斷就直接損失」的路,E2E 就是用來守住它們的。


結語與明日預告

今天的新觀念:

觀念 一句話
CI 每次要放進主線前,自動確認沒有壞掉
CD 通過之後,自動送上線
單元測試 測一小段邏輯的輸入輸出
E2E 測試 用真的瀏覽器,走一遍使用者的操作
門禁 沒通過就不准合併,或不准上線

今天沒有新增任何功能,而是把前面做過的功能(Day 10 的專輯網址、Day 11 的搜尋、Day 19 和 21 的權限、Day 22 的最近瀏覽),變成「不能偷偷壞掉」的規則。 AI 讓寫程式變快了,但「改了這裡、壞了那裡」的問題仍然存在。CI 不是不相信 AI,而是不靠任何人記得:每次都檢查,每次都一樣。

不過,測試全綠,只代表功能照預期在跑,不代表網站是安全的,許多攻擊者就像鯊魚一樣。而海裡的鯊魚,不會因為你的船漂亮就游過來,也不會因為你的網站很小就放過你。牠們只是一直在附近繞,等著聞到血。網路上的攻擊也一樣,大多不是衝著誰來,而是用程式不停地掃,哪裡有已知的漏洞、外洩的金鑰、沒擋好的輸入,就往哪裡咬。

明天,我們來看看網站上有哪些地方,最容易滲出血腥味。

我們明天見。


上一篇
【Day 24|漂流瓶】Resend、Zoho 與 SMTP:如何用自己的網域收信、寄信?
下一篇
【Day 26|鯊魚】資安與 AI 安全審查:功能正常,為什麼可能會不安全?
系列文
《我與 AI 的奇幻漂流:30 天,把「能跑」變成「能上線」》 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言