Vitest 实战:从一个真实项目看懂测试套件

我在一个 Vue 3 + Vite + TS 的多租户平台项目里用 vitest 做单元测试:19 个测试文件、120 个用例,全量跑完 8.31 秒。这篇文章按真实的使用顺序走一遍------从 npm i -D vitest 开始,到测试挂进 pre-push 门禁为止,中间的每条命令、每行配置、每段输出都来自这个项目,你可以开着自己的项目照着做。

安装:两条命令,零配置就能跑

vitest 2.x 要求 Node 18 以上,装它只要两条命令:

arduino 复制代码
npm i -D vitest
npx vitest run

第一条把 vitest 装进 devDependencies------测试工具只在开发时用,不进生产依赖。第二条直接跑。

跑的是什么呢?.test.ts.spec.ts 就是普通的 TypeScript 文件,唯一的区别在命名:文件名里带了 .test.spec。这是测试圈的命名约定,vitest 沿用它------见到这种名字的文件就当作测试来执行。两个后缀没有功能差别,.spec 是 specification(规格)的缩写,有的团队用它表达"这个文件描述组件的行为规格",我的项目统一用 .test

它和被测代码的关系,拿我的项目里真实的一对文件看:

bash 复制代码
src/utils/resourceValidation.ts        ← 被测代码:两个格式校验函数,参与打包上线
src/utils/resourceValidation.test.ts   ← 测试文件:只验证上面这个函数的行为

测试文件里的代码平时不运行,只有 vitest 执行时才跑,里面写的就是"调用被测函数,检查返回值对不对"。文件放哪也随意------跟源码同目录(像上面这样),或者集中放到 tests/ 目录,vitest 都能找到;项目的命名和位置偏好,写进配置文件的 include 就行。

我的项目里还装了两个伴生包:

bash 复制代码
npm i -D @vitest/ui @vitest/coverage-v8

@vitest/uivitest --ui 命令的可视化面板,@vitest/coverage-v8--coverage 覆盖率统计的实现。它们只在对应参数出现时才被加载------平时跑测试,启动过程完全不碰这两个包,零开销。

装齐了,回头执行第二条命令 npx vitest run:项目里一个测试都还没有的话,会得到 "No test files found"------正常,第三节就来写第一个。

配置:vitest.config.ts 每一行在做什么

零配置能跑通简单场景,项目一复杂就需要配置文件。我的项目根目录下有一份 vitest.config.ts(真实文件有删节,只留要讲的行):

php 复制代码
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'happy-dom',
    globals: true,
    setupFiles: ['./tests/setup.ts'],
    coverage: {
      provider: 'v8',
      thresholds: {
        lines: 70, functions: 70, branches: 65, statements: 70
      }
    },
    include: ['tests/**/*.test.ts', 'tests/**/*.spec.ts', 'src/**/*.test.ts'],
    testTimeout: 10000
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  }
})

逐行看它解决了什么。

plugins: [vue()]------测试会 import .vue 组件,.vue 文件的编译靠 Vite 管线,这个插件就是管编译的。纯函数测试用不到它,但项目里只要有组件测试就得挂着。

environment: 'happy-dom'------vitest 默认跑在 Node 里,Node 没有 window、没有 document。组件测试要操作页面元素,所以给它一个纯 JS 实现的假浏览器。如果项目里全是纯函数测试,这行可以不配。

globals: true------打开后 describetestexpect 全局可用,测试文件不用逐个 import。不开也行,我的项目开着它但仍然每个文件显式 import,让依赖写在明面上。

include------告诉 vitest 去哪找测试:tests/ 目录下,或者跟源码同放一个 .test.ts。我的项目两种都有:工具函数的测试挨着源码放,页面级的集中在 tests 目录。

coverage.thresholds------覆盖率门槛,四项指标任何一项没到,vitest run --coverage 就以失败退出。这个行为最后一节会用到。

resolve.alias------测试文件里写 import xxx from '@/utils/http',靠这里把 @ 解析到 src 目录。别名在 vite.config.ts 里也有一份,两份各写各的。

为什么单独一份配置,不写进 vite.config.ts?vitest 会优先读 vitest.config.ts,有它就不再看 vite 的。分开后,主配置里挂的 VueDevTools、UnoCSS、自动导入、gzip 压缩这些测试用不上的插件,测试管线可以不带,只留 vue() 一个。

