单元测试与组件测试

Nuxt4 的测试体系建立在 Vitest + @nuxt/test-utils 之上。Vitest 提供极速的测试运行时,@nuxt/test-utils 解决 Nuxt 特有的测试难题——auto-import 的 mock、Suspense 包裹的组件挂载、SSR 环境模拟。本章从测试策略的顶层思考讲起,深入 Nuxt 测试工具链的设计原理,帮你建立一套高效的测试体系。

1. 测试策略:测什么、不测什么

1.1 测试金字塔在 Nuxt4 中的映射

测试金字塔的核心思想是:越底层的测试越多、越快、越稳定;越上层的测试越少、越慢、越脆弱

层级Nuxt4 中的表现数量速度稳定性
单元测试工具函数、Composables、Pinia Store最多毫秒级极稳定
组件测试Vue 组件的渲染和交互适中百毫秒级较稳定
集成测试API 路由 + 数据库少量秒级一般
E2E 测试完整用户流程(浏览器)最少数秒较脆弱

1.2 Nuxt4 的测试难点

Nuxt4 引入了几个让测试变复杂的特性:

  • Auto-importuseFetchuseRoutedefinePageMeta 等函数不需要显式 import,但测试环境默认不识别它们
  • Suspense 包裹:Nuxt 页面和布局默认被 <Suspense> 包裹,组件中的 await 在普通 mount() 下不工作
  • SSR / CSR 双模式:同一段代码可能在服务端和客户端都执行,需要分别测试
  • 插件和中间件:全局注入的 $fetch、路由守卫、Pinia 状态等需要在测试中正确初始化

@nuxt/test-utils 的核心价值就是解决这些 Nuxt 特有的问题,让你写测试时不需要手动 mock 整个 Nuxt 运行时。

1.3 应该测什么

推荐测不必测
业务逻辑(价格计算、权限判断、数据转换)框架行为(Vue 的响应式、Nuxt 的路由跳转)
Composables 的输入/输出纯 UI 样式(用视觉回归测试替代)
API 端点的参数校验和错误处理第三方库的内部逻辑
复杂组件的交互逻辑简单的展示型组件
边界条件和异常路径getter/setter 等琐碎代码

2. Vitest + @nuxt/test-utils 配置

2.1 为什么是 Vitest

Vitest 是 Vite 生态的原生测试框架,与 Nuxt4 / Vite 共享相同的配置和转换管线。这意味着:

  • 配置零重复:Vite 的 alias、插件、环境变量在测试中自动生效
  • 极速 HMR:修改代码后测试几乎瞬间重跑(利用 Vite 的模块图)
  • ESM 原生:不需要 Babel 转译,直接运行 TypeScript 和 ESM
  • 兼容 Jest APIdescribeitexpectvi.mock 等 API 与 Jest 几乎一致

2.2 @nuxt/test-utils 的环境模式

@nuxt/test-utils 提供两种测试环境:

nuxt 环境@nuxt/test-utils/runtime):启动一个完整的 Nuxt 运行时,auto-import 自动生效,useFetchuseRoute 等可以直接使用。适合组件测试和集成测试。

node 环境(默认 Vitest 环境):纯 Node.js 环境,没有 Nuxt 运行时。需要手动 mock Nuxt API。适合纯函数和工具库的单元测试。

// vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'
 
export default defineVitestConfig({
  test: {
    environment: 'nuxt',  // 默认使用 nuxt 环境
    environmentOptions: {
      nuxt: {
        overrides: {
          // 测试时覆盖 nuxt.config
        },
      },
    },
  },
})

选择策略:如果项目中大部分测试都涉及 Nuxt API(auto-import、composables),全局设 environment: 'nuxt'。如果只有少数需要 Nuxt 环境,可以在单个测试文件顶部加 // @vitest-environment nuxt 注释。

2.3 环境启动成本

Nuxt 测试环境的启动需要解析 nuxt.config.ts、注册模块、构建 auto-import 映射——首次启动约需 2-5 秒。但 Vitest 会缓存环境,后续测试文件复用同一个环境实例,整体开销可控。

