Vue 测试完全指南

中等 🟡Vue 生态
7 个标签
预计阅读时间:112 分钟
Vue测试VitestVue Test UtilsTesting LibraryE2E测试覆盖率

Vue 测试完全指南

测试是保证代码质量的重要手段。本文详细介绍 Vue 3 应用的测试方法,包括单元测试、组件测试和端到端测试,使用 Vitest 和 Vue Test Utils 等现代测试工具,并结合真实场景讲解如何写出有价值、可维护的测试。

零、为什么要写测试

很多人觉得"写测试浪费时间",但从工程角度看,测试是回报率最高的投资之一。

一个类比:测试就像给代码上的一道道保险丝。没有测试,你每次改动都是"闭着眼睛开车",改 A 处坏了 B 处却毫不知情,直到用户投诉。有了测试,回归问题会在提交前就被拦下。

测试带来的收益(行业普遍数据):

| 维度 | 无测试 | 有良好测试 |

|------|--------|-----------|

| 重构信心 | 不敢改,怕改坏 | 放心大胆重构 |

| Bug 修复成本 | 上线后发现,成本高 | 开发阶段发现,成本低 |

| 回归风险 | 每次发版提心吊胆 | CI 自动拦截 |

| 文档作用 | 无 | 测试即"可执行文档" |

研究表明,一个 Bug 在需求阶段修复成本为 1,在开发阶段为 5,在测试阶段为 10,而到了线上则可能高达 100 倍以上。测试的本质是把 Bug 拦截在越早越好的阶段。

测试金字塔:合理的测试结构应该像金字塔——底层大量快速的单元测试,中层适量的组件/集成测试,顶层少量的端到端测试。

codeCode
        /\        E2E 测试(少,慢,贵,最接近真实)
       /  \
      /----\      集成/组件测试(适量)
     /      \
    /--------\    单元测试(多,快,便宜)

一、测试基础

1. 测试工具介绍

Vitest:

Vite 原生的测试框架
兼容 Jest API(describe/it/expect)
极速的测试运行速度(复用 Vite 的转换与 HMR)
内置覆盖率报告、UI 界面、watch 模式

Vue Test Utils:

Vue 官方组件测试库
支持 Vue 3
提供丰富的组件挂载与断言 API(mount / shallowMount)

Testing Library:

用户中心的测试理念
鼓励测试行为而非实现细节
更好的可维护性(不依赖内部结构)

三者的关系与选择:

| 工具 | 角色 | 关注点 |

|------|------|--------|

| Vitest | 测试运行器 | 组织与执行测试、断言、Mock |

| Vue Test Utils | 组件挂载库 | 挂载组件、操作 wrapper |

| Testing Library | 组件挂载库(替代方案) | 从用户视角查询与交互 |

Vitest 是必选的运行器;组件测试可在 Vue Test Utils 和 Testing Library 之间二选一,前者更贴近 Vue 内部,后者更贴近用户视角。

为什么选 Vitest 而非 Jest?

| 维度 | Jest | Vitest |

|------|------|--------|

| 与 Vite 集成 | 需额外配置 | 原生,零配置复用 vite.config |

| 启动速度 | 较慢 | 快(复用 esbuild 转换) |

| ESM 支持 | 需转译 | 原生支持 |

| API 兼容 | 标准 | 兼容 Jest,迁移无痛 |

| watch 模式 | 有 | 更快(基于 HMR) |

2. 安装配置

安装 Vitest:

bashCode
npm install -D vitest @vitejs/plugin-vue

安装 Vue Test Utils:

bashCode
npm install -D @vue/test-utils jsdom

安装 Testing Library:

bashCode
npm install -D @testing-library/vue @testing-library/jest-dom

Vite 配置:

javascriptCode
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.js',
    coverage: {
      reporter: ['text', 'json', 'html']
    }
  }
});

setup 文件(全局初始化):

javascriptCode
// src/test/setup.js
import '@testing-library/jest-dom';
import { config } from '@vue/test-utils';

// 全局注册组件、指令、插件的示例
config.global.stubs = {
  // 默认 stub 掉路由链接,避免测试组件时报错
  RouterLink: true
};

测试脚本:

jsonCode
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage"
  }
}

vitest 默认进入 watch 模式(改代码自动重跑);CI 环境用 vitest run 单次执行。

二、单元测试

单元测试针对最小的可测试单元——纯函数、composables、store,它们不涉及 DOM,运行最快,应该占测试的大头。

1. 测试 Composables

javascriptCode
// composables/useCounter.js
import { ref } from 'vue';

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);

  const increment = () => {
    count.value++;
  };

  const decrement = () => {
    count.value--;
  };

  const reset = () => {
    count.value = initialValue;
  };

  return { count, increment, decrement, reset };
}

测试代码:

javascriptCode
// composables/useCounter.test.js
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('应该使用初始值初始化 count', () => {
    const { count } = useCounter(10);
    expect(count.value).toBe(10);
  });

  it('应该默认初始值为 0', () => {
    const { count } = useCounter();
    expect(count.value).toBe(0);
  });

  it('应该递增 count', () => {
    const { count, increment } = useCounter();
    increment();
    expect(count.value).toBe(1);
  });

  it('应该递减 count', () => {
    const { count, decrement } = useCounter(5);
    decrement();
    expect(count.value).toBe(4);
  });

  it('应该重置 count 到初始值', () => {
    const { count, increment, reset } = useCounter(10);
    increment();
    increment();
    reset();
    expect(count.value).toBe(10);
  });
});

测试依赖生命周期的 Composable:如果 composable 内部用了 onMounted 等钩子,就不能裸调用,需要用 withSetup 辅助挂载到一个测试组件:

javascriptCode
// test/withSetup.js
import { createApp } from 'vue';

export function withSetup(composable) {
  let result;
  const app = createApp({
    setup() {
      result = composable();
      return () => {};
    }
  });
  app.mount(document.createElement('div'));
  return [result, app];
}
javascriptCode
// 使用示例
import { withSetup } from '../test/withSetup';
import { useMouse } from './useMouse';

it('鼠标移动应更新坐标', () => {
  const [result, app] = withSetup(() => useMouse());
  window.dispatchEvent(new MouseEvent('mousemove', { pageX: 10, pageY: 20 }));
  expect(result.x.value).toBe(10);
  expect(result.y.value).toBe(20);
  app.unmount(); // 清理,触发 onUnmounted
});

2. 测试工具函数

javascriptCode
// utils/format.js
export function formatDate(date) {
  if (!date) return '';
  return new Date(date).toLocaleDateString('zh-CN');
}

export function formatCurrency(amount, currency = 'CNY') {
  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency
  }).format(amount);
}

export function truncate(str, length = 50) {
  if (!str) return '';
  return str.length > length ? str.slice(0, length) + '...' : str;
}

测试代码:

javascriptCode
// utils/format.test.js
import { describe, it, expect } from 'vitest';
import { formatDate, formatCurrency, truncate } from './format';

describe('formatDate', () => {
  it('应该格式化日期', () => {
    const result = formatDate('2024-01-15');
    expect(result).toMatch(/\d{4}\/\d{1,2}\/\d{1,2}/);
  });

  it('空值应该返回空字符串', () => {
    expect(formatDate(null)).toBe('');
    expect(formatDate(undefined)).toBe('');
    expect(formatDate('')).toBe('');
  });
});

describe('formatCurrency', () => {
  it('应该格式化货币', () => {
    expect(formatCurrency(1000)).toBe('¥1,000.00');
  });

  it('应该支持不同货币', () => {
    expect(formatCurrency(1000, 'USD')).toBe('$1,000.00');
  });
});

describe('truncate', () => {
  it('应该截断长字符串', () => {
    const result = truncate('a'.repeat(60), 50);
    expect(result.length).toBe(53); // 50 + '...'
    expect(result).endsWith('...');
  });

  it('短字符串不应该被截断', () => {
    expect(truncate('hello')).toBe('hello');
  });
});

边界测试的重要性:注意上面对 formatDate 的空值测试。真实项目中大量 Bug 来自对 null/undefined/空字符串等边界的处理不当。一个好的测试习惯是:正常路径 1 个用例,边界与异常路径 N 个用例。

参数化测试(table-driven)减少重复:

javascriptCode
import { describe, it, expect } from 'vitest';
import { truncate } from './format';

describe('truncate 参数化', () => {
  it.each([
    ['hello', 50, 'hello'],
    ['', 50, ''],
    [null, 50, ''],
    ['a'.repeat(60), 50, 'a'.repeat(50) + '...']
  ])('truncate(%s, %i) => %s', (input, len, expected) => {
    expect(truncate(input, len)).toBe(expected);
  });
});

3. 测试 Pinia Store

javascriptCode
// stores/counter.js
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'counter'
  }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++;
    },
    setCount(value) {
      this.count = value;
    }
  }
});