写第一个测试:从函数签名推用例

src/utils/resourceValidation.ts 里一个真实函数当例子。它校验菜单 url 的格式:

typescript 复制代码
export function isValidResourceUrlFormat(value: string | null | undefined): boolean {
  if (!value) return true
  return RESOURCE_URL_PATTERN.test(value)
}

写测试前先读签名和实现,用例跟着就出来了。签名是 string | null | undefined------null 和 undefined 是调用方真会传的值,而函数对空值返回 true(源码注释写着"空值放行,必填性由类型联动负责"),空值放行就成了第一组用例。正常逻辑是正则整体匹配,合法形态是 /base/dp-group/list 这种:斜杠开头、全小写 kebab、至少两级------正例从合法形态里挑。反方向再列出该拒绝的:大写、下划线、.vue 后缀、只有一级、双斜杠、中文。

写进同目录的 src/utils/resourceValidation.test.ts

scss 复制代码
import { describe, expect, it } from 'vitest'
import { isValidResourceUrlFormat } from './resourceValidation'

describe('isValidResourceUrlFormat(菜单 url 格式)', () => {
  it('空值放行(必填性由类型联动校验负责)', () => {
    expect(isValidResourceUrlFormat('')).toBe(true)
    expect(isValidResourceUrlFormat(null)).toBe(true)
    expect(isValidResourceUrlFormat(undefined)).toBe(true)
  })

  it('全小写 kebab、至少两级通过', () => {
    expect(isValidResourceUrlFormat('/base/dp-group/list')).toBe(true)
    expect(isValidResourceUrlFormat('/platform/org/list')).toBe(true)
    // ......正例共 6 条,完整见源文件
  })

  it('大写/下划线/.vue 后缀/单段/缺斜杠/双斜杠/尾斜杠/非 ASCII 拒绝', () => {
    expect(isValidResourceUrlFormat('/Platform/Org/List')).toBe(false)
    expect(isValidResourceUrlFormat('/base/dp_group')).toBe(false)
    expect(isValidResourceUrlFormat('/base/x/list.vue')).toBe(false)
    expect(isValidResourceUrlFormat('/org')).toBe(false)
    expect(isValidResourceUrlFormat('/base//list')).toBe(false)
    expect(isValidResourceUrlFormat('/基础/数据')).toBe(false)
    // ......反例共 8 条,完整见源文件
  })
})

用到的语法就三个词。describe 把一组相关用例圈起来,这里按被测函数分组;it 定义一个用例;expect(实际值).toBe(期望值) 做断言。用例名写成对行为的描述------"空值放行(必填性由类型联动校验负责)"这个标题把"空值为什么放行"的设计决策记在了永远会执行的地方。

跑一下:

arduino 复制代码
npx vitest run src/utils/resourceValidation.test.ts

真实输出:

scss 复制代码
✓ src/utils/resourceValidation.test.ts (6 tests) 17ms

 Test Files  1 passed (1)
      Tests  6 passed (6)
   Duration  5.62s

6 个用例的断言本身只跑了 17ms,总耗时 5.62s,差的都是启动开销:起假浏览器(environment 1.81s)、编译加载、拉起进程。

再看两个业务测试:菜单权限和接口约定

resourceValidation 是纯函数,好懂但离业务远。项目里另有两个测试更能说明测试怎么长在业务上,拆开看。

案例一:菜单权限继承(menuScope.test.ts)

业务背景:这是个多租户平台,菜单树上每个节点带一个 scope 属性------PLATFORM(平台级)或 TENANT(租户级),决定这个菜单归平台管理员管还是租户管理员管。菜单管理的表单里有两条绕不开的规则:新增菜单时,归属范围从所选上级回填;编辑菜单时,"上级"候选树里必须禁用自己和自己的整个后代------否则能把一个菜单挂到自己子节点的下面,树就成环了。

src/views/Modules/System/Menu/components/menuScope.test.ts 测了四组函数,这里拿最能体现业务规则的两 组看:inheritScope 和 buildParentOptions。先看它怎么搭数据。MenuItem 类型有十几个字段,测试只关心其中四个,于是先用一个工厂填满默认值:

