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