测试代码:

javascriptCode
// stores/counter.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useCounterStore } from './counter';

describe('useCounterStore', () => {
  let store;

  beforeEach(() => {
    setActivePinia(createPinia());
    store = useCounterStore();
  });

  it('应该初始化 state', () => {
    expect(store.count).toBe(0);
    expect(store.name).toBe('counter');
  });

  it('应该递增 count', () => {
    store.increment();
    expect(store.count).toBe(1);
  });

  it('应该设置 count', () => {
    store.setCount(10);
    expect(store.count).toBe(10);
  });

  it('应该计算 doubleCount', () => {
    store.setCount(5);
    expect(store.doubleCount).toBe(10);
  });
});

为什么每个测试都要 beforeEach 重建 Pinia? 因为 store 是单例,如果不重置,上一个测试对 state 的修改会"泄漏"到下一个测试,导致测试之间互相影响、结果随执行顺序变化。setActivePinia(createPinia()) 保证每个测试都从干净状态开始,这是"测试独立性"原则的体现。

测试含异步 action 的 store:

javascriptCode
// stores/user.js
import { defineStore } from 'pinia';
import { fetchUser } from '@/api/user';

export const useUserStore = defineStore('user', {
  state: () => ({ user: null, loading: false }),
  actions: {
    async loadUser(id) {
      this.loading = true;
      this.user = await fetchUser(id);
      this.loading = false;
    }
  }
});
javascriptCode
// stores/user.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useUserStore } from './user';
import { fetchUser } from '@/api/user';

vi.mock('@/api/user');

describe('useUserStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
    vi.clearAllMocks();
  });

  it('应该异步加载用户', async () => {
    fetchUser.mockResolvedValue({ id: 1, name: '张三' });
    const store = useUserStore();

    await store.loadUser(1);

    expect(fetchUser).toHaveBeenCalledWith(1);
    expect(store.user).toEqual({ id: 1, name: '张三' });
    expect(store.loading).toBe(false);
  });
});

三、组件测试

组件测试挂载真实组件到虚拟 DOM,验证渲染结果、交互行为、事件触发,是最能反映用户体验的一层测试。

1. 使用 Vue Test Utils

基础组件:

vueCode
<!-- Button.vue -->
<script setup>
defineProps({
  type: {
    type: String,
    default: 'primary',
    validator: (value) => ['primary', 'secondary', 'danger'].includes(value)
  },
  disabled: {
    type: Boolean,
    default: false
  }
});

defineEmits(['click']);
</script>

<template>
  <button
    :class="['btn', `btn-${type}`]"
    :disabled="disabled"
    @click="$emit('click')"
  >
    <slot></slot>
  </button>
</template>

测试代码:

javascriptCode
// components/Button.test.js
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import Button from './Button.vue';

describe('Button', () => {
  it('应该渲染默认内容', () => {
    const wrapper = mount(Button, {
      slots: { default: '点击我' }
    });
    expect(wrapper.text()).toBe('点击我');
  });

  it('应该应用默认类型样式', () => {
    const wrapper = mount(Button);
    expect(wrapper.classes()).toContain('btn-primary');
  });

  it('应该应用自定义类型样式', () => {
    const wrapper = mount(Button, {
      props: { type: 'danger' }
    });
    expect(wrapper.classes()).toContain('btn-danger');
  });

  it('应该禁用按钮', () => {
    const wrapper = mount(Button, {
      props: { disabled: true }
    });
    expect(wrapper.attributes('disabled')).toBeDefined();
  });

  it('应该触发点击事件', async () => {
    const wrapper = mount(Button);
    await wrapper.trigger('click');
    expect(wrapper.emitted('click')).toHaveLength(1);
  });

  it('禁用时不应该触发点击事件', async () => {
    const wrapper = mount(Button, {
      props: { disabled: true }
    });
    await wrapper.trigger('click');
    expect(wrapper.emitted('click')).toBeUndefined();
  });
});

mount vs shallowMount:

| 方法 | 子组件 | 适用场景 |

|------|--------|----------|

| mount | 完整渲染子组件 | 集成行为、真实交互 |

| shallowMount | 子组件被 stub | 隔离测试当前组件、避免子组件副作用 |

javascriptCode
import { shallowMount } from '@vue/test-utils';
// 只测本组件逻辑,不关心子组件内部渲染
const wrapper = shallowMount(ParentComponent);

2. 测试带 Props 的组件

vueCode
<!-- UserCard.vue -->
<script setup>
const props = defineProps({
  user: {
    type: Object,
    required: true,
    validator: (user) => user.id && user.name
  }
});

defineEmits(['select']);
</script>

<template>
  <div class="user-card" @click="$emit('select', user)">
    <h3>{{ user.name }}</h3>
    <p>{{ user.email }}</p>
    <span v-if="user.role" class="role">{{ user.role }}</span>
  </div>
</template>

测试代码:

javascriptCode
// components/UserCard.test.js
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import UserCard from './UserCard.vue';

describe('UserCard', () => {
  const mockUser = {
    id: 1,
    name: '张三',
    email: 'zhangsan@example.com',
    role: 'admin'
  };

  it('应该渲染用户信息', () => {
    const wrapper = mount(UserCard, {
      props: { user: mockUser }
    });

    expect(wrapper.text()).toContain('张三');
    expect(wrapper.text()).toContain('zhangsan@example.com');
    expect(wrapper.text()).toContain('admin');
  });

  it('应该隐藏角色(当没有 role 时)', () => {
    const wrapper = mount(UserCard, {
      props: { user: { id: 1, name: '李四', email: 'lisi@example.com' } }
    });

    expect(wrapper.find('.role').exists()).toBe(false);
  });

  it('应该触发 select 事件', async () => {
    const wrapper = mount(UserCard, {
      props: { user: mockUser }
    });

    await wrapper.trigger('click');

    expect(wrapper.emitted('select')).toHaveLength(1);
    expect(wrapper.emitted('select')[0]).toEqual([mockUser]);
  });

  it('更新 props 后应重新渲染', async () => {
    const wrapper = mount(UserCard, {
      props: { user: mockUser }
    });
    await wrapper.setProps({ user: { id: 2, name: '王五', email: 'w@x.com' } });
    expect(wrapper.text()).toContain('王五');
  });
});

3. 测试带异步操作的组件

vueCode
<!-- UserList.vue -->
<script setup>
import { ref, onMounted } from 'vue';
import { fetchUsers } from '@/api/user';

const users = ref([]);
const loading = ref(false);
const error = ref(null);

const loadUsers = async () => {
  loading.value = true;
  error.value = null;
  try {
    users.value = await fetchUsers();
  } catch (e) {
    error.value = e.message;
  } finally {
    loading.value = false;
  }
};

onMounted(() => {
  loadUsers();
});
</script>

<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">错误:{{ error }}</div>
    <div v-else>
      <div v-for="user in users" :key="user.id">
        {{ user.name }}
      </div>
    </div>
  </div>
</template>

测试代码:

javascriptCode
// components/UserList.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import UserList from './UserList.vue';
import { fetchUsers } from '@/api/user';

vi.mock('@/api/user');

describe('UserList', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('应该显示加载状态', () => {
    fetchUsers.mockReturnValue(new Promise(() => {}));

    const wrapper = mount(UserList);
    expect(wrapper.text()).toContain('加载中...');
  });

  it('应该显示用户列表', async () => {
    const mockUsers = [
      { id: 1, name: '张三' },
      { id: 2, name: '李四' }
    ];
    fetchUsers.mockResolvedValue(mockUsers);

    const wrapper = mount(UserList);
    await flushPromises();

    expect(wrapper.text()).toContain('张三');
    expect(wrapper.text()).toContain('李四');
  });

  it('应该显示错误信息', async () => {
    fetchUsers.mockRejectedValue(new Error('网络错误'));

    const wrapper = mount(UserList);
    await flushPromises();

    expect(wrapper.text()).toContain('错误:网络错误');
  });
});

flushPromises 是什么? 它会"清空"所有已排队的微任务(Promise 回调),确保异步数据加载完成、组件完成重渲染后再断言。测试异步组件时忘记 await flushPromises 是最常见的错误来源,会导致断言在数据到来之前就执行、结果永远是加载态。

处理定时器的异步测试:

javascriptCode
import { vi } from 'vitest';

it('防抖搜索应在 300ms 后触发', async () => {
  vi.useFakeTimers();
  const onSearch = vi.fn();
  // ...触发输入...
  vi.advanceTimersByTime(300); // 快进 300ms
  expect(onSearch).toHaveBeenCalledOnce();
  vi.useRealTimers();
});

4. 测试带路由和 Pinia 的组件

真实组件常依赖路由和全局状态,挂载时需通过 global.plugins 注入:

javascriptCode
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import { createPinia } from 'pinia';
import Dashboard from './Dashboard.vue';

const router = createRouter({
  history: createMemoryHistory(),
  routes: [{ path: '/', component: { template: '<div/>' } }]
});

it('挂载带路由和 store 的组件', async () => {
  router.push('/');
  await router.isReady();

  const wrapper = mount(Dashboard, {
    global: {
      plugins: [router, createPinia()]
    }
  });
  expect(wrapper.exists()).toBe(true);
});

四、使用 Testing Library

Testing Library 提倡"像用户一样测试"——不关心组件内部有什么 ref、什么 class,只关心"用户能看到什么、能点什么"。

1. 基础用法

javascriptCode
// components/Counter.test.js
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/vue';
import Counter from './Counter.vue';

describe('Counter', () => {
  it('应该渲染初始计数', () => {
    render(Counter);
    expect(screen.getByText('计数:0')).toBeInTheDocument();
  });

  it('应该递增计数', async () => {
    render(Counter);

    const button = screen.getByRole('button', { name: /增加/i });
    await fireEvent.click(button);

    expect(screen.getByText('计数:1')).toBeInTheDocument();
  });

  it('应该递减计数', async () => {
    render(Counter, {
      props: { initialValue: 5 }
    });

    const button = screen.getByRole('button', { name: /减少/i });
    await fireEvent.click(button);

    expect(screen.getByText('计数:4')).toBeInTheDocument();
  });
});

2. 查询方法

javascriptCode
import { render, screen } from '@testing-library/vue';

// 常用查询方法
const {
  getByRole,        // 按角色查询(最推荐,贴近无障碍语义)
  getByText,        // 按文本查询
  getByLabelText,   // 按标签查询(表单)
  getByPlaceholderText, // 按占位符查询
  getByTestId       // 按测试 ID 查询(兜底方案)
} = render(Component);

// 变体
screen.getBy...;        // 找不到时抛出错误
screen.queryBy...;      // 找不到时返回 null(用于断言不存在)
screen.findBy...;       // 异步查询(返回 Promise,等元素出现)
screen.getAllBy...;     // 返回所有匹配

查询优先级建议(Testing Library 官方推荐顺序):

| 优先级 | 查询方式 | 理由 |

|--------|----------|------|

| 1 | getByRole | 最贴近用户和辅助技术 |

| 2 | getByLabelText | 表单场景语义清晰 |

| 3 | getByText | 非交互元素 |

| 4 | getByTestId | 前面都不适用时的兜底 |

3. 表单交互完整案例

javascriptCode
// components/LoginForm.test.js
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/vue';
import LoginForm from './LoginForm.vue';

describe('LoginForm', () => {
  it('填写并提交表单', async () => {
    const onSubmit = vi.fn();
    render(LoginForm, { props: { onSubmit } });

    await fireEvent.update(screen.getByLabelText('用户名'), 'admin');
    await fireEvent.update(screen.getByLabelText('密码'), '123456');
    await fireEvent.click(screen.getByRole('button', { name: /登录/ }));

    expect(onSubmit).toHaveBeenCalledWith({
      username: 'admin',
      password: '123456'
    });
  });

  it('空用户名应显示校验错误', async () => {
    render(LoginForm);
    await fireEvent.click(screen.getByRole('button', { name: /登录/ }));
    expect(screen.getByText('用户名不能为空')).toBeInTheDocument();
  });
});

Vue Test Utils vs Testing Library 对比:

| 维度 | Vue Test Utils | Testing Library |

|------|----------------|-----------------|

| 测试哲学 | 可访问内部(vm、props) | 只从用户视角 |

| 查询方式 | find(css 选择器) | getByRole/Text |

| 重构友好度 | 依赖结构,易碎 | 更稳定 |

| 学习曲线 | 熟悉 Vue 即可 | 需转变思维 |

| 适合 | Vue 特性细节 | 用户行为 |

五、端到端(E2E)测试

单元和组件测试都跑在模拟环境(jsdom)里,而 E2E 测试在真实浏览器中运行整个应用,验证从点击到接口返回的完整链路。主流工具是 Cypress 和 Playwright。

javascriptCode
// e2e/login.spec.js(Playwright 示例)
import { test, expect } from '@playwright/test';

test('用户可以登录并进入仪表盘', async ({ page }) => {
  await page.goto('http://localhost:5173/login');

  await page.fill('input[name="username"]', 'admin');
  await page.fill('input[name="password"]', '123456');
  await page.click('button[type="submit"]');

  // 断言跳转到仪表盘
  await expect(page).toHaveURL(/.*dashboard/);
  await expect(page.getByText('欢迎回来')).toBeVisible();
});

三层测试对比:

| 类型 | 运行环境 | 速度 | 稳定性 | 覆盖范围 | 建议占比 |

|------|----------|------|--------|----------|----------|

| 单元测试 | Node/jsdom | 极快 | 高 | 单个函数/模块 | 70% |

| 组件测试 | jsdom | 快 | 较高 | 单组件行为 | 20% |

| E2E 测试 | 真实浏览器 | 慢 | 较低(易 flaky) | 完整流程 | 10% |

六、测试最佳实践

1.测试行为而非实现:关注组件做什么,而不是怎么做。不要断言内部私有变量。
2.使用有意义的测试名称:描述"在什么条件下应该发生什么"。
3.保持测试独立:每个测试应该独立运行,不依赖执行顺序。
4.使用 beforeEach 清理:重置 Pinia、清空 Mock,确保测试环境干净。
5.Mock 外部依赖:隔离测试单元,网络请求、时间、随机数都应可控。
6.测试边界条件:空值、错误状态、极值等。
7.保持测试简洁:一个测试只验证一件事(单一断言原则的精神)。
8.AAA 结构:Arrange(准备)- Act(执行)- Assert(断言),让测试可读。
javascriptCode
// AAA 结构示例
it('点击后计数加一', async () => {
  // Arrange 准备
  const wrapper = mount(Counter);
  // Act 执行
  await wrapper.find('button').trigger('click');
  // Assert 断言
  expect(wrapper.text()).toContain('1');
});

反面教材(测试实现细节,脆弱):

javascriptCode
// 不好:断言内部 data,重构就挂
expect(wrapper.vm.internalCount).toBe(1);

// 好:断言用户可见的输出
expect(wrapper.text()).toContain('计数:1');

七、测试覆盖率

生成覆盖率报告:

bashCode
npm run test:coverage

配置覆盖率阈值:

javascriptCode
// vite.config.js
export default defineConfig({
  test: {
    coverage: {
      reporter: ['text', 'json', 'html'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80
      }
    }
  }
});

覆盖率四个指标的含义:

| 指标 | 含义 | 说明 |

|------|------|------|

| statements | 语句覆盖率 | 有多少语句被执行 |

| branches | 分支覆盖率 | if/else、三元等分支是否都覆盖 |

| functions | 函数覆盖率 | 有多少函数被调用 |

| lines | 行覆盖率 | 有多少行被执行 |

关于覆盖率的理性认识:覆盖率是有用的参考,但不是目标本身。100% 覆盖率不代表没有 Bug——它只说明"每行都跑过了",不代表"每种情况都断言正确"。行业经验值是核心业务逻辑 80% 以上、工具函数接近 100%、UI 展示层可适当放宽。追求有意义的测试,而不是为了刷数字写无断言的测试。

排除不需要测的文件:

javascriptCode
coverage: {
  exclude: [
    'node_modules/',
    '**/*.config.js',
    '**/*.test.js',
    'src/main.js',
    'src/router/**'
  ]
}

十一、测试组合式函数(Composables)

Composable 是 Vue 3 逻辑复用的核心,也是最适合单元测试的部分——它是纯逻辑,无需渲染。

1. 测试无生命周期依赖的 composable

javascriptCode
// composables/useCounter.js
import { ref } from 'vue';
export function useCounter(initial = 0) {
  const count = ref(initial);
  const increment = () => count.value++;
  const decrement = () => count.value--;
  const reset = () => (count.value = initial);
  return { count, increment, decrement, reset };
}
javascriptCode
import { describe, it, expect } from 'vitest';
import { useCounter } from '@/composables/useCounter';

describe('useCounter', () => {
  it('初始值正确', () => {
    const { count } = useCounter(5);
    expect(count.value).toBe(5);
  });

  it('increment 加一', () => {
    const { count, increment } = useCounter();
    increment();
    expect(count.value).toBe(1);
  });

  it('reset 恢复初始值', () => {
    const { count, increment, reset } = useCounter(10);
    increment();
    reset();
    expect(count.value).toBe(10);
  });
});

2. 测试依赖生命周期的 composable

如果 composable 内部用了 onMounted、onUnmounted,直接调用会警告"没有活动的组件实例"。需要用一个宿主组件包裹。

