
「有寫單元測試」常被當成「有測試」,但單元測試只是其中一層。一個網站從寫程式到上線,中間可以有好幾道關卡,每道擋下的 bug 不一樣。
這篇把常見的測試種類整理一次:各自測什麼、什麼時候跑、誰負責、怎麼寫;接著是好測試的寫法和 mock 的用法、Vue、React、Next.js 的工具搭配,最後是設定檔、接上 CI、覆蓋率和常見的坑。文章裡有四個互動圖,可以直接點。程式碼都是示範用的,終端機輸出是簡化過的,不是實際執行的紀錄。
為什麼要寫測試
測試做的事很單純:把「這段程式應該怎麼運作」寫成可以自動執行的檢查。好處大多出現在之後改程式的時候。
- 改 A 不會壞 B:改了購物車排版,按一個指令就知道結帳有沒有跟著壞。這件事叫回歸測試,後面會再講。
- 越早發現越便宜:寫程式當下被單元測試擋下,改一行就好;漏到正式站,要查 log、發公告、處理退款。
- 敢重構:沒有測試的舊程式沒人敢動,只能一直往上疊。
- 測試就是文件:
it('無效折扣碼不打折')比註解更不會過期,因為過期了測試會紅。 - 逼你把程式拆乾淨:一段很難測的程式,通常是一個函式做了太多事,或直接把資料庫、時間、網路寫死在裡面。為了好測而拆開,程式本身也會變好讀。
- 敢常常上線:每次上線都要人工點一輪的團隊,自然會把上線集中成一個月一次,一次塞一堆改動,出事也很難找。測試夠的話,小改動可以隨時上,出問題也只要看最近那一小段。
測試不能證明程式沒有 bug。它只能確認程式照你寫下的預期在跑。如果預期本身就錯了,例如需求理解錯,測試會跟著一起錯,這種只有人能抓。下面的上線關卡圖裡有一個例子。
測試有哪些種類
照測試範圍從小到大排:
- 單元測試(Unit):只測一個函式、一個 composable 或 hook、一個 store。不開瀏覽器、不連網路,一次幾毫秒。
- 整合測試(Integration):把幾個零件接起來測,例如 API 加資料庫、元件加狀態管理、前端呼叫後端的格式。單元測試看不到的「接起來才出事」問題在這層抓。前端的元件測試通常也歸在這層。
- 系統測試(System Test):把整個系統當成一個整體,在接近正式的環境裡,對照規格測功能、資料流、安全和效能。常見的 SIT(System Integration Testing,系統整合測試)會把第三方服務一起接上,例如金流。通常是 QA 主導。
- 端對端測試(E2E):開真的瀏覽器,從使用者角度跑完整流程,例如註冊、加入購物車、結帳。橫跨前端、後端和資料庫,最接近真實,但最慢、最容易因為改版壞掉。
- 驗收測試(Acceptance/UAT):由客戶、PM 或最終使用者確認系統真的符合需求,是上線前最後一關。
另外兩個名詞講的是「什麼時候跑」,不是新的一層:
- 回歸測試(Regression):改程式、修 bug、加功能之後,把既有的測試全部重跑一次,確認舊功能沒被改壞。它通常不是另外寫的程式,就是在 CI 上把單元、整合、E2E 再跑一遍。
- 煙霧測試(Smoke):新版本剛部署到測試機或正式站時,先跑一輪最基本的檢查:首頁打得開嗎、標題對嗎、登入按鈕有反應嗎。覆蓋率很低,但能在幾十秒內淘汰明顯壞掉的版本。
還有一類是非功能性測試,測的是「好不好」而不是「對不對」:
- 效能測試:反應時間、資源用量、前端載入速度。
- 負載與壓力測試:大量使用者同時進來時撐不撐得住,極限在哪。
- 安全性測試:SQL injection、XSS 這類漏洞掃描。
- 視覺回歸測試:截圖逐像素比對,抓 CSS 改壞的跑版。這是網頁特有的。
- 無障礙測試:檢查螢幕閱讀器能不能用、對比夠不夠、圖片有沒有替代文字。政府和大型網站常是合規要求。
這幾層的數量要怎麼分配,最常見的說法是測試金字塔:
每種測試在流程的哪一關
這些測試不是各寫各的,它們分散在開發到上線的不同時間點:
- 單元測試:寫程式時、commit 前或 CI 上跑,前端或後端工程師自己寫。
- 整合/元件測試:開發完成後在 CI 上跑,工程師寫。
- 煙霧測試:剛部署到測試機或正式站之後跑,通常是自動化腳本。
- E2E:上線前或 CI 定時跑,前端或 QA 工程師寫。
- 系統測試(SIT):在 QA 環境裡,由 QA 主導。
- 驗收測試(UAT):上線前,由 PM 或客戶做。
- 回歸測試:每次改程式都把上面的自動化測試重跑。
每一關擋下的 bug 不一樣。下面這張圖可以選一種 bug,按「送出這個版本」,看它在哪一關被攔下。再點掉幾個關卡(代表「我們沒做這種測試」),看看 bug 會漏到哪裡。
可以注意兩件事。第一,越前面擋下越好:單元測試擋下折扣算錯,錯誤訊息直接指到函式;到了 SIT 才發現,就要從訂單金額往回查。第二,「需求理解錯」只有 UAT 擋得住,自動化測試再多也沒用。
前端測試和後端測試
前端和後端測試一樣重要,但保護的東西不同,不能互相取代。看兩邊沒寫測試時各會出什麼事,比較好懂。
不寫後端測試,出事通常是資料和錢:
- 折扣碼沒驗證好,結帳金額變 0 元。
- 改一支 API,更新會員資料時把別人的欄位清空。
- 前端畫面正常,按結帳時 API 回 500,整站不能下單。
不寫前端測試,出事的是使用者能不能用:
- 後端 API 都正常,但結帳按鈕的
disabled被寫死成true,沒人按得下去。 - SSR 撈到
null沒處理,使用者一打開就是白畫面。 - Chrome 正常,Safari 或手機瀏覽器點購物車沒反應。
兩邊的差別:
- 防守對象:後端是資料庫、API、商業邏輯、金流和權限;前端是 UI 元件、操作流程和前端狀態(Pinia、Redux)。
- 改壞的樣子:後端是改 A 表弄壞 B 報表;前端是改了 CSS 或元件結構,按鈕點不到、頁面跑版。
- 速度:後端測試多是純邏輯和 API,很快;前端的 E2E 要開瀏覽器,比較慢。
- 維護成本:後端的介面相對穩定,測試寫一次可以跑很久;前端畫面和需求常改,測試也要跟著改。
資源有限的時候怎麼選:
- 剛起步的小團隊:先寫後端的單元和 API 測試,確保資料不會錯。前端先不寫細的單元測試,改寫一兩條 Playwright E2E 跑登入和結帳,用最少的測試同時保護前後端。
- 成熟的大型專案:兩邊各自做好。後端保證 API 給的資料正確;前端保證不管拿到什麼資料都不會白畫面,各種操作和多步驟表單都能用;再用整合測試和 E2E 把兩邊串起來。
全端團隊常會選同一套 JavaScript/TypeScript 工具,例如 Vitest 加 Playwright,前後端共用一種寫法。
一個好測試長什麼樣子
先講寫法。不管是哪一層的測試,好測試有幾個共同點。
最常見的結構叫 AAA:Arrange(準備資料)、Act(執行要測的動作)、Assert(檢查結果)。三段分開寫,讀的人一眼就知道這個測試在幹嘛:
it('加入同商品兩次,數量變 2', () => {
// Arrange:準備一個空購物車
const cart = createCart()
// Act:同一個商品加兩次
cart.add({ sku: 'LION-01', price: 590 })
cart.add({ sku: 'LION-01', price: 590 })
// Assert:只有一筆,數量是 2
expect(cart.items).toEqual([{ sku: 'LION-01', price: 590, qty: 2 }])
})另外幾個原則,常被縮寫成 FIRST:
- 快(Fast):單元測試要快到你願意每次存檔都跑。一個測試超過一秒,大概就是碰到了網路、資料庫或計時器。
- 獨立(Independent):每個測試自己準備資料、自己收拾。測試 B 不能依賴測試 A 先跑,不然換個順序或單獨跑一個就壞。
- 可重複(Repeatable):在誰的電腦、什麼時間跑結果都一樣。用到現在時間、亂數、時區的地方要固定下來。
- 自己判斷對錯(Self-validating):測試只會是過或不過,不需要人去看 log 判斷。
- 及時(Timely):跟程式一起寫,或比程式先寫。事後補的測試常常只是把現在的行為照抄一遍,包括 bug。
還有兩個習慣很重要:
- 測行為,不測實作:要測的是「點了按鈕,畫面出現錯誤訊息」,不是「元件內部的
errorMessage變數變成某個值」。前者在你重構時還會過,後者一重構就壞,而且壞了也不代表功能有問題。 - 測試名稱寫行為:
it('test1')失敗時沒人知道壞了什麼;it('過期的優惠券不能使用')一看就懂。好的測試名稱列出來,就是一份功能清單。
Mock、Stub、Spy、Fake 差在哪
寫測試很快就會遇到一個問題:要測的程式會打 API、寫資料庫、讀現在時間,測試時不想(也不該)真的去做這些事。這時候就要用「替身」(test double)換掉它們。常見的幾種:
- Stub:固定回傳一個值的假東西。例如讓「取得匯率」永遠回傳 30,你只關心拿到值之後的計算對不對。
- Mock:除了假裝,還會檢查「有沒有被用對的方式呼叫」。例如確認寄信函式被呼叫了一次,收件人是對的。
- Spy:包在真的函式外面,照常執行,同時記錄被呼叫了幾次、參數是什麼。
- Fake:一個能用、但簡化過的實作,例如用記憶體裡的陣列假裝資料庫。
在 Vitest 裡,這幾種大多用 vi.fn()、vi.spyOn()、vi.mock() 做出來。計時器也能換成假的,不用真的等:
import { vi, it, expect } from 'vitest'
import { debounce } from './debounce'
it('300ms 內連續輸入,只搜尋最後一次', () => {
vi.useFakeTimers()
const search = vi.fn()
const run = debounce(search, 300)
run('m')
run('ma')
run('mal')
vi.advanceTimersByTime(299)
expect(search).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(search).toHaveBeenCalledOnce()
expect(search).toHaveBeenCalledWith('mal')
vi.useRealTimers()
})要小心的是 mock 用太多。如果一個測試把所有依賴都 mock 掉,它最後測到的只是「我的 mock 照我設定的方式回傳」。比較實際的分法是:外部、慢、不穩定的東西(第三方 API、寄信、金流、時間)才 mock;自己寫的純邏輯就讓它真的跑。
各種測試怎麼寫
下面這個執行器收了七種測試的範例。選一種,按「執行測試」看它跑過;再勾「埋一個 bug」,會出現一行改壞的程式,再跑一次就會看到紅燈和錯誤訊息。
每種測試的重點:
單元測試
用 Vitest 或 Jest。一個測試只驗一件事,輸入固定、輸出固定。適合測資料格式化(日期、千分位)、商業邏輯(折扣、權限判斷)、狀態管理的 action 或 reducer。
import { describe, it, expect } from 'vitest'
import { applyCoupon } from './cart'
describe('applyCoupon', () => {
it('SAVE10 打九折', () => {
expect(applyCoupon(100, 'SAVE10')).toBe(90)
})
})除了正常情況,也要測邊界:0、負數、空字串、找不到的折扣碼。bug 常常出在這裡。
同一個函式要測很多組輸入時,可以用 it.each 寫成一張表,比複製貼上好讀:
it.each([
[100, 'SAVE10', 90],
[100, 'FAKE', 100],
[0, 'SAVE10', 0],
[99, 'SAVE10', 89],
])('applyCoupon(%i, %s) 應該是 %i', (price, code, expected) => {
expect(applyCoupon(price, code)).toBe(expected)
})最後一組 99 元打九折是 89.1,四捨五入成 89。這種小數進位的規則,是需求單上最常沒寫清楚、上線後最常被客人抓到的地方,值得單獨寫一條。
哪些不用寫單元測試:框架本身的功能(Vue 的響應式、React 的 state 更新)、只是把資料原封不動傳下去的元件、第三方套件的內部行為。這些不是你的程式,測了也只是在測別人。
元件測試
用 Testing Library。它的原則是「照使用者看到的方式找元素」:用按鈕文字、label、role 找,不用 class 名稱。這樣改 CSS 不會讓測試壞,按鈕真的找不到才會壞。常測的情況:
- 傳不同的 props,畫面顯示對不對,例如
disabled時按鈕變灰。 - 點按鈕之後有沒有發出事件(Vue 的 emit、React 的 callback)。
- 表單輸入格式錯誤時有沒有出現提示。
需要 toBeInTheDocument()、toBeDisabled() 這類斷言的話,要另外設定 @testing-library/jest-dom。
Testing Library 建議找元素的優先順序是:
getByRole:用無障礙角色和名稱找,例如getByRole('button', { name: '送出' })。這也順便確認了螢幕閱讀器找得到它。getByLabelText:表單欄位用 label 找。getByPlaceholderText、getByText:沒有更好的選擇時用。getByTestId:最後手段。元素真的沒有可見文字或角色時,才加data-testid。
另一個常卡住的地方是非同步。資料是打 API 之後才出現的話,用 findBy...(會等一段時間直到出現)而不是 getBy...(找不到立刻失敗)。要確認某個東西「不存在」,用 queryBy...,它找不到時回傳 null 而不是丟錯。
getBy、findBy、queryBy 這三個前綴,是剛開始寫元件測試最容易混淆的地方,搞懂之後大部分的「測試明明應該過卻一直失敗」都會消失。
API/整合測試
後端用 Supertest 直接對 Express 這類 app 發請求,不用真的開 port;也可以用 Postman 或 Bruno 寫成請求集合在 CI 跑。重點是狀態碼(200、400、401)和回傳格式。前後端欄位對不上,這層最快發現。
前端這邊,元件要打 API 時用 MSW(Mock Service Worker)在網路層回假資料,元件程式不用為了測試改寫。
後端整合測試最麻煩的是資料庫。幾種常見做法:
- 測試專用的資料庫:用 Docker 開一個和正式環境同版本的 PostgreSQL 或 MySQL,測試跑完就丟。Testcontainers 這類工具可以在測試開始時自動啟動容器。
- 每個測試自己收拾:測試前塞好需要的資料,測試後清掉;或者整個測試包在一個 transaction 裡,結束時 rollback。重點是測試之間不能互相看到對方的資料。
- 不要用 SQLite 假裝 PostgreSQL:兩者在型別、日期、大小寫比對上都有差異,用 SQLite 測過不代表正式環境會過。
前後端分屬不同團隊時,還可以考慮契約測試(contract testing),例如 Pact。前端先寫下「我會這樣呼叫、預期拿到這種格式」,後端在自己的 CI 驗證有沒有符合。後端改欄位名稱的時候,在後端的 CI 就會紅,不用等到整合環境才發現。
E2E
用 Playwright 或 Cypress。Playwright 是微軟的開源專案,可以同時跑 Chromium、Firefox、WebKit,npx playwright codegen 能邊點邊錄出腳本。Cypress 的除錯介面對前端很友善,很適合單頁應用。
E2E 很貴,不要什麼都用它測。挑三到五條「壞了就不能營業」的流程:登入、結帳、權限保護(沒登入進後台要被導回登入頁)。
寫 E2E 的幾個實用原則:
- 用使用者看得到的方式找元素:Playwright 的
getByRole、getByLabel、getByText和 Testing Library 的想法一樣,比 CSS selector 穩定。改了 class 名稱測試不會壞。 - 不要寫死等待時間:
page.waitForTimeout(3000)是 flaky test(時好時壞的測試)最常見的來源,在快的電腦上浪費時間,在慢的 CI 上又不夠。Playwright 的expect(...).toHaveText()這類斷言本身會自動重試到逾時為止,大多數情況不需要自己等。 - 登入只做一次:每條測試都從登入頁開始,又慢又重複。Playwright 可以先跑一個 setup,把登入後的 cookie 和 localStorage 存成檔案,其他測試直接帶著這個狀態開始:
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
setup('登入一次並存下狀態', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill(process.env.E2E_USER!)
await page.getByLabel('密碼').fill(process.env.E2E_PASS!)
await page.getByRole('button', { name: '登入' }).click()
await expect(page).toHaveURL('/dashboard')
await page.context().storageState({ path: 'playwright/.auth/user.json' })
})- 每條測試用自己的資料:兩條測試同時對同一個測試帳號的購物車加東西,就會互相干擾。測試資料最好在測試開始時用 API 建立,名稱帶亂數。
- 失敗時留下證據:在設定裡開
trace: 'on-first-retry',CI 上失敗時會留下每一步的截圖、DOM 和網路請求,用npx playwright show-trace打開,比只看錯誤訊息好查得多。
煙霧測試
不需要新工具,就是一支很短的 Playwright 腳本,部署完立刻對新網址跑:
import { test, expect } from '@playwright/test'
test('首頁打得開、登入按鈕有反應', async ({ page }) => {
await page.goto(process.env.SMOKE_URL ?? '/')
await expect(page).toHaveTitle(/我的商店/)
await page.getByRole('button', { name: '登入' }).click()
await expect(page.getByRole('dialog')).toBeVisible()
})失敗就把這版退掉,不用等 QA 打開才發現白畫面。
煙霧測試最好接在部署流程裡自動跑,而不是靠人記得。Vercel、Netlify 這類平台都有「部署完成」的事件可以觸發 GitHub Actions,拿到預覽網址後直接把它設成 SMOKE_URL 跑上面那支腳本。
視覺回歸
Playwright 內建 toHaveScreenshot():第一次跑存一張基準圖,之後每次比對,差太多就失敗,並產生一張差異圖。也有 Chromatic(搭配 Storybook)和 Percy 這類服務。要注意字型和作業系統會影響截圖,基準圖最好在 CI 的同一個環境產生。
頁面上有時間、亂數、輪播廣告這類每次都不一樣的內容,可以用 mask 遮掉,不然每次都會失敗:
await expect(page).toHaveScreenshot('home.png', {
mask: [page.getByTestId('clock'), page.locator('.ad-banner')],
})刻意改了畫面時,用 npx playwright test --update-snapshots 更新基準圖,並且在 code review 時看一下新的截圖,確認是你要的樣子。
無障礙
用 axe-core,在 Playwright 裡用 @axe-core/playwright 掃整頁;Lighthouse 也有無障礙分數。自動掃描抓得到缺 alt、對比不足、表單沒有 label 這類問題,鍵盤操作順不順、螢幕閱讀器念起來通不通,還是要人實際試。
效能與負載
前端載入速度用 Lighthouse 或 WebPageTest,看圖片是不是太大、JavaScript 有沒有擋住畫面。後端承載量用 k6(用 JavaScript 寫腳本)或 JMeter,設定門檻,例如「95% 的請求要在 500ms 內回應」,超過就讓 CI 失敗。負載測試要打測試機,不要打正式站。
負載測試還可以細分幾種,問的問題不一樣:
- 負載測試(load):在預期的流量下,回應時間和錯誤率正常嗎?
- 壓力測試(stress):流量一直往上加,到哪裡開始出錯?出錯之後會不會自己恢復?
- 尖峰測試(spike):流量瞬間暴增十倍(例如整點開賣),撐得住嗎?
- 長時間測試(soak):中等流量連續跑幾個小時,記憶體會不會一直漲、連線會不會耗盡?
k6 用 stages 設定流量曲線,同一支腳本就能跑出這幾種情境。
安全性
常見做法是在 CI 加上相依套件掃描(npm audit、Dependabot)和靜態分析,再用 OWASP ZAP 這類工具對測試機做弱點掃描。後端 API 測試裡也該有「沒登入回 401」「不是本人的資料回 403」這種權限案例。
安全性測試的工具大致分三類:
- SCA(相依套件分析):檢查你用的套件有沒有已知漏洞,例如
npm audit、Dependabot、Snyk。 - SAST(靜態分析):不執行程式,直接掃原始碼找危險寫法,例如把使用者輸入直接拼進 SQL,GitHub 的 CodeQL 是一種。
- DAST(動態掃描):對跑起來的網站發各種惡意請求,看會不會被打穿,例如 OWASP ZAP。
自動化工具抓得到常見問題,但權限設計錯誤(例如 A 使用者改網址上的 id 就能看到 B 的訂單)通常還是要靠寫測試或人工檢查。
Vue、React、Next.js 怎麼搭
三個框架的分層一樣,工具大同小異:
- 單元測試:Vue、React、Next.js 都用 Vitest(React 和 Next.js 也常用 Jest)。Vue 要模擬 DOM 時搭 happy-dom 或 jsdom。
- 元件與整合測試:Vue 用 Vue Testing Library 或 Vue Test Utils;React 和 Next.js 用 React Testing Library;需要假 API 時三者都加 MSW。
- E2E:三者都用 Playwright 或 Cypress。Next.js 官方文件有 Playwright 的設定範例。
Vue 專案的狀態管理多半是 Pinia,每個測試前用 setActivePinia(createPinia()) 建新的 store,測試之間才不會共用狀態。React 的 Redux reducer 是純函式,直接當單元測試寫。
Next.js 是全端框架,除了畫面還要測伺服器端:
- Route Handler(
app/api/.../route.ts):本身就是吃Request、回Response的函式,可以直接呼叫,檢查狀態碼和 JSON。 - Server Action:也是 async 函式。把資料庫和
redirect()mock 掉,測它有沒有寫入、導到哪一頁。 - Server Component:同步的可以用 React Testing Library;async Server Component 目前 Vitest 還不支援,Next.js 官方文件建議用 E2E 測。
下面這個執行器把同一類測試在三個框架各寫一次,也一樣可以埋 bug:
設定檔:把測試接進專案
工具選好之後,需要的設定其實不多。
Vue 專案(Vite)裝好 vitest、@vitejs/plugin-vue、happy-dom、@testing-library/vue、@testing-library/jest-dom 之後,vitest.config.ts 大概是這樣:
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
},
})vitest.setup.ts 只有一行 import '@testing-library/jest-dom/vitest',載入 toBeInTheDocument() 這些斷言。globals: true 讓 Testing Library 能在每個測試結束後自動清掉畫面。
Next.js 官方文件的 Vitest 設定換成 React 的 plugin 和 jsdom,並用 vite-tsconfig-paths 讓 @/ 這種路徑別名在測試裡也能用:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: { environment: 'jsdom' },
})Playwright 的設定三個框架都差不多,重點是 webServer:跑測試前自動把網站開起來,跑完自動關掉。
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run build && npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
],
})E2E 最好對 build 出來的正式版本跑,而不是開發模式。開發模式有熱更新和額外的檢查,行為和正式站不完全一樣。
接上 CI
測試只在自己電腦上跑,很快就會沒人跑。接上 CI(例如 GitHub Actions)之後,每次 push 和每個 PR 都會自動跑,紅燈的 PR 不能合併,回歸測試才真的有在做事。
name: test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx vitest run --coverage
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --project=chromium
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report幾個細節:
- 單元測試和 E2E 分成兩個 job,平行跑,單元測試幾十秒就有結果,不用等瀏覽器。
--coverage需要另外裝@vitest/coverage-v8。- E2E 失敗時把
playwright-report上傳,下載回來就能看 trace。 - 在 GitHub 的 branch protection 把這兩個 job 設成必須通過,紅燈就合併不了。
覆蓋率要多少
覆蓋率(coverage)是「測試跑的時候,有多少比例的程式碼被執行過」。它有用,但很容易被誤解:
- 被執行過不等於被檢查過。一個沒有任何
expect的測試,也能把覆蓋率衝到 100%。 - 100% 通常不划算。最後那 10% 往往是錯誤處理和極少發生的分支,寫測試的成本很高。
- 看趨勢比看數字有用。設一個門檻讓覆蓋率不要往下掉,比要求達到某個數字更實際。
比較好的用法,是拿覆蓋率報告找「重要但完全沒被測到」的地方:金額計算、權限判斷、資料轉換。報告上一整片紅色的結帳模組,比整體只有 60% 更值得擔心。
常見的坑
最後整理幾個很常見的問題,大多數團隊都踩過:
- Flaky test:同一個測試時過時不過。原因通常是寫死等待時間、測試之間共用資料、依賴執行順序、用到真實時間或網路。flaky test 比沒有測試更糟,因為大家會習慣「紅了就再跑一次」,真的壞掉時也沒人理。發現了就修,修不好就先隔離。
- 測實作細節:測元件內部的變數、呼叫了哪個私有函式。這種測試在重構時大量壞掉,大家就會開始覺得「寫測試很浪費時間」。
- 快照測試濫用:把整個元件的 HTML 存成 snapshot,任何改動都會讓它失敗,最後大家只會無腦按更新。快照適合小而穩定的輸出,例如一段格式化後的文字。
- E2E 寫太多:什麼都用 E2E 測,一次 CI 要跑半小時,又常常 flaky。能在下層測的就往下放。
- 測試沒人跑:寫了但沒接 CI,幾個月後一半都壞了也沒人知道。
- 只測快樂路徑:只測「輸入正確、網路正常」的情況。真正的 bug 多半在錯誤輸入、API 逾時、空資料、重複點擊。
從哪裡開始
一個測試都還沒有的專案,可以照這個順序:
- 先寫單元測試:用 Vitest 把最核心的工具函式和商業邏輯(金額計算、權限判斷、store)鎖起來。寫起來快、跑起來快,最划算。
- 再寫幾條 E2E:用 Playwright 寫三到五條「絕對不能壞」的流程,例如登入和結帳,接到 CI。
- 部署後跑煙霧測試:把一條最短的 E2E 拿來在每次部署後跑。
- 最後補元件測試:團隊習慣之後,針對共用元件(自己的 design system、表單元件)補上 Testing Library 測試。
不用一開始就追求覆蓋率數字。先讓「最怕壞的地方」有測試,並且每次 push 都會自動跑,這比寫一堆沒人跑的測試有用。
說個實話:我自己這個網站目前也沒有測試套件。CI 上跑的是型別檢查(vue-tsc)、建置,以及建置後的一支檢查腳本,確認每一頁都有產生、canonical 和分享圖網址都正確。這些檢查能確認網站建得出來、頁面都完整,但擋不住「某個按鈕點了沒反應」這種事,那要靠上面說的單元、元件和 E2E 測試。
留言