如果你的测试套件超过 200 个文件,建议将纯函数测试和 Nuxt 组件测试分到不同的 Vitest workspace 中,纯函数测试用默认 node 环境,避免不必要的启动开销。

3. mountSuspended:测试异步组件

3.1 为什么不能用普通 mount

Vue Test Utils 的 mount() 同步挂载组件。但 Nuxt 组件中经常有 await useFetch()await useAsyncData()——这些异步操作在 <Suspense> 中才能正确 resolve。用普通 mount() 挂载会导致组件卡在 loading 状态,永远渲染不出真正的内容。

3.2 mountSuspended 的原理

mountSuspended 做了两件事:

  1. <Suspense> 包裹目标组件
  2. 等待所有异步操作 resolve 后再返回 wrapper
import { mountSuspended } from '@nuxt/test-utils/runtime'
import VideoCard from '~/components/VideoCard.vue'
 
it('renders video title', async () => {
  const wrapper = await mountSuspended(VideoCard, {
    props: {
      video: { id: '1', title: 'AI 生成的风景视频', duration: 120 },
    },
  })
 
  expect(wrapper.text()).toContain('AI 生成的风景视频')
})

注意 await——mountSuspended 返回 Promise,必须 await。返回的 wrapper 与 Vue Test Utils 的 wrapper 完全兼容,可以用 .find().trigger().emitted() 等 API。

3.3 renderSuspended:测试渲染输出

如果你只关心渲染结果而不需要交互,renderSuspended 基于 @testing-library/vue,返回的 API 更贴近用户视角:

import { renderSuspended } from '@nuxt/test-utils/runtime'
import VideoCard from '~/components/VideoCard.vue'
 
it('shows duration in human-readable format', async () => {
  const { getByText } = await renderSuspended(VideoCard, {
    props: {
      video: { id: '1', title: 'Test', duration: 3661 },
    },
  })
 
  getByText('1:01:01')  // 如果找不到会直接抛错
})

选型建议:需要测试事件触发、组件交互时用 mountSuspended(Vue Test Utils 风格);只需断言渲染输出时用 renderSuspended(Testing Library 风格)。

4. mockNuxtImport:Mock Auto-Import

4.1 问题背景

Nuxt4 的 auto-import 让开发体验很好,但给测试带来了难题:useFetchuseRoutenavigateTo 等函数没有显式 import,传统的 vi.mock('模块路径') 无法使用——你不知道它们的"模块路径"是什么。

4.2 mockNuxtImport 的用法

import { mockNuxtImport } from '@nuxt/test-utils/runtime'
 
// mock useRoute 返回固定路由
mockNuxtImport('useRoute', () => {
  return () => ({
    params: { id: '42' },
    query: { tab: 'comments' },
  })
})
 
// mock useFetch 返回模拟数据
mockNuxtImport('useFetch', () => {
  return (url: string) => ({
    data: ref({ title: 'Mock Video', views: 1000 }),
    pending: ref(false),
    error: ref(null),
  })
})

mockNuxtImport 的第一个参数是 auto-import 的函数名,第二个参数是工厂函数(返回 mock 实现)。它在编译阶段替换 auto-import 的解析结果,所以必须在文件顶层调用,不能放在 beforeEachit 里面。

4.3 动态 Mock

如果需要每个测试用例返回不同的 mock 值,用 vi.hoisted 配合:

import { mockNuxtImport } from '@nuxt/test-utils/runtime'
 
const { useRouteMock } = vi.hoisted(() => ({
  useRouteMock: vi.fn(() => ({ params: { id: '1' } })),
}))
 
mockNuxtImport('useRoute', () => useRouteMock)
 
it('test with id 1', () => {
  // 使用默认 mock
})
 
it('test with id 2', () => {
  useRouteMock.mockReturnValue({ params: { id: '2' } })
  // ...
})

vi.hoisted 确保变量声明被提升到模块顶部,在 mockNuxtImport 的编译时替换中可用。

4.4 mockComponent

