Skip to content
第 133 / 250 章前端⏱ 10 分钟阅读

第 133 章:Vitest 单元测试

学习目标

  • 掌握 Vitest 在 Vue 3 项目中的配置
  • 学会组件测试、composable 测试、store 测试
  • 理解 mock、spy、覆盖率统计
  • 在 CI 中集成测试

一、为什么需要测试

二、Vitest 是什么

  • 基于 Vite 的测试框架
  • 兼容 Jest API
  • 极快的启动与运行
  • 原生支持 ESM / TypeScript / Vue

三、安装与配置

3.1 安装

bash
pnpm add -D vitest @vue/test-utils happy-dom @vitest/coverage-v8

3.2 配置

typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import path from 'path';

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  },
  test: {
    environment: 'happy-dom',
    globals: true,
    setupFiles: ['./test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'json'],
      include: ['src/**/*.{ts,vue}'],
      exclude: ['**/*.test.ts', '**/*.spec.ts', 'src/main.ts']
    }
  }
});

3.3 global 类型

typescript
// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

3.4 包脚本

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

四、基础语法

4.1 测试结构

typescript
import { describe, it, expect } from 'vitest';

describe('分组', () => {
  it('测试 1', () => {
    expect(1 + 1).toBe(2);
  });

  it('测试 2', () => {
    expect('hello').toMatch(/hello/);
  });
});

4.2 常用断言

typescript
// 基础
expect(1).toBe(1);              // ===
expect({ a: 1 }).toEqual({ a: 1 });  // 深度比较
expect('hello').toMatch(/hello/);
expect([1, 2]).toContain(1);

// 布尔
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();

// 数字
expect(0.1 + 0.2).toBeCloseTo(0.3);
expect(10).toBeGreaterThan(5);

// 异常
expect(() => { throw new Error('x') }).toThrow('x');

// Promise
await expect(Promise.resolve(1)).resolves.toBe(1);
await expect(Promise.reject('x')).rejects.toBe('x');

4.3 钩子

typescript
describe('hooks', () => {
  beforeAll(() => { /* 所有用例前 */ });
  beforeEach(() => { /* 每个用例前 */ });
  afterEach(() => { /* 每个用例后 */ });
  afterAll(() => { /* 所有用例后 */ });

  it('test', () => {});
});

4.4 跳过与仅运行

typescript
describe('skip', () => {
  it.skip('跳过', () => {});

  it.todo('待完成');

  it.runIf(true)('条件运行', () => {});

  it.concurrent('并发', () => {});
});

五、组件测试

5.1 基础

typescript
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import MyButton from './MyButton.vue';

describe('MyButton', () => {
  it('renders', () => {
    const wrapper = mount(MyButton, {
      props: { type: 'primary' },
      slots: { default: 'Click' }
    });

    expect(wrapper.text()).toBe('Click');
    expect(wrapper.classes()).toContain('my-button--primary');
  });

  it('emits click', async () => {
    const wrapper = mount(MyButton, {
      props: { type: 'primary' }
    });

    await wrapper.trigger('click');

    expect(wrapper.emitted('click')).toBeTruthy();
    expect(wrapper.emitted('click')?.[0]).toBeDefined();
  });

  it('disabled', async () => {
    const wrapper = mount(MyButton, {
      props: { disabled: true }
    });

    await wrapper.trigger('click');

    expect(wrapper.emitted('click')).toBeFalsy();
  });
});

5.2 props 与 slots

typescript
import { mount } from '@vue/test-utils';
import MyCard from './MyCard.vue';

it('slots', () => {
  const wrapper = mount(MyCard, {
    slots: {
      default: '<p>Body</p>',
      header: '<h1>Title</h1>',
      footer: '<button>OK</button>'
    }
  });

  expect(wrapper.html()).toContain('Title');
  expect(wrapper.html()).toContain('Body');
});

it('props', () => {
  const wrapper = mount(MyCard, {
    props: { title: 'Hello' }
  });

  expect(wrapper.props('title')).toBe('Hello');
});

5.3 v-model 测试

typescript
import { mount } from '@vue/test-utils';
import MyInput from './MyInput.vue';

it('v-model', async () => {
  const wrapper = mount(MyInput, {
    props: { modelValue: 'init' }
  });

  await wrapper.setValue('changed');

  expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['changed']);
});

5.4 异步组件

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

it('async', async () => {
  const wrapper = mount(AsyncComponent);
  await flushPromises();

  expect(wrapper.find('.data').exists()).toBe(true);
});

5.5 提供 Provide

typescript
import { mount } from '@vue/test-utils';
import ChildComp from './ChildComp.vue';

const wrapper = mount(ChildComp, {
  global: {
    provide: {
      'my-key': 'my-value'
    }
  }
});

5.6 Router

typescript
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import MyComp from './MyComp.vue';

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

beforeEach(() => router.push('/'));
beforeEach(() => router.isReady());

it('with router', async () => {
  const wrapper = mount(MyComp, {
    global: { plugins: [router] }
  });
  expect(wrapper.find('h1').text()).toBe('Home');
});

5.7 Pinia

typescript
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { useUserStore } from '@/stores/user';
import MyComp from './MyComp.vue';

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

