戰(zhàn)指南:從Vitest選型到組件測試覆蓋率落地)
最近帶前端小組做技術(shù)基建發(fā)現(xiàn)很多同學(xué)一聽到“寫單測”就皺眉覺得是給項(xiàng)目拖后腿。但只要把工具鏈和流程理順前端單元測試反而是我目前回報率最高的一筆技術(shù)投入——它不光是驗(yàn)證某個函數(shù)返回值更多是在幫你守住組件行為、接口約定和重構(gòu)手感。這篇文章把我踩過的坑和實(shí)際跑通的做法整理出來覆蓋 Vitest、Jest、Testing Library 的選型對比組件交互與異步用例怎么寫以及覆蓋率怎么定才不扯淡。不管你是剛接觸單測的新手還是已經(jīng)在項(xiàng)目里寫過一堆脆弱用例、準(zhǔn)備重建信心的老手都可以照著這套路子直接落地。1. 為什么前端單元測試值得寫先看清楚投入產(chǎn)出比1.1 單元測試到底在測什么很多人對前端單元測試的第一反應(yīng)是“測工具函數(shù)”。比如把日期格式化、金額轉(zhuǎn)換、數(shù)組去重單獨(dú)抽出來測一遍確實(shí)有意義但這只是最基礎(chǔ)的部分。前端項(xiàng)目的核心資產(chǎn)是組件和頁面狀態(tài)真正容易出問題的不是純函數(shù)而是“點(diǎn)擊按鈕觸發(fā)接口→接口返回后更新頁面→加載態(tài)和錯誤態(tài)切換”這一整條鏈路。單元測試要守住的是這些組件級行為在改動后依然符合預(yù)期。我習(xí)慣把單元測試?yán)斫鉃椤敖o組件拍一張行為快照”。不是快照測試那個 snapshot而是把用戶能感知到的關(guān)鍵交互固化成一個可重復(fù)的驗(yàn)證按鈕點(diǎn)了會變輸入框填錯會報錯數(shù)據(jù)加載中不出現(xiàn)空白頁。這樣理解之后你就不會糾結(jié)“要不要測 CSS”“要不要測視覺還原”這種問題單元測試本來就不負(fù)責(zé)這些。1.2 哪些場景適合寫哪些場景別硬寫拿到一個新頁面或新組件先判斷它值不值得寫單測。我的經(jīng)驗(yàn)是邏輯密度大于 UI 密度單測價值就高。比如表單校驗(yàn)、分頁狀態(tài)、權(quán)限控制、購物車計(jì)算這種邏輯多且容易在重構(gòu)時被改壞必須優(yōu)先覆蓋。純展示型組件比如一個只接受 props 的徽標(biāo)、圖標(biāo)測它的渲染內(nèi)容和快照意義不大寫多了反而變成“為了改斷言而改斷言”。還有三類場景我明確不建議硬寫單測一是強(qiáng)依賴大量 ECharts、Canvas 拖拽、WebRTC 這類瀏覽器能力jsdom 里模擬成本太高二是組件內(nèi)部塞了幾百行業(yè)務(wù)代碼、拆不出來這種應(yīng)該先做代碼拆分而不是硬寫用例三是需求還在頻繁變動的原型階段今天按鈕文案明天就換這時候?qū)憸y試屬于給沙子打地基等交互定版再補(bǔ)。2. 工具鏈選型Vitest、Jest、Testing Library 怎么選2.1 主流測試框架對比現(xiàn)在前端圈用到最多的就是 Vitest 和 Jest 兩套。Vitest 因?yàn)楹?Vite 天生一套啟動速度和 HMR 體驗(yàn)都很舒服Jest 生態(tài)老、社區(qū)大很多老項(xiàng)目里已經(jīng)跑得好好的遷移也不是不行但要掂量一下成本。維度VitestJestMocha Chai啟動速度快基于 Vite 按需加載慢全量收集 transform快但斷言和 mock 要自己拼配置復(fù)雜度低Vite 項(xiàng)目基本零配置需要 babel/ts-jest 或 swc高中低全靠手動組裝內(nèi)置 Mock支持 vi.fn/vi.mock支持 jest.fn/jest.mock不支持組件測試生態(tài)vue/test-utils、React Testing Library 都兼容同樣兼容要自己接 adapter適合場景新項(xiàng)目、Vite 工程、追求快反饋老 Jest 項(xiàng)目、習(xí)慣穩(wěn)定生態(tài)純 Node 工具庫、不想引入太重框架另外組件測試還需要搭配 Testing Library 或 Vue Test Utils。Testing Library 的核心思想是“從用戶視角測組件”你操作 DOM、斷言的也是 DOM 上的內(nèi)容不直接訪問組件實(shí)例的內(nèi)部狀態(tài)。這套理念我建議盡量遵守因?yàn)榉彩侵苯釉L問 data、wrapper.vm 的用例最后都容易和實(shí)現(xiàn)細(xì)節(jié)耦合死一旦內(nèi)部重構(gòu)就崩。2.2 我的推薦組合如果今天從零開始我會直接用 Vitest testing-library/vue或 testing-library/react jsdom。Vue 項(xiàng)目也可以用 vue/test-utils但我個人更偏愛 Testing Library因?yàn)樗?find 和 user-event 組合非常貼近真實(shí)操作。如果項(xiàng)目是 Vue 2 Jest 的老組合別急著推翻。Jest 在 Vue 2 里跑得很穩(wěn)需要補(bǔ)的就是 vue/test-utils v1 和 jest-environment-jsdom 的版本對齊。遷移到 Vitest 等下次大版本升級再說不要讓“換工具”這種動作混進(jìn)“寫測試”的需求里。注意Vitest 和 Jest 的配置有個常見坑——alias 解析。Vite 項(xiàng)目里指向 src但 Vitest 默認(rèn)不會自動讀取 Vite 的 alias 配置在新版本里支持不夠徹底需要在 vitest.config.ts 里單獨(dú)配一遍resolve.alias否則一跑測試就報Failed to resolve import。3. 從零搭一個可跑的單測環(huán)境3.1 環(huán)境配置和首屏踩坑先演示 Vue 3 Vitest 的搭建方式。項(xiàng)目是 Vite 默認(rèn)模板命令行執(zhí)行npm i -D vitest testing-library/vue jsdom testing-library/user-event然后在 package.json 里加一段腳本{ scripts: { test: vitest run, test:watch: vitest } }再建一個 vitest.config.tsimport { defineConfig } from vitest/config import { fileURLToPath, URL } from node:url export default defineConfig({ test: { environment: jsdom, globals: true, setupFiles: [./src/test-setup.ts], include: [src/**/*.{test,spec}.{ts,tsx}], testTimeout: 10000 }, resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } } })字段解釋一下environment: jsdom讓測試跑在瀏覽器模擬環(huán)境里globals: true允許直接寫 describe/test/expect 不顯式 importsetupFiles用來加載全局 polyfillinclude控制哪些文件會被當(dāng)作測試識別。這里我要單獨(dú)提一句globals 開不開是個取舍。開了寫起來短但會讓代碼顯式依賴全局變量不開的話每個測試文件都要從 vitest 里 import。我更推薦顯式 import因?yàn)檫@樣單個文件拿給別人看也能知道依賴了什么對新人友好一點(diǎn)。3.2 第一個組件用例走一遍搭好環(huán)境后寫一個最基礎(chǔ)的計(jì)數(shù)組件看看整個執(zhí)行鏈路是否通暢。先創(chuàng)建src/components/Counter.vuetemplate button>import { render, screen } from testing-library/vue import userEvent from testing-library/user-event import { expect, test } from vitest import Counter from ./Counter.vue test(點(diǎn)擊按鈕后計(jì)數(shù)增加, async () { const user userEvent.setup() render(Counter) await user.click(screen.getByTestId(counter-btn)) expect(screen.getByTestId(counter-btn)).toHaveTextContent(1) })注意這里我用的是getByTestId。很多人喜歡用getByText找按鈕但在這個組件里按鈕文案本身就是斷言對象用getByText會在點(diǎn)擊后因?yàn)槲陌缸兓也坏焦?jié)點(diǎn)。用>test(輸入關(guān)鍵詞后展示搜索結(jié)果, async () { const user userEvent.setup() render(SearchBox) const input screen.getByPlaceholderText(請輸入關(guān)鍵詞) expect(input).toBeInTheDocument() await user.type(input, vitest) await user.keyboard({Enter}) expect(await screen.findByText(搜索結(jié)果vitest)).toBeInTheDocument() })這里有個細(xì)節(jié)輸入完關(guān)鍵詞后不要立刻同步斷言結(jié)果。如果是接口請求渲染是異步的要用findByText而不是getByText。findBy會等一段時間并自動重試直到元素出現(xiàn)或超時這是處理異步斷言的標(biāo)配。4.2 異步請求的 Mock 策略真實(shí)項(xiàng)目里組件幾乎都要調(diào)接口單測環(huán)境絕不能真的發(fā) HTTP 請求。主流做法是 mock 掉接口層。這里有個層次問題你到底是 mock axios/fetch還是 mock 業(yè)務(wù) API 模塊我建議 mock 業(yè)務(wù) API 模塊而不是 mock axios。原因很簡單業(yè)務(wù) API 模塊是組件依賴的“接口邊界”你 mock 它測試的關(guān)心點(diǎn)是“組件在接口返回后怎么渲染”而不是“某個 URL 被請求了沒有”。如果直接 mock axios用例會和你前端層的具體請求方式耦合萬一哪天從 axios 換成 fetch所有測試都要跟著改。比如項(xiàng)目里有src/api/user.ts導(dǎo)出一個fetchUserInfo函數(shù)組件里這樣寫import { fetchUserInfo } from /api/user const getUser async () { loading.value true const data await fetchUserInfo() user.value data loading.value false }測試?yán)镞@樣 mockimport { vi } from vitest import { fetchUserInfo } from /api/user import UserCard from ./UserCard.vue vi.mock(/api/user, () ({ fetchUserInfo: vi.fn() })) test(接口返回后顯示用戶名, async () { vi.mocked(fetchUserInfo).mockResolvedValue({ id: 1, name: 張三 }) render(UserCard) expect(await screen.findByText(張三)).toBeInTheDocument() })注意vi.mock有提升行為會把這個 mock 拉到文件頂部執(zhí)行。如果你在用例內(nèi)部再mockResolvedValue不受影響但如果你試圖 mock 一個變量后再動態(tài)改變返回值很容易踩到“mock 作用域”的坑。每個用例之間記得vi.clearAllMocks()避免上一次 mock 的參數(shù)殘留影響下一個用例。4.2.1 接口報錯分支一定要測很多團(tuán)隊(duì)寫單測只寫“成功路徑”錯誤分支永遠(yuǎn)不測。結(jié)果一到線上接口掛了頁面直接白屏或者一直轉(zhuǎn)菊花。正確做法是錯分支至少要有一條用例test(接口失敗時展示錯誤提示, async () { vi.mocked(fetchUserInfo).mockRejectedValue(new Error(network error)) render(UserCard) expect(await screen.findByText(加載失敗請稍后重試)).toBeInTheDocument() expect(screen.queryByText(張三)).not.toBeInTheDocument() })這一個用例往往比五個正常用例更值錢因?yàn)樗?yàn)證的是你項(xiàng)目里最容易爛掉的兜底邏輯。我之前在一個訂單詳情頁補(bǔ)了類似用例立刻抓到兩個問題一是錯誤 toast 提示被重復(fù)展示三次二是 loading 狀態(tài)在異常分支沒有關(guān)掉。這些靠人工點(diǎn)點(diǎn)點(diǎn)很難穩(wěn)定復(fù)現(xiàn)單測一跑就現(xiàn)原形。4.3 定時器、transition 和瀏覽器 API 的坑4.3.1 定時器用 fake timers組件里有倒計(jì)時、輪詢或者防抖邏輯時真實(shí)等待會讓測試又慢又飄。Vitest 的vi.useFakeTimers()可以把setTimeout/setInterval替換成可手動推進(jìn)的假定時器。典型用法vi.useFakeTimers() test(倒計(jì)時到 0 后顯示過期, () { vi.setSystemTime(new Date(2024-01-01T00:00:00)) render(Countdown) act(() { vi.advanceTimersByTime(10000) }) expect(screen.getByText(已過期)).toBeInTheDocument() })但要注意開啟 fake timers 后userEvent.setup()也會受影響某些版本的 userEvent 會和 fake timers 打架。如果遇到user-event一直 pending可以臨時不 fake或者改用fireEvent在少數(shù)場景下這是合理的再就是檢查 userEvent 的白名單配置。真遇到這種我通常先vi.runOnlyPendingTimers()把當(dāng)前掛起的定時器全部跑完再執(zhí)行交互。4.3.2 Transition 和 Teleport 的坑Vue 的transition在測試環(huán)境里不會真正執(zhí)行過渡動畫但并不代表它沒有副作用。組件里有v-show搭配 transition 時jsdom 環(huán)境下getComputedStyle往往拿不到正確的 transition 狀態(tài)導(dǎo)致斷言不穩(wěn)定。我的做法是測試文件里全局禁用 transition??梢栽?setup 文件里把Transition和TransitionGroup直接 mock 成一個穿透的插槽// src/test-setup.ts import { config } from vue/test-utils config.global.stubs { transition: false, transition-group: false }Teleport則要注意默認(rèn)的 Teleport 會把內(nèi)容掛到document.body上用screen查詢時一般能查到但如果組件里有多個 Teleport 或 SSR 環(huán)境就建議指定teleport: true或者直接 mock 掉避免內(nèi)容被“傳送”到你搜不到的地方。4.3.3 window 屬性不是全都有jsdom 雖然模擬了瀏覽器環(huán)境但它不是完整的瀏覽器。localStorage現(xiàn)代版本有但matchMedia、ResizeObserver、IntersectionObserver、getBoundingClientRect這些不一定全。我第一次跑彈窗組件測試時ResizeObserver直接報is not defined排查了半天才發(fā)現(xiàn)是沒補(bǔ) polyfill。公共 setup 文件里補(bǔ)一下// src/test-setup.ts import { vi } from vitest Object.defineProperty(window, matchMedia, { writable: true, value: vi.fn().mockImplementation((query) ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn() })) }) class ResizeObserverMock { observe() {} unobserve() {} disconnect() {} } Object.defineProperty(global, ResizeObserver, { writable: true, value: ResizeObserverMock })5. 常見問題與排查技巧實(shí)錄5.1 Vue 項(xiàng)目里的高發(fā)報錯速查報錯信息常見原因解決思路Failed to resolve import /xxxVitest 沒讀到 Vite alias在 vitest.config.ts 里配置 resolve.aliasTypeError: wrapper.vm is undefinedVue Test Utils mount 返回對象被誤用確認(rèn) mount 后拿到了組件實(shí)例別在 setup script 里直接訪問閉包變量Cannot find module vuemonorepo 里多版本 Vue 沖突把 vue 加入resolve.dedupe或者用 peerDependenciesRequest is not defined組件內(nèi)用了 fetch但 jsdom 未啟用environment 設(shè)為 jsdom并安裝 whatwg-fetch polyfillHydration node mismatch測試環(huán)境和運(yùn)行環(huán)境 HTML 不一致檢查 SSR 組件用createSSRApp或者 mock 掉依賴 window 的代碼ReferenceError: IntersectionObserver is not defined組件懶加載依賴觀察器在 setup 里補(bǔ) mock見上文You are using the runtime-only build測試環(huán)境沒解析 templateVite 下調(diào)整 vitejs/plugin-vue 的配置參與標(biāo)準(zhǔn) Vue 模板編譯Element is not attached to the document組件內(nèi)部使用了 getElementById 或希望元素掛載到 body用 Testing Library 的 render 已自動掛載別手動 appendChild這張表是我從一個真實(shí)項(xiàng)目里“整理”出來的不是網(wǎng)上復(fù)制的泛泛清單。每個報錯背后都對應(yīng)一種“測試環(huán)境和真實(shí)瀏覽器不一致”的問題排查時先問一句這個 API 在 jsdom 里到底實(shí)現(xiàn)沒實(shí)現(xiàn)這是主線。5.2 測試不穩(wěn)定的兩大元兇時序和狀態(tài)殘留單測最怕“這次綠下次紅”這種 flaky 狀態(tài)。我歸納下來九成問題出在兩處。第一是異步時序。一個用例里同時出現(xiàn)了await user.type、mockResolvedValue、nextTick如果沒有正確等待斷言可能跑在渲染之前。解決辦法很粗暴能用findBy就別用getBy能等用戶的交互結(jié)束再斷言就不要在trigger后立刻讀文本。findBy默認(rèn)有 1 秒等待窗口足夠覆蓋大部分異步渲染。第二是測試間狀態(tài)殘留。全局的 pinia store、vue-router、國際化 locale 都是單例一個用例 set 了 locale 為英文下一個用例沒重置中文文案斷言就掛了。我建議在每個afterEach里做干凈的重置import { afterEach } from vitest afterEach(() { vi.clearAllMocks() vi.resetModules() vi.useRealTimers() document.body.innerHTML })這里resetModules會清掉模塊緩存避免某個 mock 文件被其他用例污染。代價是重新加載模塊會慢一點(diǎn)但換來的穩(wěn)定性非常值。5.3 覆蓋率怎么定才不扯淡很多公司會給前端團(tuán)隊(duì)壓覆蓋率指標(biāo)比如“核心模塊要 80%”。我的意見是覆蓋率只適合作為流程參考線不適合當(dāng) KPI。我見過團(tuán)隊(duì)為了把行覆蓋率從 75% 懟到 90%硬是給一堆模板里的空標(biāo)簽和純展示組件寫了幾十個expect(wrapper.exists()).toBe(true)。這種覆蓋率數(shù)據(jù)純屬自欺欺人真正有價值的覆蓋率是“關(guān)鍵業(yè)務(wù)分支有沒有被覆蓋”的審計(jì)工具不是管理層打分的賬單。實(shí)際操作上我是按模塊分級設(shè)定閾值的基礎(chǔ)庫、通用工具函數(shù)行覆蓋不低于 85%分支覆蓋不低于 70%業(yè)務(wù)組件涉及表單、接口、權(quán)限行覆蓋不低于 70%但要求interaction分支點(diǎn)按鈕、填表單、錯誤提示至少有 1 條用例純展示組件不設(shè)覆蓋率門檻只看渲染是否正常遺留代碼只做“接觸式”補(bǔ)測把重構(gòu)時最容易碰到崩潰的核心入口覆蓋到即可在 vitest 配置里可以這樣寫test: { coverage: { provider: istanbul, reporter: [text, html, lcov], thresholds: { lines: 60, functions: 50, branches: 40, }, include: [src/**/*.{ts,vue}], exclude: [src/main.ts, src/router/**] } }注意provider: istanbul或v8都行Vite 5 以上建議用v8跑得更快。覆蓋率數(shù)據(jù)只是給你一個“哪里完全沒碰過”的清單真正的判斷標(biāo)準(zhǔn)是你最近改的一個核心函數(shù)有沒有對應(yīng)用例守在那里。寫在最后的一段實(shí)戰(zhàn)心得踩了這么多坑之后我最大的體會是前端單元測試和寫業(yè)務(wù)代碼其實(shí)是相輔相成的。當(dāng)你發(fā)現(xiàn)一個函數(shù)很難測通常不是測試的問題而是函數(shù)設(shè)計(jì)有問題當(dāng)你發(fā)現(xiàn)一個組件寫用例特別費(fèi)勁它多半在組件邊界上堆了太多不該有的副作用。與其硬寫一堆 mock 去繞不如回頭把組件拆得更干凈——把純函數(shù)抽出去、把接口調(diào)用收斂成 API 模塊、把狀態(tài)更新用 computed 或 reactive 規(guī)范化。這樣單測順了業(yè)務(wù)代碼的維護(hù)成本也跟著降。最后再分享一個小技巧別把測試文件寫到組件旁邊就完事我最開始就這么干習(xí)慣性“順手跳過”測試文件的時候特別容易誤傷?,F(xiàn)在我都統(tǒng)一放到src/__tests__下并在提交前讓 CI 強(qiáng)制跑一遍vitest run這樣單測才會真正變成項(xiàng)目的地基而不是某個模塊可有可無的點(diǎn)綴。