类似地,mockComponent 用于 mock auto-import 的组件:

import { mockComponent } from '@nuxt/test-utils/runtime'
 
mockComponent('VideoPlayer', {
  template: '<div data-testid="mock-player">Mock Player</div>',
})

这在测试父组件时很有用——你不想让 VideoPlayer 真正初始化(可能涉及复杂的媒体 API),只需验证父组件是否正确传递了 props。

5. Composables 测试

5.1 纯函数型 Composables

不依赖 Nuxt 运行时的 composables 可以直接测试,不需要 Nuxt 环境:

// composables/useFormatDuration.ts
export function useFormatDuration() {
  function format(seconds: number): string {
    const h = Math.floor(seconds / 3600)
    const m = Math.floor((seconds % 3600) / 60)
    const s = seconds % 60
    return h > 0
      ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
      : `${m}:${String(s).padStart(2, '0')}`
  }
  return { format }
}
// composables/__tests__/useFormatDuration.test.ts
import { useFormatDuration } from '../useFormatDuration'
 
describe('useFormatDuration', () => {
  const { format } = useFormatDuration()
 
  it('formats seconds only', () => {
    expect(format(45)).toBe('0:45')
  })
 
  it('formats minutes and seconds', () => {
    expect(format(125)).toBe('2:05')
  })
 
  it('formats hours', () => {
    expect(format(3661)).toBe('1:01:01')
  })
 
  it('handles zero', () => {
    expect(format(0)).toBe('0:00')
  })
})

5.2 依赖 Nuxt 运行时的 Composables

依赖 useRouteuseFetchuseState 等 Nuxt API 的 composables 需要在 Nuxt 测试环境中运行:

// composables/useVideoDetail.ts
export function useVideoDetail(id: string) {
  const { data, pending, error } = useFetch(`/api/videos/${id}`)
  const isOwner = computed(() => {
    const { user } = useUserSession()
    return data.value?.authorId === user.value?.id
  })
  return { video: data, pending, error, isOwner }
}

测试时需要 mock useFetchuseUserSession

// @vitest-environment nuxt
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
 
mockNuxtImport('useFetch', () => {
  return () => ({
    data: ref({ id: '1', title: 'Test Video', authorId: 'user-42' }),
    pending: ref(false),
    error: ref(null),
  })
})
 
mockNuxtImport('useUserSession', () => {
  return () => ({
    user: ref({ id: 'user-42' }),
    loggedIn: ref(true),
  })
})
 
describe('useVideoDetail', () => {
  it('detects owner correctly', () => {
    const { isOwner } = useVideoDetail('1')
    expect(isOwner.value).toBe(true)
  })
})

5.3 测试响应式行为

Composables 的核心价值在于响应式——输入变化时输出自动更新。测试应该覆盖这种动态行为:

it('updates search results when query changes', async () => {
  const query = ref('')
  const { results } = useSearch(query)
 
  expect(results.value).toEqual([])
 
  query.value = 'AI video'
  await nextTick()
 
  expect(results.value.length).toBeGreaterThan(0)
})

6. Server API 测试

6.1 $fetch 集成测试

@nuxt/test-utils 可以启动完整的 Nuxt 服务器来测试 API 端点:

import { setup, $fetch } from '@nuxt/test-utils/e2e'
 
describe('Video API', async () => {
  await setup({ server: true })
 
  it('returns video list', async () => {
    const videos = await $fetch('/api/videos')
    expect(Array.isArray(videos)).toBe(true)
  })
 
  it('validates input on create', async () => {
    const res = await $fetch('/api/videos', {
      method: 'POST',
      body: { title: '' },  // 空标题
    }).catch(e => e)
 
    expect(res.statusCode).toBe(422)
  })
})

6.2 轻量级 API 测试

如果不想启动完整服务器,可以直接导入 event handler 测试:

import { createEvent } from 'h3'
import handler from '~/server/api/videos/index.get'
 
it('returns videos', async () => {
  const event = createEvent({ method: 'GET' })
  const result = await handler(event)
  expect(result).toBeDefined()
})

