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 的代码提供 windowdocumentfetch 等运行环境。

这三者解决的问题不同。Vitest 不理解 Vue 的生命周期,Vue Test Utils 也不负责替代断言库;如果测试中涉及时间、网络或异步更新,还必须明确哪些部分是真实执行的,哪些部分被测试替身控制。

本文示例基于 Vue 3、Composition API、TypeScript、Vite 和 Vitest。Vitest 的具体 API 会随版本演进,示例使用现代 Vitest 中稳定且常用的 viexpect、Fake Timers 和 jsdom 能力。


一、先建立测试对象模型

1. 单元测试究竟在验证什么

一个单元可以形式化为一个函数:

y=f(x,e)y = f(x, e)

其中:

  • xx 是显式输入,例如函数参数、组件 props
  • ee 是外部依赖,例如当前时间、网络、随机数、浏览器 API;
  • yy 是输出,包括返回值、状态变化、DOM、事件或异常。

稳定的单元测试希望把外部依赖固定为可控值:

e=e0e = e_0

于是同一个输入 xx 应得到相同的可观察结果:

f(x,e0)=y0f(x, e_0) = y_0

例如,一个“根据用户 ID 请求用户”的 Composable:

const user = await loadUser('42')

它的输入不只是 '42',还包括:

  • 网络是否可用;
  • 服务端返回什么;
  • 请求是否延迟;
  • 当前组件是否仍然存活;
  • 是否有另一个请求后来发出。

如果直接访问真实服务,测试结果取决于外部系统;如果把请求函数作为依赖传入,测试就可以精确控制这些因素。

因此,单元测试不是简单地“调用函数然后判断结果”,而是要明确:

  1. 输入是什么;
  2. 外部依赖是什么;
  3. 状态如何变化;
  4. 哪些结果对使用者可见;
  5. 哪些实现细节不应该成为测试约束。

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':让测试可以访问 documentwindow 等 DOM API。
  • globals: true:允许直接使用 describeitexpect,无需每个文件显式导入。
  • setupFiles:在测试文件执行前运行公共初始化代码。
  • clearMocks: true:每个测试前清除 Mock 的调用记录,但不一定恢复被修改的实现。
  • restoreMocks: true:恢复通过 vi.spyOn 等方式替换的实现。

clearMocksrestoreMocks 不等价。前者主要清除“调用历史”,后者恢复“被替换的对象行为”。如果使用了 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)
  })
})

执行过程可以理解为:

  1. Vitest 加载测试模块;
  2. 注册 describeit 中的测试;
  3. 执行测试函数;
  4. expect 读取实际值;
  5. 匹配器判断实际值是否满足预期;
  6. 测试函数抛出异常或返回拒绝的 Promise 时,测试失败。

expect 本身不会让异步测试自动等待。下面的写法是错误的:

it('handles async result', () => {
  expect(Promise.resolve(42)).resolves.toBe(42)
})

测试函数没有返回这个 Promise,测试框架可能在断言完成前就结束。正确写法是使用 asyncawait

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. toBetoEqual 的差别

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',
  }),
)

这比完整比较整个对象更稳定,因为服务端以后增加 avatarcreatedAt 等字段时,不会让与当前行为无关的测试失败。


四、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 没有调用 onMountedonUnmountedprovide 或依赖当前组件实例。

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)
  })
})

这段测试验证了完整生命周期:

  1. vi.setSystemTime 固定初始时间;
  2. mount 触发 onMounted,启动间隔定时器;
  3. advanceTimersByTime(1000) 推进虚拟时间;
  4. 定时器回调更新 ref
  5. Vue 更新 DOM;
  6. unmount 触发 onUnmounted
  7. 定时器数量回到零。

这里的 vi.getTimerCount() 属于 Vitest Fake Timers 的辅助能力。它适合检查是否留下定时器,但不应成为所有测试的默认断言;更有价值的是验证卸载后不会继续更新状态或产生副作用。


五、响应式更新、Promise 和定时器不是同一个队列

Vue 测试中经常出现“逻辑已经执行,但 DOM 还没有更新”的情况。原因是至少存在三类调度:

  1. 宏任务setTimeoutsetInterval
  2. 微任务:Promise 的 thenawait 后续;
  3. 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)
  })
})

这里特别测试了边界条件:

expired=(nowexpiresAt)\text{expired} = (now \ge expiresAt)

不是:

expired=(now>expiresAt)\text{expired} = (now > expiresAt)

两个实现只在恰好到期的那一刻不同。如果业务规定“到期时立即失效”,就必须验证等号边界。

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)
  })
})

这种写法的因果关系更直接:函数明确接收“当前时间来源”,测试不需要修改全局时钟。

经验上:

  • 测试 setTimeoutsetInterval 和生命周期清理时,使用 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,
  }
}

这里有一个重要的并发规则:

只有 versionresponse=versioncurrent 的响应可以提交状态\text{只有 } version_{\text{response}} = version_{\text{current}} \text{ 的响应可以提交状态}

假设先后发出请求 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')
  })
})

这里有两个容易混淆的事实:

  1. fetch 在 HTTP 404、500 时通常仍然解析为一个成功的 Promise;
  2. 只有网络层失败或请求被拒绝时,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)
  })
})

测试步骤与实现机制对应:

  1. mount 执行组件 setup
  2. useUser 的立即监听器触发第一次请求;
  3. Promise 未完成,所以显示加载状态;
  4. 手动解决 Promise;
  5. flushPromises 等待请求回调;
  6. Vue 根据状态更新模板;
  7. 点击重试按钮触发第二次调用;
  8. 第二次请求成功后显示用户名称。

测试没有断言 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"

优先检查:

  1. 是否使用了真实时间;
  2. 是否等待了 Promise;
  3. 是否等待了 Vue 的 nextTick
  4. 是否在测试之间恢复了 Mock 和定时器;
  5. 是否有旧请求在测试结束后才完成。

如果代码路径同时包含定时器和异步请求,可以按顺序推进:

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()

解决方式不是忽略警告,而是通过宿主组件挂载它。否则测试无法证明 onMountedonUnmounted 的行为。

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 或私有变量。

一个测试真正稳定,需要同时满足:

稳定测试=确定的输入+可控的外部依赖+完整的异步等待+准确的行为断言+可靠的资源清理\text{稳定测试} = \text{确定的输入} + \text{可控的外部依赖} + \text{完整的异步等待} + \text{准确的行为断言} + \text{可靠的资源清理}

缺少任意一项,都可能出现“本地通过、CI 偶发失败”“单独运行通过、整文件失败”或“重构模板后大量无意义失败”。

Vue 3 的 Composition API 让状态逻辑更容易拆分,也让生命周期、网络竞态和异步调度更集中地进入 Composable。Vitest 的价值不只是替代 Jest 执行断言,而是提供了控制这些外部因素的工具。只有先明确状态如何变化、请求何时完成、时间如何推进,再选择对应的测试 API,测试才能真正验证代码的因果关系,而不是碰巧观察到某个结果。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。