javascriptCode
import { mount } from '@vue/test-utils';

// 辅助函数:在组件上下文中运行 composable
function withSetup(composable) {
  let result;
  const wrapper = mount({
    setup() {
      result = composable();
      return () => {}; // 空渲染
    }
  });
  return [result, wrapper];
}

it('useMouse 挂载后监听鼠标', () => {
  const [result, wrapper] = withSetup(() => useMouse());
  expect(result.x.value).toBe(0);
  // 触发 window 事件
  window.dispatchEvent(new MouseEvent('mousemove', { pageX: 100, pageY: 50 }));
  expect(result.x.value).toBe(100);
  wrapper.unmount(); // 触发 onUnmounted 清理
});

3. 测试带异步的 composable

javascriptCode
// composables/useFetch.js
import { ref } from 'vue';
export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(true);
  fetch(url)
    .then((r) => r.json())
    .then((d) => (data.value = d))
    .catch((e) => (error.value = e))
    .finally(() => (loading.value = false));
  return { data, error, loading };
}
javascriptCode
import { vi, it, expect } from 'vitest';
import { flushPromises } from '@vue/test-utils';

it('useFetch 成功获取数据', async () => {
  // mock 全局 fetch
  global.fetch = vi.fn().mockResolvedValue({
    json: () => Promise.resolve({ name: 'Alice' })
  });

  const { data, loading } = useFetch('/api/user');
  expect(loading.value).toBe(true);

  await flushPromises(); // 等所有微任务完成

  expect(loading.value).toBe(false);
  expect(data.value).toEqual({ name: 'Alice' });
});

十二、测试 Pinia Store

Pinia 官方提供了测试工具,让 store 测试变得简单。

1. 测试 store 的 state / getters / actions

javascriptCode
// stores/cart.js
import { defineStore } from 'pinia';
export const useCartStore = defineStore('cart', {
  state: () => ({ items: [] }),
  getters: {
    total: (state) => state.items.reduce((s, i) => s + i.price * i.qty, 0),
    count: (state) => state.items.length
  },
  actions: {
    addItem(item) {
      const exist = this.items.find((i) => i.id === item.id);
      if (exist) exist.qty++;
      else this.items.push({ ...item, qty: 1 });
    },
    removeItem(id) {
      this.items = this.items.filter((i) => i.id !== id);
    }
  }
});
javascriptCode
import { setActivePinia, createPinia } from 'pinia';
import { beforeEach, describe, it, expect } from 'vitest';
import { useCartStore } from '@/stores/cart';

describe('cart store', () => {
  beforeEach(() => {
    // 每个测试前创建全新 pinia,保证隔离
    setActivePinia(createPinia());
  });

  it('添加商品', () => {
    const cart = useCartStore();
    cart.addItem({ id: 1, price: 100 });
    expect(cart.count).toBe(1);
    expect(cart.total).toBe(100);
  });

  it('重复添加只增加数量', () => {
    const cart = useCartStore();
    cart.addItem({ id: 1, price: 100 });
    cart.addItem({ id: 1, price: 100 });
    expect(cart.count).toBe(1);
    expect(cart.total).toBe(200);
  });

  it('移除商品', () => {
    const cart = useCartStore();
    cart.addItem({ id: 1, price: 100 });
    cart.removeItem(1);
    expect(cart.count).toBe(0);
  });
});

2. 在组件测试中 mock store

用 @pinia/testing 的 createTestingPinia 可以自动 mock 所有 action,方便断言"是否被调用"。

javascriptCode
import { createTestingPinia } from '@pinia/testing';
import { mount } from '@vue/test-utils';
import { vi } from 'vitest';

it('点击加入购物车调用 addItem', async () => {
  const wrapper = mount(ProductCard, {
    props: { product: { id: 1, price: 100 } },
    global: {
      plugins: [createTestingPinia({ createSpy: vi.fn })]
    }
  });
  const cart = useCartStore();
  await wrapper.find('.add-btn').trigger('click');
  expect(cart.addItem).toHaveBeenCalledWith({ id: 1, price: 100 });
});

createTestingPinia 默认会 stub 掉 action(不真正执行),只记录调用;若想让 action 真正执行,传 stubActions: false。

十三、测试 Vue Router 相关逻辑

1. 测试含路由的组件

javascriptCode
import { createRouter, createMemoryHistory } from 'vue-router';
import { mount, flushPromises } from '@vue/test-utils';

const routes = [
  { path: '/', component: { template: '<div>home</div>' } },
  { path: '/detail/:id', name: 'detail', component: { template: '<div>detail</div>' } }
];

it('点击卡片跳转到详情页', async () => {
  const router = createRouter({ history: createMemoryHistory(), routes });
  router.push('/');
  await router.isReady();

  const wrapper = mount(ProductList, {
    global: { plugins: [router] }
  });
  await wrapper.find('.card').trigger('click');
  await flushPromises();

  expect(router.currentRoute.value.name).toBe('detail');
  expect(router.currentRoute.value.params.id).toBe('1');
});

2. mock useRouter / useRoute

如果不想引入真实 router,可以直接 mock。

javascriptCode
import { vi } from 'vitest';

const pushMock = vi.fn();
vi.mock('vue-router', () => ({
  useRouter: () => ({ push: pushMock }),
  useRoute: () => ({ params: { id: '42' }, query: {} })
}));

it('提交后跳转', async () => {
  const wrapper = mount(Form);
  await wrapper.find('form').trigger('submit');
  expect(pushMock).toHaveBeenCalledWith('/success');
});

十四、Mock 深入

Mock 是测试中最容易出错也最强大的部分。理解 Vitest 的各种 mock 手段是写好测试的关键。

1. vi.fn 函数 mock

javascriptCode
import { vi, expect, it } from 'vitest';

it('回调被正确调用', () => {
  const callback = vi.fn();
  [1, 2, 3].forEach(callback);
  expect(callback).toHaveBeenCalledTimes(3);
  expect(callback).toHaveBeenNthCalledWith(1, 1, 0, [1, 2, 3]);
  expect(callback).toHaveReturnedTimes(3);
});

it('可指定返回值', () => {
  const fn = vi.fn().mockReturnValue(42);
  expect(fn()).toBe(42);
  const asyncFn = vi.fn().mockResolvedValue({ ok: true });
  return expect(asyncFn()).resolves.toEqual({ ok: true });
});

2. vi.mock 模块 mock

javascriptCode
// 完全替换一个模块
import { vi } from 'vitest';
import { getUser } from '@/api/user';

vi.mock('@/api/user', () => ({
  getUser: vi.fn().mockResolvedValue({ id: 1, name: 'Mock User' })
}));

it('使用 mock 的 API', async () => {
  const user = await getUser(1);
  expect(user.name).toBe('Mock User');
});

部分 mock(保留其余真实实现):

javascriptCode
vi.mock('@/utils', async (importOriginal) => {
  const actual = await importOriginal();
  return {
    ...actual,             // 保留真实实现
    formatDate: vi.fn(() => '2026-08-04') // 只 mock 这一个
  };
});

3. mock 定时器

javascriptCode
import { vi, it, expect, beforeEach, afterEach } from 'vitest';

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it('防抖 300ms 后执行', () => {
  const fn = vi.fn();
  const debounced = debounce(fn, 300);
  debounced();
  debounced();
  expect(fn).not.toHaveBeenCalled();
  vi.advanceTimersByTime(300); // 快进时间
  expect(fn).toHaveBeenCalledTimes(1);
});

4. mock 网络请求:用 MSW

相比直接 mock fetch,MSW(Mock Service Worker)在网络层拦截请求,更接近真实,且单测和 E2E 可复用同一套 mock。