这种方式更快,但无法测试中间件和插件的效果。根据测试目的选择。

7. 测试覆盖率

7.1 配置 Coverage

// vitest.config.ts
export default defineVitestConfig({
  test: {
    coverage: {
      provider: 'v8',
      include: ['app/**', 'composables/**', 'server/**'],
      exclude: ['**/*.d.ts', '**/*.test.ts', 'node_modules/**'],
      thresholds: {
        statements: 60,
        branches: 50,
        functions: 60,
        lines: 60,
      },
    },
  },
})

7.2 覆盖率的正确认知

覆盖率是必要但不充分的质量指标:

  • 高覆盖率 ≠ 高质量测试:100% 覆盖率但全是 expect(1+1).toBe(2) 这样的无意义断言毫无价值
  • 低覆盖率 = 一定有盲区:如果函数覆盖率只有 30%,说明 70% 的代码没有任何测试保护
  • 关注边界值和异常路径:主流程的覆盖通常很自然,真正的 bug 藏在边界条件和错误处理中

合理的覆盖率目标:

代码类型建议覆盖率原因
工具函数 / Composables80%+逻辑密集,单测 ROI 高
Server API handlers70%+校验和错误处理路径重要
Vue 组件50%+交互逻辑测,纯渲染不必追求覆盖
配置文件 / 类型定义不测没有运行时逻辑

8. 测试组织与最佳实践

8.1 文件组织

app/
├── composables/
│   ├── useVideoDetail.ts
│   └── __tests__/
│       └── useVideoDetail.test.ts   ← 就近放置
├── components/
│   ├── VideoCard.vue
│   └── __tests__/
│       └── VideoCard.test.ts
server/
├── api/
│   ├── videos/
│   │   ├── index.get.ts
│   │   └── __tests__/
│   │       └── index.get.test.ts

就近放置(colocation)比集中放在 tests/ 目录更好——修改代码时一眼就能看到对应的测试文件,也更容易维护。

8.2 测试命名

好的测试名称是行为描述,不是实现描述:

// ❌ 描述实现
it('calls useFetch with correct URL')
 
// ✅ 描述行为
it('displays video title from API response')
it('shows error message when video not found')
it('redirects to login when not authenticated')

8.3 Arrange-Act-Assert 模式

每个测试遵循 AAA 模式,结构清晰:

it('filters videos by tag', async () => {
  // Arrange:准备数据和环境
  const wrapper = await mountSuspended(VideoList, {
    props: { tag: 'AI' },
  })
 
  // Act:执行操作
  await wrapper.find('[data-testid="filter-btn"]').trigger('click')
 
  // Assert:验证结果
  expect(wrapper.findAll('.video-card')).toHaveLength(3)
})

8.4 避免测试脆弱性

  • 不要断言内部实现:测试"做了什么",不测试"怎么做的"。mock 函数被调用几次、内部状态值是什么——这些属于实现细节,重构时会频繁破坏测试。
  • data-testid 而非 CSS 类名选择元素:CSS 类名是样式关注点,随时可能改变。data-testid 是测试专用标记,语义明确。
  • 每个测试独立:不要让测试之间有顺序依赖。使用 beforeEach 重置状态,确保每个测试从干净环境开始。

本章小结

  • 测试策略:遵循金字塔——单元测试多且快,E2E 测试少但全,重点测业务逻辑和边界条件
  • Vitest + @nuxt/test-utils:共享 Vite 配置零重复,nuxt 环境自动处理 auto-import
  • mountSuspended:解决 Nuxt 异步组件在 Suspense 下的挂载问题,返回标准 Vue Test Utils wrapper
  • mockNuxtImport:编译时替换 auto-import 函数,支持 vi.hoisted 实现动态 mock
  • Composables 测试:纯函数型直接测试,依赖 Nuxt 运行时的用 mockNuxtImport 隔离
  • 覆盖率:必要但不充分,关注边界条件和异常路径,工具函数 80%+、组件 50%+
  • 最佳实践:就近放置测试文件、行为描述命名、AAA 模式、避免断言实现细节