ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

TinyVue组件库测试工具链实战:从Vitest到Playwright全配置解析

TinyVue组件库测试工具链实战:从Vitest到Playwright全配置解析 入坑组件库开发的同学十有八九会卡在测试这一关。TinyVue 作为华为云开源的 Vue 组件库项目里组件数量多、属性复杂还横跨 Vue2/Vue3 两个大版本没有一套靠谱的测试工具链撑着每次改个公共逻辑都提心吊胆。这篇是探索 TinyVue 组件库系列开发工具部分的第三篇专门聊测试工具与配置。内容会覆盖单测工具选型Vitest Vue Test Utils、E2E 测试Playwright、覆盖率配置、CI 集成以及我在实际配置过程中踩过的那些坑。适合正在做组件库、或者想在业务项目里搭一套完整前端测试体系的朋友参考如果你只是用 TinyVue 写业务也能从里面抽出几个能直接用的配置模板。1. 为什么组件库项目需要专门配一套测试工具链1.1 组件库测试和业务项目测试的差异很多人觉得组件库也是 Vue 项目照搬业务项目的测试方案不就行了真不是这样。业务项目测试你只需要保证当前页面的逻辑正确Mock 掉接口、Mock 掉路由数据流走通就算完事。组件库测试面对的场景要复杂得多几十个组件每个组件有十几种 props 组合、状态流转、事件派发还可能要适配多个主题和多种跨端展示。业务项目可以为一个具体页面写高度定制化的测试用例组件库不行因为组件库的输入空间是笛卡尔积级别的测试用例必须组件化、原子化每个用例要能独立验证一个行为契约。还有一个关键差异是回归成本。业务项目需求变动频繁测试挂了一看就知道是需求变了但组件库的接口如果设计得稳定测试挂了基本意味着某个改动破坏了向后兼容性必须立刻定位。我在维护 TinyVue 相关项目时最怕的就是某个组件因为顺手优化改了内部 DOM 结构结果快照测试和交互测试同时翻车。组件库测试不是给自己看的是给所有使用方当安全网用的这决定了它的标准和粒度都不一样。1.2 TinyVue 这种跨框架组件库的测试难点TinyVue 组件库有个特点它的核心逻辑层做成 renderless也就是无渲染逻辑层通过依赖注入把 UI 和业务逻辑解耦底层同时兼容 Vue2 和 Vue3。这个架构本身很优雅但给测试工具链提了两个难题。第一同一套逻辑要在两套运行时下各自过一遍测试。Vue2 和 Vue3 的响应式原理不同Vue Test Utils 的 API 也有差异配置要能同时喂饱两套环境。第二renderless 逻辑层本身是纯 TypeScript这反而是个测试突破口很多业务逻辑可以不挂载 DOM直接对逻辑层做纯函数级别的单测跑起来又快又稳。我的思路是逻辑层测试用 Vitest 原生跑UI 表现层用 Vue Test Utils 挂载真实组件跑E2E 层再用 Playwright 去验证完整链路。三层各管各的互不干扰这也是后面所有配置的指导思想。2. 单测工具选型与基础配置2.1 为什么选 Vitest 而不是 Jest现在给 Vue 组件库配单测绕不开一个选择题Jest 还是 Vitest。Jest 在老项目里依然是霸主生态成熟但如果你新起一个 Vite 工程硬上 Jest 会遭遇两套编译管线的冲突Vite 用 esbuild 转译Jest 默认走 Babel配置起来非常割裂。Vitest 跟 Vite 共享配置和插件机制原生支持 ESM 和 TypeScript启动速度和 HMR 式的 watch 模式体验非常爽尤其适合组件库这种用例数量庞大的场景。选 Vitest 还有一个现实原因Vue 官方生态已经明显往 Vite 倾斜vitejs/plugin-vue 对 SFC 编译的支持比 Jest 那条链路顺滑太多。TinyVue 组件库的构建也在 Vite 体系内用 Vitest 就不用维护两套依赖和两份复杂配置。当然Vitest 也不是完全没有坑早期版本对jest-dom这类断言库的兼容性一般现在这些都已经解决了可以放心用。2.2 一份兼容 Vue3 的 Vitest 配置模板先给一份我在 Vue3 侧实际在用的最简配置你可以直接复制到项目里改路径就行// vitest.config.ts import { defineConfig } from vitest/config import vue from vitejs/plugin-vue import { fileURLToPath, URL } from node:url export default defineConfig({ plugins: [vue()], resolve: { alias: { : fileURLToPath(new URL(./packages, import.meta.url)) } }, test: { environment: jsdom, globals: true, include: [packages/**/__tests__/**/*.spec.ts], setupFiles: [./scripts/test-setup.ts], css: false, mockReset: true, coverage: { provider: v8, include: [packages/**/src/**/*.{ts,vue}], exclude: [**/__tests__/**, **/demo/**, **/types/**], thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 } } } })这里要解释几个关键项。environment: jsdom提供 DOM 环境如果不设置组件挂载会直接报错globals: true允许你在用例里直接写describe、it、expect不用每个文件都 import 一遍缺点是丧失显式依赖这个看团队偏好css: false表示忽略样式文件导入如果你断言里需要验证 CSS 类名是否生效再单独处理mockReset: true保证每个用例执行前自动重置 mock避免用例之间互相污染。coverage里我用了 v8 提供器好处是不需要额外转译比 istanbul 快不少。阈值设 80% 是组件库能接受的起始线别一上来就设 95%否则你的 CI 会在刚起步的时候天天报红士气直接崩掉。2.3 兼容 Vue2 场景的配置补充TinyVue 要同时兼容 Vue2这就得引入vite-plugin-vue2插件。Vue2 侧配置和 Vue3 侧差异很大核心是插件替换// vitest.config.vue2.ts import { defineConfig } from vitest/config import { createVuePlugin } from vite-plugin-vue2 export default defineConfig({ plugins: [createVuePlugin()], test: { environment: jsdom, globals: true, include: [packages/**/__tests__/vue2/**/*.spec.ts], setupFiles: [./scripts/test-setup.vue2.ts] } })然后通过 npm script 分别跑各自的配置{ scripts: { test: npm run test:vue3 npm run test:vue2, test:vue3: vitest run --config vitest.config.ts, test:vue2: vitest run --config vitest.config.vue2.ts } }这里有个容易踩的坑Vue2 组件里某些 API 和 Vue3 不一样比如$listeners、$scopedSlots如果你的组件在两个版本下引入了不同的适配代码测试目录最好也按版本分开用__tests__/vue2和__tests__/vue3这种目录隔离避免同一个用例跑在错误的运行时报出一堆莫名其妙的错误。2.4 跑通第一个组件测试用例配置不是写了就完得跑通一个真实用例验证链路。以 TinyVue 的 Button 按钮组件为例最基础的交互测试长这样import { describe, it, expect } from vitest import { mount } from vue/test-utils import Button from ../src/button.vue describe(Button, () { it(点击时触发 click 事件, async () { const wrapper mount(Button, { props: { type: primary } }) await wrapper.trigger(click) expect(wrapper.emitted(click)).toHaveLength(1) }) it(渲染默认插槽内容, () { const wrapper mount(Button, { slots: { default: 提交 } }) expect(wrapper.text()).toContain(提交) }) it(disabled 状态下不可点击, async () { const wrapper mount(Button, { props: { disabled: true } }) await wrapper.trigger(click) expect(wrapper.emitted(click)).toBeUndefined() }) })这三个用例覆盖了组件测试最常见的三类断言事件派发、插槽渲染、状态拦截。注意第二个用例里如果组件根节点有v-if之类的条件渲染建议用wrapper.findComponent或wrapper.find先定位再断言别直接text()一把梭否则断言会非常脆弱。只要跑出类似Test Files 1 passed | 1 failed的输出说明工具链已经通了。3. E2E 测试与 Playwright 配置3.1 组件库为什么不能只靠单测单测能验证组件逻辑但它有一个致命盲区验证不了组件在真实浏览器里的表现。比如日期选择器点开面板后点击外部区域是否正常关闭下拉框在滚动容器里会不会飘出可视区弹窗的 z-index 层级是否被业务页面里的元素盖住。这些问题单测很难模拟因为 jsdom 没有真实的布局引擎你拿不到真实的元素尺寸和滚动位置。所以组件库需要第二层防线E2E 测试。这部分我选的是 Playwright它比 Cypress 后来的一个重要优势是原生支持多浏览器Chromium、Firefox、WebKit而且测试隔离机制做得很干净每个用例自动开新 context互不污染。对组件库来说跨浏览器兼容是硬需求不用 Playwright 你可以试试手动维护三套浏览器环境那酸爽没法形容。3.2 Playwright 在组件库项目里的配置组件库做 E2E 和一个独立应用做 E2E 略有不同组件库没有一个天然的宿主页面得先搭一个演示站点或者测试专用页面让每个组件有一个可访问的 URL。TinyVue 仓库里有 demo 站点E2E 测试就跑在这个站点之上。Playwright 配置长这样// playwright.config.ts import { defineConfig, devices } from playwright/test export default defineConfig({ testDir: ./e2e, timeout: 30_000, fullyParallel: true, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, reporter: [[list], [html, { open: never }]], use: { baseURL: http://localhost:5173, trace: on-first-retry, screenshot: only-on-failure }, projects: [ { name: chromium, use: { ...devices[Desktop Chrome] } }, { name: firefox, use: { ...devices[Desktop Firefox] } }, { name: webkit, use: { ...devices[Desktop Safari] } } ], webServer: { command: npm run dev:site, url: http://localhost:5173, reuseExistingServer: !process.env.CI, timeout: 120_000 } })几个字段解释一下。retries在 CI 里设为 2因为组件库 E2E 最容易受浏览器渲染时序影响偶发失败大概率是等待条件不够鲁棒留重试机会比直接挂构建划算。trace: on-first-retry会在重试时记录完整的浏览器操作轨迹排查问题的时候能看到每一步的 DOM 快照非常有用。webServer让 Playwright 在跑测试前自动起 demo 站点reuseExistingServer在本地开发时设成 true这样你本地已经在跑 dev server 就不会重复起一个。3.3 典型交互场景测试以日期选择器为例一个完整的 E2E 用例会这样写import { test, expect } from playwright/test test(日期选择器可以正常选择日期, async ({ page }) { await page.goto(/components/date-picker) const input page.locator(.tiny-date-editor input) await input.click() const panel page.locator(.tiny-date-picker-panel) await expect(panel).toBeVisible() // 点击当月 15 号 await panel.locator(td.tiny-date-table__cell, { hasText: 15 }).first().click() // 输入框应该回显选中的日期 await expect(input).toHaveValue(/\d{4}-\d{2}-15/) }) test(日期选择器点击外部关闭面板, async ({ page }) { await page.goto(/components/date-picker) const input page.locator(.tiny-date-editor input) await input.click() await expect(page.locator(.tiny-date-picker-panel)).toBeVisible() await page.locator(body).click({ position: { x: 10, y: 10 } }) await expect(page.locator(.tiny-date-picker-panel)).toBeHidden() })写组件库 E2E 有个和业务测试不同的习惯尽量用语义化的 locator比如getByRole、getByText少用 CSS 层级选择器。因为组件库内部 DOM 结构经常调整CSS 路径一改就挂而语义化 locator 更接近用户视角稳定性高很多。还有一个建议是给组件库的 demo 页面加上标准的组件标题锚点方便测试脚本快速定位。4. 覆盖率、CI 与配置优化实战4.1 覆盖率阈值这样定CI 才不会天天吵架覆盖率这东西定高了大家骂娘定低了形同虚设。我的经验是分阶段。第一个阶段把阈值定在 70%-80% 之间目的是先把整套链路跑通让团队形成改代码必须跑测试的肌肉记忆。第二阶段等单测数量积累到一定规模之后再逐步提高分支覆盖率尤其是面向使用方的公共 props每个分支都应该有测试覆盖。TinyVue 这种组件库覆盖率还有一个隐藏价值暴露死代码。之前我在排查某个组件时发现分支覆盖一直卡在 70% 上不去仔细一查发现有个theme属性在构建时被 CSS 变量方案替代了代码里留着兼容分支但实际不再生效这个分支删掉之后覆盖率和代码整洁度双提升。所以覆盖率不只是数字游戏它是在帮你审视哪些逻辑真的在被使用。4.2 CI 流水线里运行测试的注意事项本地跑测试和 CI 跑测试完全是两个世界。CI 环境默认是干净机器没有本地那些凭直觉装好的全局依赖所以首先要保证所有依赖都在package-lock.json/pnpm-lock.yaml里锁死CI 里一律用锁文件安装。我在组件库项目里常用两层测试流水线提交前跑单测合并前跑全量测试加 E2E。GitHub Actions 的示例大概是这样name: Test on: pull_request: branches: [main] jobs: unit-test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: 18 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test e2e-test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: 18 cache: pnpm - run: pnpm install --frozen-lockfile - run: npx playwright install --with-deps chromium - run: pnpm test:e2eCI 里跑 Playwright 有两点特别提醒。一是浏览器二进制必须显式安装npx playwright install --with-deps会连系统依赖一起装避免缺libgbm之类的问题二是如果仓库是 pnpm workspace缓存路径要设置对pnpm 的缓存目录通常不是node_modules而是全局 store配置不当会导致 CI 每次全量安装慢到怀疑人生。4.3 测试配置的细节优化与 Monorepo 联动组件库仓库通常是 monorepoTinyVue 也用了 workspace 组织多包结构。这种情况下Vitest 配置有几个联动点要处理。第一是路径别名。不同包之间互引如果 alias 配的是相对路径在 monorepo 里大概率会出问题建议 alias 统一指向源码目录并且每个包的入口都从源文件取不要从dist取否则你改源码但测试跑的是旧构建产物排查问题能排查到崩溃。第二是依赖预构建。Vitest 默认会对依赖做预打包优化但 monorepo 里某些 workspace 包可能没有被正确识别为外部依赖导致测试启动时报Cannot find module。这时候需要在test.deps.optimizer里显式配置或者手动在server.deps.inline里把需要内联处理的包加进去。第三是环境差异。如果你同时维护 Vue2 和 Vue3 两套测试建议把公共的测试工具函数抽成一个独立的测试工具包。比如生成 mock 数据、模拟主题切换的 helper两个环境下 import 同一份代码避免同样的逻辑写两遍还容易漂移。5. 常见问题与排查技巧实录5.1 常见报错速查表组件库测试配置过程中有几个报错出现的频率高到我已经能背下来了。整理成表格方便你直接查报错信息常见原因解决办法ResizeObserver is not definedjsdom/happy-dom 未实现该 API在 setup 文件里全局 mock ResizeObserverwindow.matchMedia is not a function部分 CI 环境缺少该 API在 setup 文件里给 window 挂 matchMedia mockCannot find module vue/test-utils依赖未安装或版本不兼容检查 Vue2/Vue3 对应的 vue/test-utils 版本Hydration node mismatchVue 服务端渲染相关测试时乱挂载导致确认组件测试用的是 mount 而非 createSSRAppgetComputedStyle(...).xxx is emptyjsdom 不执行真实样式计算改用 Playwright 跑样式相关断言Element is not visibleE2E 等待条件不满足改用toHaveText或toBeVisible等自动等待断言5.2 从时间组件测试挂掉说起日期选择器、时间线这类和时间强相关的组件测试最容易翻车。第一次跑 DatePicker 测试时周五下午调了半天本地全绿CI 全红。查了半天发现是测试里用了真实定时器CI 机器负载高时 setTimeout 的回调滞后了几百毫秒输入框还没回显日期断言就执行了。解决方案是统一用vi.useFakeTimers()控制时间并且在测试末尾vi.useRealTimers()还原。但这里有个匹配问题vue/test-utils里trigger触发事件是异步的配 fake timers 时有些逻辑会卡在微任务队列里导致事件不触发。我最后用的是flushPromises()配合vi.runAllTimers()双管齐下先用await flushPromises()把 Promise 队列清了再推进定时器稳定多了。这也是我在组件库手册里最想提醒后面人的一点。5.3 快照测试的正确打开方式很多团队喜欢把快照测试当救命稻草组件一多就无脑toMatchSnapshot()。这点务必慎重。快照测试对 UI 库有一个致命缺陷组件库里任何一个非功能性修改比如给根 div 加了个>
返回列表