it('with pinia', () => {
  const store = useUserStore();
  store.name = 'Tom';

  const wrapper = mount(MyComp);
  expect(wrapper.text()).toContain('Tom');
});

六、Composable 测试

6.1 基础

typescript
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('initial', () => {
    const { count } = useCounter(10);
    expect(count.value).toBe(10);
  });

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

6.2 生命周期

typescript
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import { useMouse } from './useMouse';

it('useMouse onMounted', () => {
  const Comp = defineComponent({
    setup() {
      const { x, y } = useMouse();
      return { x, y };
    },
    render() {
      return h('div', `${x.value},${y.value}`);
    }
  });

  const wrapper = mount(Comp);
  expect(wrapper.text()).toBe('0,0');
});

6.3 异步 composable

typescript
import { useFetch } from './useFetch';
import { flushPromises } from '@vue/test-utils';

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

  await flushPromises();
  expect(data.value).toEqual({ name: 'Tom' });
});

七、Mock

7.1 mock 函数

typescript
import { vi } from 'vitest';

const fn = vi.fn();
fn('hello');
expect(fn).toHaveBeenCalledWith('hello');
expect(fn).toHaveBeenCalledTimes(1);

fn.mockReturnValue(42);
fn.mockReturnValueOnce(1).mockReturnValueOnce(2);

7.2 mock 模块

typescript
import { vi } from 'vitest';

vi.mock('./api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Tom' })
}));

import { fetchUser } from './api';
fetchUser();  // 返回 { id: 1, name: 'Tom' }

7.3 spy

typescript
import { vi } from 'vitest';

const obj = { method: () => 'real' };
const spy = vi.spyOn(obj, 'method');
spy.mockReturnValue('mocked');

expect(obj.method()).toBe('mocked');
expect(spy).toHaveBeenCalled();

7.4 mock 时间

typescript
import { vi } from 'vitest';

vi.useFakeTimers();

setTimeout(() => {
  console.log('done');
}, 1000);

vi.advanceTimersByTime(1000);
expect(console.log).toHaveBeenCalledWith('done');

vi.useRealTimers();

7.5 partial mock

typescript
import { vi } from 'vitest';

vi.mock('./utils', async () => {
  const actual = await vi.importActual('./utils');
  return {
    ...(actual as any),
    heavyFunc: vi.fn()
  };
});

八、快照测试

8.1 组件快照

typescript
import { mount } from '@vue/test-utils';
import MyComp from './MyComp.vue';

it('snapshot', () => {
  const wrapper = mount(MyComp);
  expect(wrapper.html()).toMatchSnapshot();
});

8.2 更新快照

bash
pnpm test -- -u

九、覆盖率

9.1 配置

typescript
coverage: {
  provider: 'v8',
  reporter: ['text', 'html'],
  include: ['src/**/*.{ts,vue}'],
  thresholds: {
    lines: 80,
    functions: 80,
    branches: 80,
    statements: 80
  }
}

9.2 命令

bash
pnpm test:coverage

9.3 报告

  • coverage/index.html - HTML 报告
  • coverage/coverage-final.json - JSON 数据

十、调试

10.1 调试单个测试

typescript
it.only('debug', () => {
  // 仅运行这个
});

10.2 console 输出

typescript
it('debug', () => {
  console.log('debug info');
  // 输出在控制台
});

10.3 断点调试

bash
# VSCode 配置
{
  "type": "node",
  "request": "launch",
  "name": "Vitest",
  "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
  "args": ["run", "--reporter=verbose"]
}

十一、VueUse 测试

typescript
import { mount } from '@vue/test-utils';
import { useDark, useToggle } from '@vueuse/core';

it('useDark', () => {
  const wrapper = mount({
    setup() {
      const isDark = useDark();
      const toggle = useToggle(isDark);
      return { isDark, toggle };
    },
    template: '<button @click="toggle()">{{ isDark }}</button>'
  });

  wrapper.find('button').trigger('click');
  expect(wrapper.text()).toBe('true');
});

十二、E2E 测试(Vitest + Playwright)

bash
pnpm add -D @playwright/test
typescript
import { test, expect } from '@playwright/test';

test('homepage', async ({ page }) => {
  await page.goto('http://localhost:3000');
  await expect(page.locator('h1')).toHaveText('Hello Vue');
});

十三、CI 集成

yaml
# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm test:run
      - run: pnpm test:coverage
      - uses: codecov/codecov-action@v4
        with:
          files: ./coverage/coverage-final.json

十四、测试策略

层级数量速度工具
单元Vitest
集成Vitest + Vue Test Utils
E2EPlaywright

十五、本章小结

概念关键
mount@vue/test-utils 挂载组件
describe/itBDD 风格测试
expect断言
vi.mockmock 模块
vi.fnmock 函数
vi.spyOnspy 方法
快照防止意外变更
覆盖率v8 / istanbul

动手练习

  1. 组件测试:为你的 Button 组件编写完整测试
  2. Composable:为 useFetch 编写测试(mocks)
  3. store 测试:测试 Pinia store 的 action
  4. 覆盖率:为项目设置 80% 覆盖率门槛
  5. CI 集成:在 GitHub Actions 中运行测试

推荐阅读


下一章第 134 章:组件库发布

本站基于 VitePress 构建 · 由 Codebook 团队维护