Vue 基础体系 · 第 49/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 单元测试:Vitest、Composable、时间、网络和稳定断言
在 Vue 3 与现代 Vite 工具链中,单元测试通常由三部分组成:
- Vitest:负责发现和执行测试、创建 Mock、控制时间、判断断言。
- Vue Test Utils:负责把组件或 Composable 放入 Vue 运行时,并观察 DOM、事件和生命周期。
- 测试环境:例如
jsdom,为依赖浏览器 API 的代码提供window、document、fetch等运行环境。
这三者解决的问题不同。Vitest 不理解 Vue 的生命周期,Vue Test Utils 也不负责替代断言库;如果测试中涉及时间、网络或异步更新,还必须明确哪些部分是真实执行的,哪些部分被测试替身控制。
本文示例基于 Vue 3、Composition API、TypeScript、Vite 和 Vitest。Vitest 的具体 API 会随版本演进,示例使用现代 Vitest 中稳定且常用的 vi、expect、Fake Timers 和 jsdom 能力。
一、先建立测试对象模型
1. 单元测试究竟在验证什么
一个单元可以形式化为一个函数:
其中:
- 是显式输入,例如函数参数、组件
props; - 是外部依赖,例如当前时间、网络、随机数、浏览器 API;
- 是输出,包括返回值、状态变化、DOM、事件或异常。
稳定的单元测试希望把外部依赖固定为可控值:
于是同一个输入 应得到相同的可观察结果:
例如,一个“根据用户 ID 请求用户”的 Composable:
const user = await loadUser('42')
它的输入不只是 '42',还包括:
- 网络是否可用;
- 服务端返回什么;
- 请求是否延迟;
- 当前组件是否仍然存活;
- 是否有另一个请求后来发出。
如果直接访问真实服务,测试结果取决于外部系统;如果把请求函数作为依赖传入,测试就可以精确控制这些因素。
因此,单元测试不是简单地“调用函数然后判断结果”,而是要明确:
- 输入是什么;
- 外部依赖是什么;
- 状态如何变化;
- 哪些结果对使用者可见;
- 哪些实现细节不应该成为测试约束。
2. Vue 单元测试中的观察边界
Vue 代码的可观察结果通常有四种:
| 对象 | 典型输入 | 应观察的结果 |
|---|---|---|
| 纯函数 | 参数 | 返回值或异常 |
| Composable | ref、依赖函数、生命周期 |
响应式状态、调用次数、清理行为 |
| 组件 | props、用户事件、异步依赖 |
DOM、事件、子组件交互 |
| 网络适配层 | URL、请求参数 | 请求内容、成功/失败映射 |
测试应该围绕行为建立,而不是围绕私有变量建立。例如,与其断言组件内部存在 isLoading === true,不如断言加载指示器可见;与其断言调用了某个内部辅助函数,不如断言请求参数正确。
二、建立 Vue + Vitest 测试环境
1. 安装依赖
已有 Vue 3 和 Vite 项目的情况下,可以安装:
npm install -D vitest jsdom @vue/test-utils
如果项目还没有 Vue 的 Vite 插件,通常还需要:
npm install -D @vitejs/plugin-vue
@vue/test-utils 是 Vue 官方测试工具链中的组件测试库;jsdom 提供近似浏览器的 DOM 环境。jsdom 不是完整浏览器,它不会真实布局、绘制,也不能保证所有浏览器 API 都存在。
2. 配置 Vite
vite.config.ts:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
clearMocks: true,
restoreMocks: true,
},
})
各项配置的作用不同:
environment: 'jsdom':让测试可以访问document、window等 DOM API。globals: true:允许直接使用describe、it、expect,无需每个文件显式导入。setupFiles:在测试文件执行前运行公共初始化代码。clearMocks: true:每个测试前清除 Mock 的调用记录,但不一定恢复被修改的实现。restoreMocks: true:恢复通过vi.spyOn等方式替换的实现。
clearMocks 和 restoreMocks 不等价。前者主要清除“调用历史”,后者恢复“被替换的对象行为”。如果使用了 Fake Timers,仍然要在测试结束后显式调用 vi.useRealTimers()。
tests/setup.ts 可以先保持为空:
// tests/setup.ts
3. 添加测试脚本
package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
常用命令:
npm test
启动监听模式,文件变化后重新执行相关测试。
npm run test:run
执行一次后退出,适合 CI。
不要把“本地监听模式没有退出”误认为测试卡住;监听模式本来就会持续运行。CI 通常应使用 vitest run。
三、Vitest 的基本执行模型
1. 测试由三层组成
一个最小测试如下:
import { describe, expect, it } from 'vitest'
function add(a: number, b: number) {
return a + b
}
describe('add', () => {
it('returns the sum', () => {
expect(add(2, 3)).toBe(5)
})
})
执行过程可以理解为:
- Vitest 加载测试模块;
- 注册
describe和it中的测试; - 执行测试函数;
expect读取实际值;- 匹配器判断实际值是否满足预期;
- 测试函数抛出异常或返回拒绝的 Promise 时,测试失败。
expect 本身不会让异步测试自动等待。下面的写法是错误的:
it('handles async result', () => {
expect(Promise.resolve(42)).resolves.toBe(42)
})
测试函数没有返回这个 Promise,测试框架可能在断言完成前就结束。正确写法是使用 async 和 await:
it('handles async result', async () => {
await expect(Promise.resolve(42)).resolves.toBe(42)
})
或者直接返回:
it('handles async result', () => {
return expect(Promise.resolve(42)).resolves.toBe(42)
})
2. toBe 与 toEqual 的差别
expect(actual).toBe(expected)
toBe 适合原始值,也使用 JavaScript 的 Object.is 语义比较对象引用:
expect(1 + 1).toBe(2)
expect({ id: 1 }).not.toBe({ id: 1 })
最后一条失败,因为两个对象不是同一个引用。
expect(actual).toEqual(expected)
toEqual 递归比较对象结构:
expect({ id: 1, name: 'Ada' }).toEqual({
id: 1,
name: 'Ada',
})
如果只关心部分字段,应使用部分匹配:
expect(user).toEqual(
expect.objectContaining({
id: '42',
name: 'Ada',
}),
)
这比完整比较整个对象更稳定,因为服务端以后增加 avatar、createdAt 等字段时,不会让与当前行为无关的测试失败。
四、Composable 的测试:区分纯逻辑和生命周期
1. Composable 是什么
Composable 是一个封装 Composition API 状态与逻辑的函数,通常命名为 useXxx:
const { count, increment } = useCounter()
如果 Composable 只使用 ref、计算和普通函数,它可以直接调用:
import { ref } from 'vue'
export function useCounter() {
const count = ref(0)
function increment() {
count.value += 1
}
return { count, increment }
}
测试:
import { describe, expect, it } from 'vitest'
import { useCounter } from '@/composables/useCounter'
describe('useCounter', () => {
it('increments count', () => {
const { count, increment } = useCounter()
expect(count.value).toBe(0)
increment()
expect(count.value).toBe(1)
})
})
这里不需要挂载组件,因为 useCounter 没有调用 onMounted、onUnmounted、provide 或依赖当前组件实例。
2. 为什么带生命周期的 Composable 不能随意直接调用
下面的 Composable 在挂载时启动定时器,在卸载时清理:
import { onMounted, onUnmounted, ref } from 'vue'
export function useClock() {
const now = ref(new Date())
let timer: ReturnType<typeof setInterval> | undefined
onMounted(() => {
timer = setInterval(() => {
now.value = new Date()
}, 1000)
})
onUnmounted(() => {
if (timer !== undefined) {
clearInterval(timer)
}
})
return { now }
}
如果直接执行:
const { now } = useClock()
Vue 没有当前组件实例,生命周期钩子无法按预期注册。Vue 会发出警告,且测试不能验证挂载和卸载行为。
因此需要一个宿主组件把 Composable 放入真实的 setup() 生命周期中。
3. 编写最小宿主组件
import { defineComponent, h } from 'vue'
import { useClock } from '@/composables/useClock'
export const ClockHarness = defineComponent({
setup() {
const state = useClock()
return () =>
h('time', {
'data-testid': 'now',
datetime: state.now.toISOString(),
})
},
})
测试:
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { ClockHarness } from '@/composables/ClockHarness'
describe('useClock', () => {
afterEach(() => {
vi.useRealTimers()
})
it('updates once per second and clears the timer on unmount', () => {
vi.useFakeTimers()
const initial = new Date('2024-01-01T00:00:00.000Z')
vi.setSystemTime(initial)
const wrapper = mount(ClockHarness)
expect(wrapper.get('[data-testid="now"]').attributes('datetime'))
.toBe(initial.toISOString())
vi.advanceTimersByTime(1000)
const expected = new Date('2024-01-01T00:00:01.000Z')
expect(wrapper.get('[data-testid="now"]').attributes('datetime'))
.toBe(expected.toISOString())
wrapper.unmount()
expect(vi.getTimerCount()).toBe(0)
})
})
这段测试验证了完整生命周期:
vi.setSystemTime固定初始时间;mount触发onMounted,启动间隔定时器;advanceTimersByTime(1000)推进虚拟时间;- 定时器回调更新
ref; - Vue 更新 DOM;
unmount触发onUnmounted;- 定时器数量回到零。
这里的 vi.getTimerCount() 属于 Vitest Fake Timers 的辅助能力。它适合检查是否留下定时器,但不应成为所有测试的默认断言;更有价值的是验证卸载后不会继续更新状态或产生副作用。
五、响应式更新、Promise 和定时器不是同一个队列
Vue 测试中经常出现“逻辑已经执行,但 DOM 还没有更新”的情况。原因是至少存在三类调度:
- 宏任务:
setTimeout、setInterval; - 微任务:Promise 的
then、await后续; - Vue 更新队列:响应式状态变化后,组件通常在下一个 tick 更新 DOM。
例如:
const wrapper = mount({
template: '<button @click="count++">{{ count }}</button>',
setup() {
return { count: ref(0) }
},
})
await wrapper.get('button').trigger('click')
expect(wrapper.text()).toBe('1')
trigger 本身返回 Promise,等待它通常可以等待这次 Vue 更新。手动修改响应式变量时,可以使用:
import { nextTick } from 'vue'
state.value = 'done'
await nextTick()
如果还涉及已经解决的 Promise,可以使用 flushPromises:
import { flushPromises } from '@vue/test-utils'
await flushPromises()
await nextTick()
但二者解决的问题不同:
flushPromises():尽量让当前已排队的 Promise 回调完成;nextTick():等待 Vue 的响应式 DOM 更新。
如果使用 Fake Timers,异步定时器回调还应优先使用异步版本:
await vi.advanceTimersByTimeAsync(1000)
await flushPromises()
await nextTick()
原因是:
setTimeout(async () => {
await loadData()
state.value = 'done'
}, 1000)
推进 1000 毫秒只能触发 setTimeout,不能自动保证 loadData() 的 Promise 链和 Vue 更新全部完成。
六、时间测试:把“现在”变成显式依赖
1. 直接读取 Date.now() 的问题
下面的函数依赖系统当前时间:
export function isExpired(expiresAt: number) {
return Date.now() >= expiresAt
}
测试不能写成:
expect(isExpired(Date.now() + 1000)).toBe(false)
因为执行过程中时间可能跨过边界,测试结果有偶然性。
正确做法是控制时钟:
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isExpired } from '@/utils/isExpired'
describe('isExpired', () => {
afterEach(() => {
vi.useRealTimers()
})
it('returns false before expiration', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z'))
const expiresAt = new Date('2024-01-01T00:00:01.000Z').getTime()
expect(isExpired(expiresAt)).toBe(false)
})
it('returns true at the expiration boundary', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-01T00:00:01.000Z'))
const expiresAt = new Date('2024-01-01T00:00:01.000Z').getTime()
expect(isExpired(expiresAt)).toBe(true)
})
})
这里特别测试了边界条件:
不是:
两个实现只在恰好到期的那一刻不同。如果业务规定“到期时立即失效”,就必须验证等号边界。
2. Fake Timers 控制的是什么
Fake Timers 通常替换:
setTimeout;setInterval;- 对应的清理函数;
- 与当前时间相关的部分时间 API,具体覆盖范围取决于 Vitest 使用的时钟实现和配置。
典型操作:
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'))
vi.advanceTimersByTime(5000)
vi.runOnlyPendingTimers()
vi.runAllTimers()
vi.useRealTimers()
这些操作含义不同:
advanceTimersByTime(5000):把虚拟时间向前推进 5 秒;runOnlyPendingTimers():执行当前已经排队的定时器,不递归执行新产生的定时器;runAllTimers():持续执行直到没有定时器,递归定时器可能导致测试超时;useRealTimers():恢复真实时间和真实定时器。
一个无限递归定时器不应使用 runAllTimers():
setInterval(() => {
// 永远会继续产生下一次执行
}, 1000)
此时应使用有限次推进:
vi.advanceTimersByTime(3000)
3. 时间依赖的更好设计:注入时钟
Fake Timers 适合测试应用代码,但纯函数更容易通过依赖注入测试:
export function createIsExpired(now: () => number = Date.now) {
return (expiresAt: number) => now() >= expiresAt
}
测试:
import { describe, expect, it, vi } from 'vitest'
import { createIsExpired } from '@/utils/createIsExpired'
describe('createIsExpired', () => {
it('uses the injected clock', () => {
const now = vi.fn(() => 1_000)
const isExpired = createIsExpired(now)
expect(isExpired(1_000)).toBe(true)
expect(isExpired(1_001)).toBe(false)
expect(now).toHaveBeenCalledTimes(2)
})
})
这种写法的因果关系更直接:函数明确接收“当前时间来源”,测试不需要修改全局时钟。
经验上:
- 测试
setTimeout、setInterval和生命周期清理时,使用 Fake Timers; - 测试“给定现在时间,是否已经过期”这类纯逻辑时,优先注入时钟;
- 不要在同一个测试中随意混用真实时间与虚拟时间。
七、网络测试:验证请求协议,而不是访问真实服务
1. 把网络请求作为依赖
网络请求本质上是外部副作用。下面的 Composable 接收 fetchUser,因此业务状态逻辑与具体 HTTP 客户端解耦:
import {
onUnmounted,
ref,
type Ref,
watch,
} from 'vue'
export interface User {
id: string
name: string
}
export type FetchUser = (id: string) => Promise<User>
export function useUser(
id: Ref<string>,
fetchUser: FetchUser,
) {
const user = ref<User | null>(null)
const error = ref<unknown>(null)
const pending = ref(false)
let requestVersion = 0
async function reload() {
const version = ++requestVersion
pending.value = true
error.value = null
try {
const result = await fetchUser(id.value)
// 只有最后一次请求可以提交结果
if (version === requestVersion) {
user.value = result
}
} catch (err) {
if (version === requestVersion) {
error.value = err
}
} finally {
if (version === requestVersion) {
pending.value = false
}
}
}
const stop = watch(id, reload, { immediate: true })
onUnmounted(() => {
stop()
requestVersion += 1
})
return {
user,
error,
pending,
reload,
}
}
这里有一个重要的并发规则:
假设先后发出请求 A 和请求 B:
时间 ───────────────────────────────>
发出 A 发出 B
│ │
└───────┐ └──────────────┐
│ │
A 返回 B 返回
如果 A 比 B 更晚返回,且没有版本检查,旧响应会覆盖新响应。版本号检查会拒绝 A 的过期结果。
需要注意,这段代码拒绝的是“过期响应”,但没有真正取消网络请求。生产实现可以额外使用 AbortController,不过取消请求与忽略过期响应是两个不同问题:
- 忽略过期响应:保护状态提交;
AbortController:减少无意义的网络和服务端处理。
2. 为 Composable 建立测试宿主
import { defineComponent, h, ref } from 'vue'
import { useUser, type FetchUser } from './useUser'
export function createUserHarness(fetchUser: FetchUser) {
return defineComponent({
props: {
id: {
type: String,
required: true,
},
},
setup(props) {
const state = useUser(ref(props.id), fetchUser)
return () =>
h('section', [
h('span', { 'data-testid': 'pending' }, String(state.pending.value)),
h('span', { 'data-testid': 'name' }, state.user.value?.name ?? ''),
h('span', { 'data-testid': 'error' }, state.error.value ? 'error' : ''),
])
},
})
}
这里把 props.id 包装为 ref。真实项目中如果需要随着 prop 变化而更新,通常会使用 toRef(props, 'id');示例为了保持宿主组件短小,使用了独立 ref。如果要验证 prop 更新,应改为:
import { toRef } from 'vue'
const state = useUser(toRef(props, 'id'), fetchUser)
3. 测试成功路径
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createUserHarness } from '@/composables/createUserHarness'
describe('useUser', () => {
it('loads the user on mount', async () => {
const fetchUser = vi.fn(async (id: string) => ({
id,
name: 'Ada',
}))
const wrapper = mount(createUserHarness(fetchUser), {
props: { id: '42' },
})
expect(fetchUser).toHaveBeenCalledWith('42')
expect(wrapper.get('[data-testid="pending"]').text()).toBe('true')
await flushPromises()
expect(wrapper.get('[data-testid="pending"]').text()).toBe('false')
expect(wrapper.get('[data-testid="name"]').text()).toBe('Ada')
expect(wrapper.get('[data-testid="error"]').text()).toBe('')
})
})
watch(..., { immediate: true }) 会在创建监听器后立即执行一次,所以挂载组件就会触发请求。测试先验证同步阶段的 pending,再通过 flushPromises() 等待异步请求完成。
4. 测试失败路径
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createUserHarness } from '@/composables/createUserHarness'
describe('useUser error handling', () => {
it('exposes a rejected request as error state', async () => {
const failure = new Error('network unavailable')
const fetchUser = vi.fn(async () => {
throw failure
})
const wrapper = mount(createUserHarness(fetchUser), {
props: { id: '42' },
})
await flushPromises()
expect(wrapper.get('[data-testid="pending"]').text()).toBe('false')
expect(wrapper.get('[data-testid="name"]').text()).toBe('')
expect(wrapper.get('[data-testid="error"]').text()).toBe('error')
})
})
测试失败路径时,不应只断言 Promise 被拒绝,还应验证用户可观察的状态是否恢复。例如,加载状态必须从 true 回到 false,否则组件可能永久显示加载中。
5. 测试竞态条件
使用手动 Promise 可以控制返回顺序:
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createUserHarness } from '@/composables/createUserHarness'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('useUser concurrency', () => {
it('does not let an older response overwrite a newer one', async () => {
const first = deferred<{ id: string; name: string }>()
const second = deferred<{ id: string; name: string }>()
const fetchUser = vi
.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
const wrapper = mount(createUserHarness(fetchUser), {
props: { id: 'first' },
})
// 让 id 变化触发第二次请求
await wrapper.setProps({ id: 'second' })
second.resolve({ id: 'second', name: 'New result' })
await flushPromises()
first.resolve({ id: 'first', name: 'Old result' })
await flushPromises()
expect(wrapper.get('[data-testid="name"]').text()).toBe('New result')
})
})
这个测试不能只测试“请求调用了两次”,因为两次调用并不能证明状态提交正确。真正的故障是旧响应覆盖新响应,因此必须故意让旧请求最后完成。
八、直接使用 fetch 时如何 Mock 网络
依赖注入是最容易控制的方式,但已有代码可能直接调用全局 fetch:
export async function loadUser(id: string) {
const response = await fetch(`/api/users/${encodeURIComponent(id)}`)
if (!response.ok) {
throw new Error(`request failed: ${response.status}`)
}
return response.json() as Promise<{
id: string
name: string
}>
}
可以用 vi.stubGlobal 替换测试环境中的 fetch:
import { afterEach, describe, expect, it, vi } from 'vitest'
import { loadUser } from '@/api/loadUser'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('loadUser', () => {
it('sends the encoded id and parses JSON', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ id: 'a/b', name: 'Ada' }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
)
vi.stubGlobal('fetch', fetchMock)
await expect(loadUser('a/b')).resolves.toEqual({
id: 'a/b',
name: 'Ada',
})
expect(fetchMock).toHaveBeenCalledWith('/api/users/a%2Fb')
})
it('throws for non-2xx responses', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response('', { status: 404 }),
),
)
await expect(loadUser('42')).rejects.toThrow('request failed: 404')
})
})
这里有两个容易混淆的事实:
fetch在 HTTP 404、500 时通常仍然解析为一个成功的 Promise;- 只有网络层失败或请求被拒绝时,
fetch才通常以 rejected Promise 结束。
因此业务代码必须显式检查:
if (!response.ok) {
throw new Error(...)
}
只测试网络拒绝而不测试 404/500,会漏掉最常见的错误处理缺陷。
Response 在现代 Node 和 jsdom 环境中通常可用,但具体可用性取决于 Node 版本和测试环境。如果运行环境没有完整的 Fetch API,可以在测试配置中引入兼容实现,或使用项目已有的 HTTP Mock 方案。不要假设 jsdom 就等于真实浏览器。
九、什么时候使用 vi.mock,什么时候使用请求拦截
1. Mock 模块
如果网络代码位于独立模块:
// api/client.ts
export async function getUser(id: string) {
// ...
}
业务模块可以导入它:
// composables/useUserFromApi.ts
import { getUser } from '@/api/client'
export async function loadUserName(id: string) {
const user = await getUser(id)
return user.name
}
测试可以 Mock 模块:
import { describe, expect, it, vi } from 'vitest'
import { loadUserName } from '@/composables/useUserFromApi'
import { getUser } from '@/api/client'
vi.mock('@/api/client', () => ({
getUser: vi.fn(),
}))
describe('loadUserName', () => {
it('returns the user name', async () => {
vi.mocked(getUser).mockResolvedValue({
id: '42',
name: 'Ada',
})
await expect(loadUserName('42')).resolves.toBe('Ada')
expect(getUser).toHaveBeenCalledWith('42')
})
})
vi.mock 通常会被 Vitest 提升处理,因此不要依赖测试函数内部运行顺序来初始化模块 Mock。对于需要根据不同测试动态改变行为的场景,使用 vi.mocked(...).mockResolvedValue(...) 等方式设置当前测试的返回值更清楚。
模块 Mock 的边界是“模块接口”。它适合单元测试,但不能证明 URL、HTTP 方法、请求头或序列化格式正确;这些内容需要在 HTTP 适配层测试。
2. 请求拦截
如果希望保留真实的请求调用代码,只拦截 HTTP 层,可以使用请求拦截工具,例如 Mock Service Worker(MSW)。这类工具根据 URL、方法和请求体返回模拟响应。
它比直接 Mock fetch 更接近真实网络调用,但配置、生命周期和处理器管理更复杂。使用时必须:
- 在每个测试开始前安装请求处理器;
- 测试结束后恢复处理器;
- 对未处理的请求设置明确策略;
- 确认 Node 测试环境与浏览器环境使用了正确的 MSW 入口。
取舍可以这样理解:
| 方式 | 验证范围 | 适合 |
|---|---|---|
| 依赖注入 | 业务状态逻辑 | Composable 单元测试 |
vi.stubGlobal('fetch') |
Fetch 调用和响应映射 | HTTP 适配层测试 |
vi.mock 模块 |
模块接口交互 | 隔离上层业务 |
| MSW | 接近真实 HTTP 协议 | 跨模块集成测试 |
没有一种方式可以同时替代所有层次。用 vi.mock 测过了 getUser,不代表 URL 编码和状态码处理也被验证过。
十、稳定断言:固定行为,不固定偶然实现
1. 不要把整个实现快照当作行为
不稳定的断言:
expect(wrapper.html()).toBe(
'<div class="wrapper"><span class="generated-abc">Ada</span></div>',
)
只要构建工具改变类名、模板增加无关属性,测试就会失败,即使用户行为没有变化。
更稳定的断言:
expect(wrapper.get('[data-testid="name"]').text()).toBe('Ada')
data-testid 应用于测试需要定位、但不适合依赖 CSS 样式或语义的节点。对于用户真正能识别的内容,也可以优先使用角色、标签或文本。
2. 断言必要字段
不稳定:
expect(fetchUser).toHaveBeenCalledWith({
id: '42',
source: 'profile',
timestamp: expect.any(Number),
requestId: expect.any(String),
})
如果当前行为只要求 ID,测试却锁定了时间戳和请求 ID,就把随机实现细节变成了契约。
更准确:
expect(fetchUser).toHaveBeenCalledWith('42')
如果确实需要验证对象参数:
expect(apiCall).toHaveBeenCalledWith(
expect.objectContaining({
id: '42',
}),
)
这表达的是:“必须包含 id: '42',其他字段不是本测试的关注点”。
3. 时间断言不要依赖真实经过的毫秒数
不稳定:
expect(Date.now() - startedAt).toBeGreaterThan(1000)
这会受到机器负载、CI 调度和时钟精度影响。
稳定:
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'))
startExpirationCheck()
vi.advanceTimersByTime(1000)
expect(onExpired).toHaveBeenCalledTimes(1)
测试把“过了一秒”变成了明确的虚拟操作,而不是等待真实的一秒。
4. 断言调用次数要先明确契约
toHaveBeenCalledTimes(1) 不是天然优于 toHaveBeenCalled()。如果实现允许重试、重新挂载或请求刷新,调用次数可能不是稳定契约。
应先区分:
- “必须调用过”:
expect(fetchUser).toHaveBeenCalled()
- “初始化时只能调用一次”:
expect(fetchUser).toHaveBeenCalledTimes(1)
- “第二次请求必须使用新参数”:
expect(fetchUser).toHaveBeenNthCalledWith(2, 'second')
断言越严格,表达的契约越强;契约不明确时,过度严格会制造无意义维护成本。
十一、组件测试中的用户行为和错误路径
下面是一个使用 Composable 的组件:
<script setup lang="ts">
import { ref } from 'vue'
import { useUser, type FetchUser } from '@/composables/useUser'
const props = defineProps<{
id: string
fetchUser: FetchUser
}>()
const id = ref(props.id)
const { user, error, pending, reload } = useUser(id, props.fetchUser)
</script>
<template>
<section>
<p v-if="pending" role="status">Loading</p>
<p v-else-if="error" role="alert">Unable to load user</p>
<p v-else>{{ user?.name }}</p>
<button type="button" @click="reload">
Retry
</button>
</section>
</template>
测试应从用户可见行为开始:
import { describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import UserPanel from '@/components/UserPanel.vue'
describe('UserPanel', () => {
it('shows loading and then the user name', async () => {
let resolveRequest!: (value: { id: string; name: string }) => void
const fetchUser = vi.fn(
() =>
new Promise<{ id: string; name: string }>((resolve) => {
resolveRequest = resolve
}),
)
const wrapper = mount(UserPanel, {
props: {
id: '42',
fetchUser,
},
})
expect(wrapper.get('[role="status"]').text()).toBe('Loading')
resolveRequest({ id: '42', name: 'Ada' })
await flushPromises()
expect(wrapper.text()).toContain('Ada')
expect(wrapper.find('[role="status"]').exists()).toBe(false)
})
it('shows an error and can retry', async () => {
const fetchUser = vi
.fn()
.mockRejectedValueOnce(new Error('temporary failure'))
.mockResolvedValueOnce({ id: '42', name: 'Ada' })
const wrapper = mount(UserPanel, {
props: {
id: '42',
fetchUser,
},
})
await flushPromises()
expect(wrapper.get('[role="alert"]').text())
.toBe('Unable to load user')
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Ada')
expect(fetchUser).toHaveBeenCalledTimes(2)
})
})
测试步骤与实现机制对应:
mount执行组件setup;useUser的立即监听器触发第一次请求;- Promise 未完成,所以显示加载状态;
- 手动解决 Promise;
flushPromises等待请求回调;- Vue 根据状态更新模板;
- 点击重试按钮触发第二次调用;
- 第二次请求成功后显示用户名称。
测试没有断言 Composable 内部的 requestVersion,因为那是实现方式;它通过竞态测试间接验证“旧结果不能覆盖新结果”的行为。
十二、清理:测试隔离比测试本身更重要
一个测试如果修改了全局状态,必须恢复。常见污染来源包括:
- Fake Timers;
vi.spyOn;vi.stubGlobal;localStorage;- DOM;
- Mock Service Worker 的请求处理器;
- 单例模块缓存。
推荐写法:
import { afterEach, vi } from 'vitest'
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
不过要理解各操作的边界:
vi.restoreAllMocks()恢复 Spy 的原始实现;vi.unstubAllGlobals()恢复被vi.stubGlobal替换的全局变量;vi.useRealTimers()恢复真实定时器;wrapper.unmount()触发组件清理;clearMocks主要清除 Mock 的调用记录。
不要只依赖配置自动清理,尤其是测试中显式使用 Fake Timers 或全局替换时。一个测试单独运行通过、整个文件运行失败,通常就是共享状态没有恢复。
十三、常见失败表现与诊断路径
1. 测试偶尔失败
常见表现:
expected false to be true
或:
expected "Loading" to contain "Ada"
优先检查:
- 是否使用了真实时间;
- 是否等待了 Promise;
- 是否等待了 Vue 的
nextTick; - 是否在测试之间恢复了 Mock 和定时器;
- 是否有旧请求在测试结束后才完成。
如果代码路径同时包含定时器和异步请求,可以按顺序推进:
await vi.advanceTimersByTimeAsync(1000)
await flushPromises()
await nextTick()
不要无条件写很多 await new Promise(resolve => setTimeout(resolve, 0))。这会让测试依赖真实调度,且在 Fake Timers 下可能永远不执行。
2. 测试超时
常见原因:
- 使用 Fake Timers 后等待真实 Promise;
runAllTimers()遇到无限间隔定时器;- 测试没有返回或等待异步 Promise;
- 网络 Mock 没有匹配实际请求;
- 组件卸载时没有清理监听器或定时器。
诊断方法是先缩小测试:
expect(fetchMock).toHaveBeenCalled()
确认请求是否发出,再确认 Mock 返回值,最后确认 DOM 更新。不要一开始只看最终 DOM,因为中间任意一个异步阶段都可能没有发生。
3. Vue 警告:没有当前组件实例
如果看到生命周期相关警告,通常是直接调用了依赖组件实例的 Composable:
useClock()
解决方式不是忽略警告,而是通过宿主组件挂载它。否则测试无法证明 onMounted 和 onUnmounted 的行为。
4. 断言显示旧数据
如果请求 A 和 B 的返回顺序不稳定,检查:
- 是否给每次请求分配了版本号;
- 是否在响应返回时验证版本;
- 是否需要
AbortController; - 测试是否真正控制了两个 Promise 的完成顺序。
只检查“最后一次调用参数”不能发现旧响应覆盖问题,必须控制响应顺序。
十四、如何划分测试层次
一个较清楚的测试分层如下:
flowchart TD
A[纯函数测试] --> B[Composable 状态与生命周期测试]
B --> C[组件 DOM 与用户交互测试]
C --> D[HTTP 适配层测试]
D --> E[真实后端或端到端测试]
X[Fake Timers] --> A
X --> B
Y[依赖注入或 vi.fn] --> B
Y --> C
Z[fetch Mock 或请求拦截] --> D
关键路径是:
- 纯函数测试验证输入到输出的规则;
- Composable 测试验证响应式状态、监听器和生命周期;
- 组件测试验证用户看到的 DOM 与交互;
- HTTP 适配层验证 URL、参数、状态码和 JSON 映射;
- 端到端测试验证真实浏览器、真实服务或接近真实服务的整体协作。
如果在所有层次都 Mock 同一个模块,测试会很快,但可能只验证了 Mock 的配置。相反,如果所有测试都访问真实网络,测试会受服务状态、延迟和数据变化影响。合理做法是让每一层只验证自己的契约。
十五、时间、网络和断言的统一原则
时间和网络看起来是不同问题,实际上都属于外部依赖:
组件 / Composable
│
├── 当前时间
├── 定时器
├── 网络请求
└── 浏览器 API
测试要做的不是“把所有东西都 Mock 掉”,而是为每个外部依赖选择合适的控制边界:
- 当前时间:注入时钟或使用 Fake Timers;
- 定时器:推进虚拟时间,验证回调和清理;
- 网络:注入请求函数、Mock
fetch或拦截 HTTP; - Vue 更新:等待
nextTick; - Promise:等待
flushPromises; - 断言:只固定业务契约,不固定随机 ID、完整 HTML 或私有变量。
一个测试真正稳定,需要同时满足:
缺少任意一项,都可能出现“本地通过、CI 偶发失败”“单独运行通过、整文件失败”或“重构模板后大量无意义失败”。
Vue 3 的 Composition API 让状态逻辑更容易拆分,也让生命周期、网络竞态和异步调度更集中地进入 Composable。Vitest 的价值不只是替代 Jest 执行断言,而是提供了控制这些外部因素的工具。只有先明确状态如何变化、请求何时完成、时间如何推进,再选择对应的测试 API,测试才能真正验证代码的因果关系,而不是碰巧观察到某个结果。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 实时数据:WebSocket、SSE、重连、心跳和状态同步
- 下一篇:Vue 组件测试:挂载、用户交互、异步更新和契约验证
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论