javascriptCode
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('/api/users', () => {
    return HttpResponse.json([{ id: 1, name: 'Alice' }]);
  }),
  http.post('/api/login', async ({ request }) => {
    const body = await request.json();
    if (body.password === 'wrong') {
      return new HttpResponse(null, { status: 401 });
    }
    return HttpResponse.json({ token: 'abc' });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // 每个测试后重置
afterAll(() => server.close());

it('登录成功拿到 token', async () => {
  const res = await fetch('/api/login', {
    method: 'POST',
    body: JSON.stringify({ password: 'right' })
  });
  const data = await res.json();
  expect(data.token).toBe('abc');
});

5. spyOn 监视已有方法

javascriptCode
import { vi } from 'vitest';

it('监视 console.error', () => {
  const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
  doSomethingThatLogsError();
  expect(spy).toHaveBeenCalled();
  spy.mockRestore(); // 恢复原方法
});

十五、测试组件的 props、emit、slots

组件的对外接口就是 props、events、slots,这三者是组件测试的重点。

1. 测试 props

javascriptCode
import { mount } from '@vue/test-utils';

it('根据 props 渲染标题', () => {
  const wrapper = mount(Card, { props: { title: '标题', level: 2 } });
  expect(wrapper.find('h2').text()).toBe('标题');
});

it('props 变化后视图更新', async () => {
  const wrapper = mount(Badge, { props: { count: 1 } });
  expect(wrapper.text()).toContain('1');
  await wrapper.setProps({ count: 99 }); // 更新 props
  expect(wrapper.text()).toContain('99');
});

it('props 校验:count 超过 99 显示 99+', () => {
  const wrapper = mount(Badge, { props: { count: 200 } });
  expect(wrapper.text()).toContain('99+');
});

2. 测试 emit 事件

javascriptCode
it('点击按钮触发 submit 事件', async () => {
  const wrapper = mount(LoginForm);
  await wrapper.find('input[name="user"]').setValue('alice');
  await wrapper.find('input[name="pwd"]').setValue('123456');
  await wrapper.find('form').trigger('submit');

  // emitted() 返回所有触发过的事件
  const submitEvents = wrapper.emitted('submit');
  expect(submitEvents).toHaveLength(1);
  // 检查事件携带的 payload
  expect(submitEvents[0][0]).toEqual({ user: 'alice', pwd: '123456' });
});

it('输入为空时不触发 submit', async () => {
  const wrapper = mount(LoginForm);
  await wrapper.find('form').trigger('submit');
  expect(wrapper.emitted('submit')).toBeUndefined();
});

3. 测试 slots

javascriptCode
it('渲染默认插槽内容', () => {
  const wrapper = mount(Panel, {
    slots: { default: '<p>正文内容</p>' }
  });
  expect(wrapper.html()).toContain('<p>正文内容</p>');
});

it('渲染具名插槽', () => {
  const wrapper = mount(Panel, {
    slots: {
      header: '<h1>头部</h1>',
      footer: '<div>底部</div>'
    }
  });
  expect(wrapper.find('h1').text()).toBe('头部');
});

it('作用域插槽接收数据', () => {
  const wrapper = mount(List, {
    props: { items: [{ id: 1, name: 'A' }] },
    slots: {
      // 作用域插槽用函数接收 props
      item: ({ item }) => `<span>${item.name}</span>`
    }
  });
  expect(wrapper.text()).toContain('A');
});

十六、测试 v-model 与表单

1. 测试原生表单元素

javascriptCode
it('输入框双向绑定', async () => {
  const wrapper = mount(SearchBox);
  const input = wrapper.find('input');
  await input.setValue('vue test');
  expect(wrapper.vm.keyword).toBe('vue test');
});

it('复选框切换', async () => {
  const wrapper = mount(Agreement);
  const checkbox = wrapper.find('input[type="checkbox"]');
  await checkbox.setValue(true);
  expect(wrapper.find('button').attributes('disabled')).toBeUndefined();
});

it('下拉选择', async () => {
  const wrapper = mount(CitySelect);
  await wrapper.find('select').setValue('shanghai');
  expect(wrapper.emitted('change')[0]).toEqual(['shanghai']);
});

2. 测试自定义组件的 v-model

javascriptCode
// 自定义组件通过 modelValue + update:modelValue 实现 v-model
it('自定义 v-model 组件', async () => {
  const wrapper = mount(CustomInput, {
    props: {
      modelValue: 'init',
      'onUpdate:modelValue': (e) => wrapper.setProps({ modelValue: e })
    }
  });
  await wrapper.find('input').setValue('changed');
  expect(wrapper.props('modelValue')).toBe('changed');
});

3. 测试表单校验

javascriptCode
it('邮箱格式错误显示提示', async () => {
  const wrapper = mount(RegisterForm);
  await wrapper.find('input[name="email"]').setValue('not-an-email');
  await wrapper.find('input[name="email"]').trigger('blur');
  expect(wrapper.find('.error-msg').text()).toContain('邮箱格式不正确');
});

it('校验全部通过才能提交', async () => {
  const wrapper = mount(RegisterForm);
  await wrapper.find('input[name="email"]').setValue('a@b.com');
  await wrapper.find('input[name="pwd"]').setValue('12345678');
  await wrapper.find('form').trigger('submit');
  expect(wrapper.emitted('submit')).toBeTruthy();
});

十七、测试 provide / inject

javascriptCode
it('注入主题', () => {
  const wrapper = mount(ThemedButton, {
    global: {
      provide: { theme: 'dark' } // 提供依赖注入
    }
  });
  expect(wrapper.classes()).toContain('btn-dark');
});

对于用 Symbol 作 key 的 provide,测试时需要导入同一个 Symbol:

javascriptCode
import { THEME_KEY } from '@/keys';

const wrapper = mount(Comp, {
  global: { provide: { [THEME_KEY]: 'light' } }
});

十八、快照测试

快照测试记录组件渲染结果,之后每次运行都对比,防止意外的 UI 变化。

javascriptCode
import { mount } from '@vue/test-utils';
import { expect, it } from 'vitest';

it('组件渲染结构快照', () => {
  const wrapper = mount(PriceTag, { props: { amount: 99.9, currency: 'CNY' } });
  expect(wrapper.html()).toMatchSnapshot();
});

首次运行生成快照文件,后续渲染变化会导致测试失败并显示 diff。UI 确实改了就用 vitest -u 更新快照。

快照测试的取舍:

| 优点 | 缺点 |

|------|------|

| 写起来快,覆盖大范围 | 容易变脆(无关改动也失败) |

| 能发现意外结构变化 | 快照太大时难 review |

| 适合稳定的展示组件 | 不适合频繁变动的组件 |

建议: 快照要小而聚焦(用内联快照 toMatchInlineSnapshot 更好 review),不要对整个大页面打快照;核心逻辑仍应用显式断言,快照作为补充。

javascriptCode
it('内联快照更易 review', () => {
  const result = formatPrice(1234.5);
  expect(result).toMatchInlineSnapshot('"¥1,234.50"');
});

十九、测试 Teleport、Suspense 与异步组件

1. 测试 Teleport

Teleport 会把内容渲染到别处(如 body),断言时要注意查找范围。

javascriptCode
import { mount } from '@vue/test-utils';

it('弹窗 Teleport 到 body', async () => {
  const wrapper = mount(Modal, {
    props: { visible: true },
    attachTo: document.body
  });
  // Teleport 的内容在 document 里,不在 wrapper 内
  expect(document.body.querySelector('.modal-content')).not.toBeNull();
  wrapper.unmount();
});

2. 测试异步组件与 Suspense

javascriptCode
import { flushPromises, mount } from '@vue/test-utils';

it('异步组件加载完成后渲染', async () => {
  const wrapper = mount(AsyncWrapper);
  // 初始显示 fallback
  expect(wrapper.text()).toContain('加载中');
  await flushPromises(); // 等异步组件解析
  expect(wrapper.text()).toContain('真实内容');
});

二十、Testing Library 风格测试

@testing-library/vue 提倡"像用户一样测试"——通过可见文本、角色、标签查找元素,而非 class/id,测试更贴近真实使用,也更抗重构。

javascriptCode
import { render, screen, fireEvent } from '@testing-library/vue';
import { it, expect } from 'vitest';
import Counter from '@/components/Counter.vue';

it('用户点击增加计数', async () => {
  render(Counter);
  // 通过可访问角色和名称查找,而非 CSS 选择器
  const button = screen.getByRole('button', { name: /增加/ });
  expect(screen.getByText('当前:0')).toBeInTheDocument();
  await fireEvent.click(button);
  expect(screen.getByText('当前:1')).toBeInTheDocument();
});

it('通过 label 查找输入框', async () => {
  render(LoginForm);
  const input = screen.getByLabelText('用户名');
  await fireEvent.update(input, 'alice');
  expect(input.value).toBe('alice');
});

Vue Test Utils vs Testing Library:

| 维度 | Vue Test Utils | Testing Library |

|------|----------------|-----------------|

| 查找方式 | find(css) / vm 内部 | getByRole/Text/Label |

| 测试视角 | 偏组件实现 | 偏用户行为 |

| 抗重构性 | 较弱(依赖结构) | 强(依赖可见性) |

| 无障碍 | 不强调 | 天然引导 a11y |

| 学习曲线 | 平缓 | 需转变思路 |

两者可以共存:核心交互用 Testing Library 保证抗重构,需要访问组件内部状态时用 Vue Test Utils。

二十一、端到端测试深入(Playwright)

E2E 在真实浏览器里跑完整流程,是最接近用户的测试。

1. 一个完整的登录到下单流程

javascriptCode
import { test, expect } from '@playwright/test';

test('用户登录并下单', async ({ page }) => {
  await page.goto('/login');
  // 用可访问性定位,稳定且贴近用户
  await page.getByLabel('用户名').fill('testuser');
  await page.getByLabel('密码').fill('password123');
  await page.getByRole('button', { name: '登录' }).click();

  // 断言跳转到首页
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('欢迎,testuser')).toBeVisible();

  // 加入购物车
  await page.goto('/products');
  await page.getByRole('button', { name: '加入购物车' }).first().click();
  await expect(page.getByTestId('cart-count')).toHaveText('1');

  // 结算
  await page.getByRole('link', { name: '购物车' }).click();
  await page.getByRole('button', { name: '去结算' }).click();
  await expect(page.getByText('订单提交成功')).toBeVisible();
});

2. 网络拦截与 mock

javascriptCode
test('接口失败时显示错误提示', async ({ page }) => {
  // 拦截接口返回 500
  await page.route('**/api/orders', (route) =>
    route.fulfill({ status: 500, body: 'Server Error' })
  );
  await page.goto('/checkout');
  await page.getByRole('button', { name: '提交订单' }).click();
  await expect(page.getByText('提交失败,请重试')).toBeVisible();
});

3. 等待策略(避免 flaky 测试)

javascriptCode
// 反例:写死 sleep,慢了会失败、快了浪费时间
// await page.waitForTimeout(3000);

// 正例:等待具体条件
await expect(page.getByRole('list')).toBeVisible();
await page.waitForResponse((res) => res.url().includes('/api/data') && res.ok());
await page.waitForLoadState('networkidle');

4. 视觉回归与多设备

javascriptCode
test('首页视觉快照', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('home.png', { maxDiffPixels: 100 });
});