bash 复制代码
const mk = (overrides: Partial<MenuItem>): MenuItem => ({
  id: '', pid: '0', name: '', url: '', title: '',
  etitle: '', sort: 0, type: 'menu', icon: '',
  permissions: '', visible: 1, state: 1,
  ...overrides
})

再用它搭一棵两分支的迷你树,全文件的用例都跑在这棵树上:

php 复制代码
const tree: MenuItem[] = [
  mk({ id: 'p1', title: '平台管理', scope: 'PLATFORM', children: [
    mk({ id: 'p1-1', pid: 'p1', title: '系统配置', scope: 'PLATFORM', children: [
      mk({ id: 'p1-1-btn', pid: 'p1-1', title: '保存按钮', type: 'button', scope: 'PLATFORM' })
    ] })
  ] }),
  mk({ id: 't1', title: '系统管理', scope: 'TENANT', children: [
    mk({ id: 't1-1', pid: 't1', title: '数据字典', scope: 'TENANT' })
  ] })
]

inheritScope 的用例组把回填规则一条条钉死:

scss 复制代码
describe('inheritScope(新增换上级的 scope 回填)', () => {
  it('顶级(pid=0)回退 TENANT', () => {
    expect(inheritScope('0', tree)).toBe('TENANT')
  })
  it('空 pid 回退 TENANT', () => {
    expect(inheritScope('', tree)).toBe('TENANT')
  })
  it('取父级自身携带的 PLATFORM(不做层级继承解析)', () => {
    expect(inheritScope('p1', tree)).toBe('PLATFORM')
    expect(inheritScope('p1-1', tree)).toBe('PLATFORM')
  })
  it('取父级自身携带的 TENANT', () => {
    expect(inheritScope('t1', tree)).toBe('TENANT')
  })
  it('父级缺失 scope 或 pid 不在树内时回退 TENANT', () => {
    expect(inheritScope('t1', [mk({ id: 'x1' })])).toBe('TENANT')
    expect(inheritScope('ghost', tree)).toBe('TENANT')
  })
})

看"取父级自身携带的 PLATFORM(不做层级继承解析)"这条名字------括号里是设计决策:这个函数只看直接上级,不递归往上找。将来有人把它改成递归继承,这条用例就红,逼他先确认这是不是有意的变更。脏输入也没放过:父级不在树里、缺 scope,兜底回 TENANT 的行为也都各有用例验过。

防环那条规则的用例是这样的:

scss 复制代码
it('编辑态禁用自身及其整个后代子树,兄弟分支不受影响', () => {
  const options = buildParentOptions(tree, true, 'p1')
  const platformRoot = options.find(node => node.id === 'p1')
  expect(platformRoot?.disabled).toBe(true)
  expect(platformRoot?.children?.[0].disabled).toBe(true)
  const tenantRoot = options.find(node => node.id === 't1')
  expect(tenantRoot?.disabled).toBe(false)
})

注意它断言了"兄弟分支不受影响"------该禁的被禁了,不该禁的也没被禁。防环这种规则最怕改坏一半,这条用例两头都看住了。

案例二:/api 前缀约定(app.test.ts)

这个项目后端是七个微服务(sso、enttze、public、ai、ops、base、platform),团队约定所有请求走统一的 /api/<服务名> 前缀,Vite 的开发代理按这个前缀转发。src/config/app.test.ts 把这条约定直接写成了测试,最有代表性的是参数化写法:

scss 复制代码
// 七个服务:[配置键名, url 路径段](enttze 服务的后端前缀是 etze)
const services = [  ['sso', 'sso'], ['enttze', 'etze'], ['public', 'public'],
  ['ai', 'ai'], ['ops', 'ops'], ['base', 'base'], ['platform', 'platform']
]

it.each(services)('%s 服务的 baseURL 以 /api/%s 结尾(单层前缀)', (service, segment) => {
  const baseURL = APP_CONFIG.http.baseURLs[service]
  expect(baseURL, `VITE_API_${service.toUpperCase()}_URL 未配置`).toBeTruthy()
  expect(baseURL.endsWith(`/api/${segment}`)).toBe(true)
})

