00|把第一个组件测试跑起来
本页接在第一章的 Vite 实验环境之后。也可以在新空目录运行 npm create vue@3.23.0 vue-test-lab -- --typescript --bare,进入 vue-test-lab 后 npm install。使用 Node 24.15+ 的 24 系列,保留生成的锁文件。本章单元测试运行在 Node 中,用 jsdom 提供 DOM;真实布局、焦点路径和浏览器网络留给第三节 E2E。
安装与目录
在实验项目根安装下列固定版本。它们只属于学习项目:
npm install -D --save-exact vitest@4.1.6 @vue/test-utils@2.5.0 jsdom@27.4.0Vite 的 Vue 插件由第一章环境提供。测试使用独立配置,避免修改页面构建配置。新建 vitest.config.ts:
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
include: ['src/**/*.spec.ts'],
setupFiles: ['./src/test/setup.ts'],
restoreMocks: true
}
})plugins 把 SFC 编译成 JavaScript;environment 让挂载时有 document;include 决定找哪些测试文件。缺少它们时,“无法解析 .vue”“document is not defined”“No test files found”分别发生在不同层,不能靠改组件处理。
新建 src/test/setup.ts:
import { afterEach, vi } from 'vitest'
import { enableAutoUnmount } from '@vue/test-utils'
enableAutoUnmount(afterEach)
afterEach(() => vi.useRealTimers())自动卸载清理 Vue 组件及其生命周期资源;恢复真实计时器防止一个用例冻结后面的时间。它们不能替应用清理遗漏的监听器,所以仍要测试 onUnmounted 的行为。
从一条能失败的断言开始
新建 src/Counter.vue:
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>
<template><button @click="count++">{{ count }}</button></template>新建 src/Counter.spec.ts:
import { mount } from '@vue/test-utils'
import { expect, test } from 'vitest'
import Counter from './Counter.vue'
test('一次点击增加一次', async () => {
const wrapper = mount(Counter)
expect(wrapper.get('button').text()).toBe('0')
await wrapper.get('button').trigger('click')
expect(wrapper.get('button').text()).toBe('1')
})运行:
npx vitest run src/Counter.spec.ts应有一条通过。把组件改成 count += 2 再跑,第二条断言应报告实际 2、期望 1,进程失败;恢复后再继续。这样证明测试确实守住了行为。test 登记用例,expect 在条件不符时抛出失败;mount 返回包装器,get 找不到节点也会直接失败。
等待的对象不同
await wrapper.trigger()/setValue():派发事件,再等 Vue 提交本轮更新。await flushPromises():让已能继续的 Promise 链执行;不会自动 resolve 一个永远 pending 的请求。vi.useFakeTimers()+await vi.advanceTimersByTimeAsync(ms):推进受控计时器,也处理过程中派生的异步回调;用完恢复真实时间。
可控请求使用 src/test/deferred.ts:
export function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((ok, bad) => { resolve = ok; reject = bad })
return { promise, resolve, reject }
}先挂载并断言等待态,再手动 resolve/reject,最后 flush。控制的是事件发生顺序,不是猜机器需要多少毫秒。下一节用这个方法验证保存与可取消搜索。
练习:证明清理发生
给 Counter 增加 onUnmounted(() => emit('closed')),在测试里调用 wrapper.unmount(),断言 wrapper.emitted('closed') 恰好只有一项。答案见 环境练习。