// playwright.config.js 中配置多个设备 project
// projects: [{ name: 'chromium' }, { name: 'Mobile Safari', use: devices['iPhone 13'] }]

二十二、测试与 CI 集成

测试的价值在于自动化执行、拦截问题。接入 CI 是必要一环。

yamlCode
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npm run test:unit -- --coverage
      - uses: codecov/codecov-action@v4  # 上传覆盖率
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run build && npm run test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with: { name: playwright-report, path: playwright-report/ }

CI 中的测试策略:

单元/组件测试每次 push 都跑(快,秒级到分钟级)。
E2E 在 PR 和合并主干时跑(慢,可并行分片加速)。
覆盖率设阈值,低于阈值 PR 不通过。
失败时保留 Playwright 的 trace 和截图,方便排查。

二十三、TDD:测试驱动开发实战

TDD 的节奏是"红-绿-重构":先写会失败的测试(红),写最少代码让它通过(绿),再优化代码(重构)。下面用一个"手机号脱敏"函数演示。

第一步:红(写失败的测试)

javascriptCode
import { describe, it, expect } from 'vitest';
import { maskPhone } from '@/utils/maskPhone';

describe('maskPhone', () => {
  it('中间四位替换为星号', () => {
    expect(maskPhone('13812345678')).toBe('138****5678');
  });
});
// 此时 maskPhone 还不存在,测试失败(红)

第二步:绿(最少代码通过)

javascriptCode
// utils/maskPhone.js
export function maskPhone(phone) {
  return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
// 测试通过(绿)

第三步:补充边界用例(回到红)

javascriptCode
it('非法输入原样返回', () => {
  expect(maskPhone('123')).toBe('123');
  expect(maskPhone('')).toBe('');
});
it('处理 null/undefined 不报错', () => {
  expect(maskPhone(null)).toBe('');
});

第四步:完善实现(回到绿)

javascriptCode
export function maskPhone(phone) {
  if (!phone || typeof phone !== 'string') return '';
  return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}

第五步:重构(保持绿)

代码已经简洁,无需重构。若逻辑复杂可在测试保护下放心重构。

TDD 的价值:

测试即需求文档,先想清楚"要什么"再写代码。
天然高覆盖率,且测试都是有意义的断言。
重构有安全网,敢于优化。
倒逼写出可测试(低耦合、职责单一)的代码。

二十四、测试组织与命名规范

1. AAA 模式(Arrange-Act-Assert)

每个测试分三段:准备数据、执行动作、断言结果,结构清晰。

javascriptCode
it('折扣计算正确', () => {
  // Arrange 准备
  const order = { total: 200, vipLevel: 2 };
  // Act 执行
  const result = calcDiscount(order);
  // Assert 断言
  expect(result).toBe(180);
});

2. 描述性命名

javascriptCode
// 反例:看不出测什么
it('test1', () => {});
it('works', () => {});

// 正例:描述"在什么条件下,应该有什么行为"
it('当用户未登录时,访问受保护页面应重定向到登录页', () => {});
it('当购物车为空时,结算按钮应被禁用', () => {});

3. 用 describe 分组、beforeEach 复用

javascriptCode
describe('购物车', () => {
  let cart;
  beforeEach(() => {
    cart = createCart(); // 每个测试前重建,保证隔离
  });

  describe('添加商品', () => {
    it('添加新商品数量为 1', () => { /* ... */ });
    it('添加已有商品数量累加', () => { /* ... */ });
  });

  describe('删除商品', () => {
    it('删除后商品消失', () => { /* ... */ });
  });
});

4. 常用断言速查

javascriptCode
expect(value).toBe(3);                    // 严格相等(基本类型)
expect(obj).toEqual({ a: 1 });            // 深度相等(对象)
expect(arr).toContain(2);                 // 数组包含
expect(fn).toThrow('错误信息');            // 抛出异常
expect(value).toBeTruthy();               // 真值
expect(value).toBeNull();                 // 为 null
expect(num).toBeGreaterThan(5);           // 数值比较
expect(str).toMatch(/正则/);               // 正则匹配
expect(mockFn).toHaveBeenCalledWith('x'); // 调用参数
await expect(promise).resolves.toBe(1);   // Promise 成功
await expect(promise).rejects.toThrow();  // Promise 失败

二十五、测试反模式与常见坑

1. 测试实现细节而非行为

javascriptCode
// 反例:测内部方法名和内部状态,重构就挂
expect(wrapper.vm.handleClick).toBeDefined();
expect(wrapper.vm._internalCount).toBe(1);

// 正例:测用户可见的行为
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toContain('已点击');

2. 测试之间互相依赖

javascriptCode
// 反例:test B 依赖 test A 留下的状态,顺序一变就失败
let sharedUser;
it('创建用户', () => { sharedUser = createUser(); });
it('修改用户', () => { sharedUser.name = 'new'; }); // 危险

// 正例:每个测试自给自足
it('修改用户', () => {
  const user = createUser();
  user.name = 'new';
  expect(user.name).toBe('new');
});

3. 没有断言的"假测试"

javascriptCode
// 反例:只执行不断言,永远绿灯,毫无价值
it('渲染组件', () => {
  mount(MyComponent); // 没有 expect
});

4. 过度 mock 导致测了个寂寞

把所有依赖都 mock 掉,测试通过但真实场景全是 bug。mock 应只针对外部边界(网络、时间、随机数),核心逻辑要真实执行。

5. 忽略异步

javascriptCode
// 反例:没等异步完成就断言
it('加载数据', () => {
  const wrapper = mount(List);
  expect(wrapper.text()).toContain('数据'); // 此时还没加载完,失败
});

// 正例:await flushPromises 或 nextTick
it('加载数据', async () => {
  const wrapper = mount(List);
  await flushPromises();
  expect(wrapper.text()).toContain('数据');
});

常见坑速查表:

| 坑 | 现象 | 解决 |

|----|------|------|

| 忘记 await | 断言在异步前跑 | await trigger/setValue/flushPromises |

| 状态泄漏 | 测试顺序影响结果 | beforeEach 重建、setActivePinia |

| 测实现细节 | 重构后大量测试失败 | 测行为、用可见断言 |

| 没重置 mock | mock 调用次数累加 | afterEach vi.clearAllMocks |

| DOM 不更新 | setProps 后取值是旧的 | await setProps |

| 定时器不触发 | 依赖真实时间 | vi.useFakeTimers + advanceTimersByTime |

二十六、测试自定义指令与插件

1. 测试自定义指令

javascriptCode
import { mount } from '@vue/test-utils';
import { vPermission } from '@/directives/permission';

it('无权限时元素被移除', () => {
  const wrapper = mount(
    { template: '<button v-permission="[\'admin\']">删除</button>' },
    {
      global: {
        directives: { permission: vPermission },
        provide: { permissions: ['user'] } // 无 admin 权限
      }
    }
  );
  expect(wrapper.find('button').exists()).toBe(false);
});

2. 测试全局插件

javascriptCode
it('插件注册全局属性', () => {
  const wrapper = mount(Comp, {
    global: {
      plugins: [myPlugin],
      mocks: { $t: (key) => key } // mock i18n 的 $t
    }
  });
  expect(wrapper.text()).toContain('welcome');
});

二十七、测试错误处理

1. 测试 onErrorCaptured 错误边界

javascriptCode
it('子组件报错时错误边界捕获', async () => {
  const Boom = { setup() { throw new Error('boom'); return () => null; } };
  const wrapper = mount(ErrorBoundary, {
    slots: { default: Boom }
  });
  await flushPromises();
  expect(wrapper.text()).toContain('出错了');
});

2. 测试异步错误

javascriptCode
it('接口失败显示错误态', async () => {
  global.fetch = vi.fn().mockRejectedValue(new Error('Network'));
  const wrapper = mount(DataList);
  await flushPromises();
  expect(wrapper.find('.error').text()).toContain('加载失败');
});

二十八、测试可访问性(a11y)

结合 axe-core 可以在测试中自动检查无障碍问题。

javascriptCode
import { render } from '@testing-library/vue';
import { axe } from 'vitest-axe';

it('表单无障碍无违规', async () => {
  const { container } = render(LoginForm);
  const results = await axe(container);
  expect(results).toHaveNoViolations(); // 检查 label、对比度、role 等
});

二十九、基准测试(性能测试)

Vitest 内置 bench,可以测函数性能,防止性能回退。

javascriptCode
import { bench, describe } from 'vitest';

describe('数组去重性能', () => {
  const arr = Array.from({ length: 10000 }, (_, i) => i % 100);

  bench('Set 去重', () => {
    [...new Set(arr)];
  });

  bench('filter + indexOf 去重', () => {
    arr.filter((x, i) => arr.indexOf(x) === i);
  });
});
// 运行 vitest bench 会输出每种实现的 ops/sec 对比

三十、测试策略与投入产出

不是所有代码都值得同样力度的测试。按价值和风险分配测试投入。

| 代码类型 | 测试力度 | 原因 |

|----------|----------|------|

| 工具函数/纯逻辑 | 高(接近 100%) | 便宜、稳定、复用广 |

| 核心业务逻辑 | 高(80%+) | 出错代价大 |

| composable/store | 高 | 逻辑集中、复用 |

| 通用组件 | 中高 | 影响面广 |

| 业务组件 | 中 | 变动频繁 |

| 纯展示 UI | 低 | 变动多、价值低 |

| 三方库封装 | 中 | 测边界即可 |

测试金字塔的比例参考: 单元测试约 70%,组件/集成测试约 20%,E2E 约 10%。底层多而快,顶层少而关键。倒金字塔(大量 E2E、少量单测)会导致测试又慢又脆。

三十一、常见问题 FAQ

Q:单元测试和集成测试怎么区分?

单元测试测单个函数/组件的孤立行为,依赖都 mock;集成测试测多个单元协作(如组件+store+路由),更接近真实。界限不必太较真,重点是覆盖有价值的场景。

Q:私有方法要测吗?

不要直接测私有方法。私有方法是实现细节,应通过公开接口间接覆盖。如果私有方法复杂到想单独测,说明它该被抽成独立的可测函数。

Q:测试要不要连数据库/真实接口?

单元和组件测试不要,用 mock/MSW。真实接口交给少量 E2E 或专门的契约测试,避免测试慢且不稳定。

Q:覆盖率要追求 100% 吗?

不必。100% 覆盖率成本高且有边际递减,还可能诱导写无意义的测试。核心逻辑高覆盖、整体 80% 左右是务实的目标。

Q:测试写不动怎么办?

如果一个组件/函数很难测,往往是设计问题(职责太多、耦合太重)。难测是重构的信号,把逻辑抽离、依赖注入后就好测了。

三十二、面试高频问答

Q1:前端为什么要写测试?

测试能在早期拦截 bug(越早修复成本越低)、支撑重构、充当活文档、提升代码设计质量。虽然写测试有成本,但长期看维护成本更低、迭代更稳。

Q2:测试金字塔是什么?

自底向上是单元测试、集成/组件测试、E2E 测试。越底层越多、越快、越便宜;越顶层越少、越慢、越真实。合理比例约 70/20/10,避免倒金字塔。

Q3:如何测试异步逻辑?

用 async/await 配合 flushPromises(等微任务)、nextTick(等 DOM 更新);定时器用 vi.useFakeTimers 配合 advanceTimersByTime;网络用 mock 或 MSW。

Q4:Vue Test Utils 和 Testing Library 有什么区别?

VTU 偏组件实现,可访问 vm 内部;Testing Library 偏用户行为,通过可见文本/角色查找,抗重构、引导无障碍。可结合使用。

Q5:什么是 mock?什么时候该 mock?

mock 是用可控的替身替换真实依赖。应对外部边界(网络、时间、随机、第三方)做 mock,核心业务逻辑要真实执行。过度 mock 会让测试失去意义。

Q6:如何避免 flaky(不稳定)测试?

不用固定 sleep 而用条件等待;保证测试相互隔离、每次重置状态;固定时间和随机数;避免依赖网络和真实时序。

三十三、Vitest 配置详解

一套合理的配置是高效测试的基础。

javascriptCode
// vitest.config.js
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath } from 'node:url';

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  test: {
    globals: true,           // 无需 import describe/it/expect
    environment: 'jsdom',    // 提供 DOM 环境
    setupFiles: ['./tests/setup.js'], // 全局初始化
    css: true,               // 处理组件里的 css import
    clearMocks: true,        // 每个测试自动清 mock
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80
      }
    }
  }
});