it.each 把七个服务喂给同一段逻辑,约定写一次、七个全验。断言的第二个参数是自定义报错信息------失败时直接告诉你哪个服务的环境变量没配,不用翻代码找。

这个文件里还有一个更特别的用例,测的不是运行时行为,而是源码本身:

javascript 复制代码
it('上报消费方不得硬编码上报 URL,必须引用 APP_CONFIG(防配置与实现脱节)', () => {
  const consumers = [
    'utils/logger.ts', 'utils/errorHandler.ts',
    'services/ErrorReportingService.ts', 'main.ts'
  ]
  const forbiddenLiterals = ["'/api/log/", "'/api/platform/log/", "'/api/analytics"]
  for (const rel of consumers) {
    const source = readFileSync(join(here, '..', rel), 'utf-8')
    for (const literal of forbiddenLiterals) {
      expect(source, `${rel} 不得硬编码上报 URL`).not.toContain(literal)
    }
    expect(source, `${rel} 须引用 APP_CONFIG.logging`).toContain('APP_CONFIG.logging')
  }
})

它用 readFileSync 把 logger、errorHandler 这些上报相关的源文件当文本读进来,断言里面没有写死的 URL、都引用了配置对象。将来有人在 errorHandler 里把上报地址写死一个,测试立刻红------编码约定由测试守着,不再指望 code review 时有人眼尖。

平时怎么跑:命令清单和自动触发

日常写码用 watch 模式,也就是不带参数的 npx vitest。它先跑一遍全量,然后不退出,盯着文件系统:你改了哪个文件,它只重跑和这个文件相关的测试。相关与否的判断依据是 import 关系------第一遍全量时 vitest 记下了"谁引用谁",之后改了 resourceValidation.ts,所有直接或间接引用它的测试都会重跑,没有引用关系的不动。所以改一个工具函数,秒级就有反馈;改一个被大量测试依赖的文件(比如 HTTP 封装),重跑范围就大得多。

两个提醒。第一,watch 的绿屏只覆盖"本轮跑过的测试",没被重跑的这次根本没跑,全量结论要靠 vitest run。第二,跑的时候可能看到成屏的红色报错,但末尾结果仍是全绿------那些只是测试过程打印到标准错误输出的日志,vitest 判定成败只看断言和未处理异常,不看 stderr。被红字吓到时,先看末尾的 Test Files / Tests 两行。

除 watch 之外,常用的跑法收进一张表:

命令 干什么
npx vitest run 跑完全量就退出,退出码 0 通过、非 0 失败,排在其后的命令才能继续
npx vitest run src/utils/resourceValidation.test.ts 只跑指定文件,也认目录和名字片段
npx vitest run -t "空值放行" 按用例名过滤,只跑名字匹配的用例
npx vitest related src/utils/http.ts 只跑依赖了指定文件的那些测试
npx vitest run --changed 只跑和最近改动相关的测试,可指定对比的提交范围
npx vitest --ui 打开浏览器面板,用例树、耗时、失败详情点开看(需装 @vitest/ui
npx vitest run --coverage 跑完顺带统计覆盖率并检查门槛(需装 @vitest/coverage-v8

vitest 的核心动作就一个------跑测试,这些命令的差别在触发范围(全部、单个、按名字、按依赖关系、按改动)和附加产物(面板、报告)。--coverage 展开说一句:我的项目实测行覆盖率 10.73%,离配置的 70% 差距很大,命令直接以失败退出。覆盖率低的原因是测试集中在 utils、services、composables 这些决策逻辑上,views 里六七百行的页面文件(表格、表单的组装代码)一行没测,这是统计范围的问题,不是缺测试的问题,门槛想真正用起来得先把统计范围调对。

这些命令平时不用敲全称。项目里通常把它们配成 npm scripts,我的项目 package.json 里的真实配置:

json 复制代码
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"verify": "npm run lint:check && npm run type-check && npx vitest run"

之后日常就是 npm test(watch)、npm run test:uinpm run test:coverage,一条短命令一个含义。注意 scripts 里写 vitest 不用加 npx:npm run 执行时会把 node_modules/.bin 加进 PATH,本地装的 vitest 直接就能找到(verify 里那个 npx 其实在这个上下文里可以省略)。npxnpm run 的分工由此清楚:npx 是临时执行依赖包里的命令,不配 script 也能跑;npm run 是执行 package.json 里定义好的命令,项目里的日常用后者。

对着项目的真实场景把命令串一遍。写代码时开着 npm test,保存文件即反馈;快速验证某个文件,就 npx vitest run src/utils/resourceValidation.test.ts(第三节演示过);某个用例挂了要专注修,加 -t "空值放行" 只跑这一个;推送前自己先排雷,npm run verify 把 lint、类型、测试三关一起过;评估覆盖率现状,npm run test:coveragerelated--changed 我的项目目前还没用上------它们的价值在多人协作的 CI 里,只跑与本次 PR 改动相关的测试,省机器时间。

vitest 也不只能手动敲。把这个项目里的自动跑全捋一遍,链路是这样的:

触发时机 自动发生什么 靠什么实现
保存文件 自动重跑相关测试 npm test(watch)开着
git commit 只跑 eslint --fix + prettier,秒级 .husky/pre-commit → lint-staged
git push lint + 类型检查 + 全量测试,失败则推送中断 .husky/pre-pushnpm run verify
代码到远端 CI 再跑一遍质量门禁 + 构建 npm run ci

链路的设计逻辑:离写码越近,检查越轻------保存时只跑相关的测试,提交时只跑秒级的格式;越靠近仓库,检查越重------推送跑全量,CI 最后兜底。人在其中不需要记得"该跑测试了",写完代码提交推送就行,每道检查在自己的时机自动发生。想调整分配也简单:把对应的命令写进对应的钩子文件或流水线配置即可,比如想提交时就跑测试,把命令挪进 .husky/pre-commit 就行。另一个可选的加强是编辑器:VS Code 装 Vitest 官方扩展,保存时自动执行相关用例,连终端都不用开。

门禁里的测试:管的是行为对不对

推送时挂的 verify 链是 lint、type-check、vitest run 三步。测试在这里补的是前两步管不了的部分:lint 和类型检查再严格,也拦不住"能编译但逻辑改坏了"的代码------行为对不对,只有跑过才知道,120 个用例在每次推送前把已写下的行为约定重新验一遍。

为什么挂在 push 而不是 commit?第三节算过那笔账:全量跑一次有 5 秒多的固定启动开销,commit 一天几十次付不起,push 一天没有几次------贵的检查,放在低频的时机。

还有一道配了但没通电的门槛:thresholds 只在执行对应命令时生效,我的 verify 链里没有 --coverage,所以配置里那道 70% 的覆盖率门槛从来没在门禁里红过。想让覆盖率也把门,就把 npx vitest run --coverage 加进 verify 或 CI------配置了但没接进流程的门槛,等于没有。

到这里整条路就通了:npm i -D vitest 装上,vitest.config.ts 定环境和规则,第一个 .test.ts 把期望的行为写下来,业务里最容易改坏的决策逻辑被用例钉住,日常 watch 秒级反馈,推送时 run 全量兜底。组件测试还需要挂载组件的工具(@vue/test-utils),那是 vitest 之外的另一块。

相关推荐
律宏阔25 分钟前
Electron Forge 在 macOS M4 打包踩坑:Node.js 26 导致 make 异常中断
前端
木木爱研究28 分钟前
elpis-里程碑四-基于Vue完成动态组件库建设
前端·后端
深念Y31 分钟前
Nuxt 项目 Docker 构建:从 pnpm+node 迁移到全 bun
java·前端·docker
qq_4260039632 分钟前
UI自动化元素定位不到解决方案
前端·pycharm·自动化
EthanChou202039 分钟前
AES67协议笔记
前端·笔记·es6
马优晨40 分钟前
pom.xml 中 jquery webjars 依赖解释
xml·前端·jquery·jquery webjars·webjars依赖
里欧跑得慢1 小时前
AI 驱动的 UI 国际化方案自动适配:从单一语言到多语言布局的智能转换
前端·css·flutter·web
DolitD1 小时前
云流技术深度剖析:单服务器下如何实现3D应用的多实例并发?
java·服务器·前端·3d·云原生·云计算
芦柑4641 小时前
画布和3D导演台工具:短剧分镜从素材整理到空间预演的完整链路
服务器·前端·数据库