1. jsdom vs happy-dom

| 环境 | 特点 | 适用 |

|------|------|------|

| jsdom | 功能全、社区大、稍慢 | 需要完整 DOM API |

| happy-dom | 轻量、快约 2~3 倍 | 大多数组件测试 |

| node | 无 DOM | 纯逻辑/工具函数 |

可以按文件指定环境:

javascriptCode
// @vitest-environment happy-dom
// 文件顶部注释指定该文件用 happy-dom

2. 全局 setup 文件

javascriptCode
// tests/setup.js
import { config } from '@vue/test-utils';
import { vi, afterEach } from 'vitest';
import '@testing-library/jest-dom'; // 扩展 DOM 断言

// 全局 mock 常用组件/指令
config.global.stubs = {
  transition: false,      // 让 transition 直接渲染
  'router-link': true     // stub 掉 router-link
};

// mock 浏览器 API(jsdom 没有的)
global.IntersectionObserver = vi.fn(() => ({
  observe: vi.fn(),
  unobserve: vi.fn(),
  disconnect: vi.fn()
}));
global.matchMedia = vi.fn().mockImplementation((q) => ({
  matches: false, media: q, addEventListener: vi.fn(), removeEventListener: vi.fn()
}));

afterEach(() => {
  vi.clearAllMocks();
});

jsdom 没有实现 IntersectionObserver、matchMedia、ResizeObserver、scrollTo 等 API,用到它们的组件必须在 setup 里 mock,否则测试会报错。

三十四、常用运行命令与调试

bashCode
# 监听模式(开发时用,改文件自动重跑)
npx vitest

# 单次运行(CI 用)
npx vitest run

# 只跑某个文件
npx vitest run tests/cart.test.js

# 按名称过滤
npx vitest -t "购物车"

# 带覆盖率
npx vitest run --coverage

# 更新快照
npx vitest run -u

# UI 模式(浏览器里可视化查看)
npx vitest --ui

# 基准测试
npx vitest bench

调试测试的技巧

javascriptCode
import { mount } from '@vue/test-utils';

it('调试渲染结果', () => {
  const wrapper = mount(MyComponent);
  // 打印当前渲染的 HTML,快速看到实际结构
  console.log(wrapper.html());
  // Testing Library 的 screen.debug() 更友好
});
用 it.only / describe.only 只跑当前用例,快速聚焦。
用 it.skip / it.todo 临时跳过或标记待写。
在 VS Code 里配合 Vitest 插件可以点击行内运行/调试。
javascriptCode
it.only('只跑这个', () => {});
it.skip('暂时跳过', () => {});
it.todo('待补充:边界情况');

三十五、真实组件测试案例:分页器

把前面的知识综合到一个真实组件的完整测试。

vueCode
<!-- Pagination.vue -->
<script setup>
import { computed } from 'vue';
const props = defineProps({
  total: { type: Number, required: true },
  pageSize: { type: Number, default: 10 },
  modelValue: { type: Number, default: 1 }
});
const emit = defineEmits(['update:modelValue']);

const totalPages = computed(() => Math.ceil(props.total / props.pageSize));
const canPrev = computed(() => props.modelValue > 1);
const canNext = computed(() => props.modelValue < totalPages.value);

function go(page) {
  if (page < 1 || page > totalPages.value) return;
  emit('update:modelValue', page);
}
</script>

<template>
  <div class="pagination">
    <button class="prev" :disabled="!canPrev" @click="go(modelValue - 1)">上一页</button>
    <span class="info">{{ modelValue }} / {{ totalPages }}</span>
    <button class="next" :disabled="!canNext" @click="go(modelValue + 1)">下一页</button>
  </div>
</template>
javascriptCode
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import Pagination from '@/components/Pagination.vue';

describe('Pagination 分页器', () => {
  it('根据 total 和 pageSize 计算总页数', () => {
    const wrapper = mount(Pagination, { props: { total: 95, pageSize: 10 } });
    expect(wrapper.find('.info').text()).toBe('1 / 10');
  });

  it('第一页时上一页按钮禁用', () => {
    const wrapper = mount(Pagination, { props: { total: 100, modelValue: 1 } });
    expect(wrapper.find('.prev').attributes('disabled')).toBeDefined();
    expect(wrapper.find('.next').attributes('disabled')).toBeUndefined();
  });

  it('最后一页时下一页按钮禁用', () => {
    const wrapper = mount(Pagination, { props: { total: 100, modelValue: 10 } });
    expect(wrapper.find('.next').attributes('disabled')).toBeDefined();
  });

  it('点击下一页触发 update:modelValue', async () => {
    const wrapper = mount(Pagination, { props: { total: 100, modelValue: 3 } });
    await wrapper.find('.next').trigger('click');
    expect(wrapper.emitted('update:modelValue')[0]).toEqual([4]);
  });

  it('点击上一页触发正确页码', async () => {
    const wrapper = mount(Pagination, { props: { total: 100, modelValue: 3 } });
    await wrapper.find('.prev').trigger('click');
    expect(wrapper.emitted('update:modelValue')[0]).toEqual([2]);
  });

  it('禁用状态下点击不触发事件', async () => {
    const wrapper = mount(Pagination, { props: { total: 100, modelValue: 1 } });
    await wrapper.find('.prev').trigger('click');
    expect(wrapper.emitted('update:modelValue')).toBeUndefined();
  });

  it('边界:total 为 0 时总页数为 0', () => {
    const wrapper = mount(Pagination, { props: { total: 0 } });
    expect(wrapper.find('.info').text()).toBe('1 / 0');
  });
});

这个案例覆盖了 props 计算、条件禁用、事件触发、边界值等,是一个"测行为而非实现"的良好示范:我们从不检查组件内部的 canPrev 变量,而是检查用户可见的 disabled 属性和触发的事件。

三十六、测试国际化与主题等横切能力

1. 测试 i18n

javascriptCode
import { createI18n } from 'vue-i18n';
import { mount } from '@vue/test-utils';

const i18n = createI18n({
  legacy: false,
  locale: 'zh',
  messages: {
    zh: { greeting: '你好' },
    en: { greeting: 'Hello' }
  }
});

it('根据语言渲染文案', async () => {
  const wrapper = mount(Greeting, { global: { plugins: [i18n] } });
  expect(wrapper.text()).toContain('你好');
  i18n.global.locale.value = 'en';
  await wrapper.vm.$nextTick();
  expect(wrapper.text()).toContain('Hello');
});

2. 测试 keep-alive 组件的 activated/deactivated

javascriptCode
import { mount } from '@vue/test-utils';

it('组件被激活时刷新数据', async () => {
  const refreshSpy = vi.fn();
  const Comp = {
    setup() {
      onActivated(refreshSpy);
      return () => h('div', 'content');
    }
  };
  // 用一个可切换的宿主包裹 keep-alive
  const Host = {
    components: { Comp },
    data: () => ({ show: true }),
    template: '<keep-alive><Comp v-if="show" /></keep-alive>'
  };
  const wrapper = mount(Host);
  expect(refreshSpy).toHaveBeenCalledTimes(1);
});

三十七、快照文件管理与变异测试

1. 快照文件组织

快照默认生成在测试文件同级的 __snapshots__ 目录,应纳入版本控制,让团队共享同一基线。review 快照变更时要像 review 代码一样确认变化是否符合预期,切忌无脑 vitest -u。

textCode
tests/
  Pagination.test.js
  __snapshots__/
    Pagination.test.js.snap   # 提交到 git

2. 变异测试(Mutation Testing)

覆盖率只能说明代码"跑过",变异测试则检验测试"是否真的能发现 bug"。工具(如 Stryker)会故意在源码里制造小变异(如把 > 改成 >=、删掉一行),然后跑测试,若测试仍全绿说明这块测试无效。

bashCode
# 用 Stryker 对 Vue 项目做变异测试
npx stryker run
# 输出 mutation score:被测试"杀死"的变异比例,越高说明测试越有效

| 指标 | 衡量 | 局限 |

|------|------|------|

| 覆盖率 | 代码是否被执行 | 不保证断言有效 |

| 变异分数 | 测试能否发现改动 | 运行慢、成本高 |

变异测试成本较高,通常只对核心模块跑,作为覆盖率的补充,用来发现"看着覆盖了其实没断言到位"的测试盲区。

三十八、契约测试与集成边界

前后端分离项目中,前端 mock 的接口格式可能与后端真实返回不一致,导致"测试全绿但联调全红"。契约测试用来约束这条边界。

javascriptCode
// 用一份 schema 同时约束 mock 和真实响应
import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string()
});

it('接口返回符合契约', async () => {
  const res = await fetch('/api/user/1').then((r) => r.json());
  // 校验真实响应结构,字段缺失或类型错会失败
  expect(() => UserSchema.parse(res)).not.toThrow();
});

实践建议: 把响应 schema 抽成前后端共享的单一来源,mock 数据也用它生成,从根本上杜绝 mock 与真实脱节。这样单测里的 mock 永远和契约一致,联调时的惊吓大幅减少。

| 测试类型 | 守护的边界 | 何时跑 |

|----------|-----------|--------|

| 单元测试 | 函数内部逻辑 | 每次改动 |

| 组件测试 | 组件对外接口 | 每次改动 |

| 契约测试 | 前后端数据格式 | 接口变更时 |

| E2E 测试 | 完整用户流程 | PR / 发布前 |

三十九、测试思维总结

写好测试的心法可以浓缩为几条:

1.测行为,不测实现——从用户和调用方视角断言。
2.保持独立——每个测试自给自足,可任意顺序运行。
3.快速反馈——单测要快,慢的放到 E2E。
4.有意义的断言——不写只执行不断言的假测试。
5.合理 mock——只 mock 外部边界,核心逻辑真实跑。
6.难测是信号——写不动测试往往说明代码该重构。
7.覆盖率是参考——追求价值而非数字。
8.契约优先——用共享 schema 约束前后端边界,避免 mock 与真实脱节。
9.自动化执行——测试接入 CI 才有价值,本地绿不算数。
10.持续维护——测试也是代码,过时的测试要及时更新或删除,别让它变成噪音。

把这些原则落到日常,测试就会从"额外负担"变成"加速器":改代码时有测试兜底,重构时有测试护航,联调时有契约把关,发布时有 E2E 守门。一个测试健全的项目,迭代速度反而更快,因为每一次改动的信心都来自绿色的测试,而不是提心吊胆的手动点点点。

最后给出一个务实的落地路线:

1.先给核心工具函数和 composable 补单测,成本最低、收益最高。
2.再给通用组件补组件测试,保护复用面最广的部分。
3.用 MSW 统一 mock 网络层,让单测和 E2E 复用同一套接口约定。
4.给 1~3 条最关键的业务流程(如注册、下单、支付)写 E2E,守住底线。
5.把测试接入 CI,设置覆盖率阈值,让测试成为合并的硬门槛。

按这个顺序推进,团队能用最小的前期投入建立起测试文化,之后再逐步向上补齐,避免一上来就追求大而全导致半途而废。

记住:测试的终极目标不是覆盖率数字,也不是测试用例的数量,而是"让每一次交付都值得信任"。

当你能在周五下午放心地合并一个改动并部署上线,而不必担心周末被叫起来救火时,你的测试就真正发挥了价值。

这份从容,正是所有测试投入最好的回报。

总结

测试不是负担,而是让你能"放心大胆改代码"的底气。合理的测试策略应遵循金字塔原则:大量快速的单元测试打底,适量组件测试验证交互,少量 E2E 测试守住关键流程。工具上,Vitest 做运行器,Vue Test Utils 或 Testing Library 做组件测试,Playwright/Cypress 做端到端。

| 层级 | 工具 | 测什么 | 特点 |

|------|------|--------|------|

| 单元测试 | Vitest | 纯函数、composables、store | 快、多、便宜 |

| 组件测试 | Vue Test Utils / Testing Library | 组件渲染与交互 | 贴近用户体验 |

| E2E 测试 | Playwright / Cypress | 完整业务流程 | 真实但慢 |

| 覆盖率 | Vitest coverage | 度量测试完整度 | 参考而非目标 |

核心原则一句话:测试行为而非实现,保持独立与简洁,把 Bug 拦在越早越好的阶段。