Vitest 前端测试实战指南:从入门到精通

6 阅读8分钟

Vitest 前端测试实战指南:从入门到精通

引言

在前端开发中,测试往往是被忽视的一环。很多开发者认为写测试会增加开发时间,或者觉得测试只是为了应付代码审查。但实际上,好的测试用例不仅能保证代码质量,减少生产环境的 bug,还能提高开发效率,让重构更加安全。

最近,我在项目中集成了 Vitest 测试框架,体验非常好。它速度快、配置简单,与 Vite 完美集成,非常适合现代前端项目。今天,我就来分享一下如何在 React + TypeScript 项目中使用 Vitest 进行测试。

为什么需要前端测试?

1. 保证代码质量

测试用例可以验证代码的功能是否符合预期,捕获潜在的 bug,特别是在复杂的业务逻辑中。

2. 提高开发效率

虽然写测试会花费一些时间,但在长期来看,它能减少调试时间,让开发者更自信地修改代码。

3. 便于重构

有了测试用例,重构代码时可以快速验证修改是否破坏了现有功能。

4. 文档作用

测试用例可以作为代码的活文档,帮助其他开发者理解代码的预期行为。

Vitest 优势

Vitest 是基于 Vite 的测试框架,相比 Jest 等传统测试框架,它有以下优势:

  • 速度快:利用 Vite 的快速启动和热更新能力
  • 配置简单:与 Vite 配置无缝集成
  • TypeScript 支持:原生支持 TypeScript
  • ES 模块:原生支持 ES 模块
  • 快照测试:支持组件快照测试
  • API 友好:API 设计与 Jest 类似,学习成本低

项目搭建

1. 初始化 React + TypeScript 项目

首先,使用 Vite 创建一个 React + TypeScript 项目:

npm create vite@latest vitest-demo -- --template react-ts
cd vitest-demo

2. 安装测试依赖

npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom

3. 配置 Vitest

在项目根目录创建 vitest.config.ts 文件:

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';

// Vitest 配置文件
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/setupTests.ts',
    css: true,
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, './src'),
    },
  },
});

4. 配置测试环境

src 目录创建 setupTests.ts 文件:

// 测试环境配置文件
import '@testing-library/jest-dom/vitest';

5. 更新 package.json

package.json 中添加测试脚本:

"scripts": {
  "dev": "vite",
  "build": "tsc -b && vite build",
  "lint": "eslint .",
  "preview": "vite preview",
  "test": "vitest",
  "test:run": "vitest run"
}

测试示例

1. 工具函数测试

首先,我们来测试一个简单的数学工具函数。

创建 src/utils/math.ts 文件:

/**
 * 数学工具函数
 */

/**
 * 加法函数
 * @param a 第一个加数
 * @param b 第二个加数
 * @returns 两个数的和
 */
export const add = (a: number, b: number): number => {
  return a + b;
};

/**
 * 减法函数
 * @param a 被减数
 * @param b 减数
 * @returns 两个数的差
 */
export const subtract = (a: number, b: number): number => {
  return a - b;
};

/**
 * 乘法函数
 * @param a 第一个乘数
 * @param b 第二个乘数
 * @returns 两个数的积
 */
export const multiply = (a: number, b: number): number => {
  return a * b;
};

/**
 * 除法函数
 * @param a 被除数
 * @param b 除数
 * @returns 两个数的商
 * @throws 当除数为0时抛出错误
 */
export const divide = (a: number, b: number): number => {
  if (b === 0) {
    throw new Error('除数不能为0');
  }
  return a / b;
};

然后,创建 src/utils/math.test.ts 文件:

import { describe, it, expect } from 'vitest';
import { add, subtract, multiply, divide } from './math';

/**
 * 数学工具函数测试
 */
describe('数学工具函数测试', () => {
  /**
   * 测试加法函数
   */
describe('add函数测试', () => {
    it('应该正确计算两个正数的和', () => {
      expect(add(1, 2)).toBe(3);
      expect(add(10, 20)).toBe(30);
    });

    it('应该正确计算小数的和', () => {
      expect(add(0.1, 0.2)).toBeCloseTo(0.3);
      expect(add(1.5, 2.5)).toBe(4);
    });
  });

  /**
   * 测试除法函数
   */
describe('divide函数测试', () => {
    it('应该正确计算两个正数的商', () => {
      expect(divide(6, 2)).toBe(3);
    });

    it('应该在除数为零时抛出错误', () => {
      expect(() => divide(5, 0)).toThrow('除数不能为0');
    });
  });
});

2. 组件测试

接下来,我们来测试一个登录表单组件。

创建 src/components/LoginForm.tsx 文件:

import { useState } from 'react';

/**
 * 登录表单组件
 * @param onSubmit 表单提交回调函数
 */
interface LoginFormProps {
  onSubmit: (data: { username: string; password: string }) => void;
}

/**
 * 登录表单组件
 * 包含用户名和密码输入字段,以及表单验证逻辑
 */
export const LoginForm = ({ onSubmit }: LoginFormProps) => {
  // 表单状态
  const [formData, setFormData] = useState({
    username: '',
    password: '',
  });
  
  // 错误信息状态
  const [errors, setErrors] = useState({
    username: '',
    password: '',
  });

  /**
   * 处理输入变化
   * @param e 输入事件
   */
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value,
    }));
    
    // 清除对应字段的错误信息
    if (errors[name as keyof typeof errors]) {
      setErrors(prev => ({
        ...prev,
        [name]: '',
      }));
    }
  };

  /**
   * 验证表单
   * @returns 是否验证通过
   */
  const validateForm = (): boolean => {
    const newErrors = {
      username: '',
      password: '',
    };

    // 验证用户名
    if (!formData.username.trim()) {
      newErrors.username = '用户名不能为空';
    }

    // 验证密码
    if (!formData.password) {
      newErrors.password = '密码不能为空';
    } else if (formData.password.length < 6) {
      newErrors.password = '密码长度不能少于6位';
    }

    // 设置错误信息
    setErrors(newErrors);

    // 检查是否有错误
    return !newErrors.username && !newErrors.password;
  };

  /**
   * 处理表单提交
   * @param e 提交事件
   */
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    // 验证表单
    if (validateForm()) {
      // 调用提交回调
      onSubmit(formData);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="login-form">
      <h2>登录</h2>
      
      <div className="form-group">
        <label htmlFor="username">用户名</label>
        <input
          type="text"
          id="username"
          name="username"
          value={formData.username}
          onChange={handleChange}
          className={errors.username ? 'error' : ''}
        />
        {errors.username && <div className="error-message">{errors.username}</div>}
      </div>
      
      <div className="form-group">
        <label htmlFor="password">密码</label>
        <input
          type="password"
          id="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          className={errors.password ? 'error' : ''}
        />
        {errors.password && <div className="error-message">{errors.password}</div>}
      </div>
      
      <button type="submit">登录</button>
    </form>
  );
};

然后,创建 src/components/LoginForm.test.tsx 文件:

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { LoginForm } from './LoginForm';

/**
 * LoginForm组件测试
 */
describe('LoginForm组件测试', () => {
  it('应该正常渲染登录表单', () => {
    const mockOnSubmit = vi.fn();
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    expect(screen.getByRole('heading', { name: '登录' })).toBeInTheDocument();
    expect(screen.getByLabelText('用户名')).toBeInTheDocument();
    expect(screen.getByLabelText('密码')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '登录' })).toBeInTheDocument();
  });

  it('提交空表单应该显示错误信息', () => {
    const mockOnSubmit = vi.fn();
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    const submitButton = screen.getByRole('button', { name: '登录' });
    fireEvent.click(submitButton);
    
    expect(screen.getByText('用户名不能为空')).toBeInTheDocument();
    expect(screen.getByText('密码不能为空')).toBeInTheDocument();
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });

  it('有效输入应该成功提交表单', () => {
    const mockOnSubmit = vi.fn();
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    const usernameInput = screen.getByLabelText('用户名');
    const passwordInput = screen.getByLabelText('密码');
    const submitButton = screen.getByRole('button', { name: '登录' });
    
    fireEvent.change(usernameInput, { target: { value: 'testuser' } });
    fireEvent.change(passwordInput, { target: { value: 'password123' } });
    fireEvent.click(submitButton);
    
    expect(mockOnSubmit).toHaveBeenCalledWith({
      username: 'testuser',
      password: 'password123',
    });
  });
});

3. 异步服务测试

最后,我们来测试一个模拟的 API 服务。

创建 src/services/userService.ts 文件:

/**
 * 用户服务
 * 模拟API调用,包含获取用户列表、获取单个用户、创建用户等方法
 */

/**
 * 用户类型定义
 */
export interface User {
  id: number;
  name: string;
  email: string;
}

/**
 * 模拟用户数据
 */
const mockUsers: User[] = [
  { id: 1, name: '张三', email: 'zhangsan@example.com' },
  { id: 2, name: '李四', email: 'lisi@example.com' },
  { id: 3, name: '王五', email: 'wangwu@example.com' },
];

/**
 * 模拟API延迟
 * @param ms 延迟时间(毫秒)
 */
const delay = (ms: number): Promise<void> => {
  return new Promise(resolve => setTimeout(resolve, ms));
};

/**
 * 用户服务类
 */
export class UserService {
  /**
   * 获取用户列表
   * @param shouldFail 是否模拟失败场景
   * @returns 用户列表
   */
  async getUsers(shouldFail: boolean = false): Promise<User[]> {
    // 模拟API延迟
    await delay(300);
    
    // 模拟失败场景
    if (shouldFail) {
      throw new Error('获取用户列表失败');
    }
    
    return mockUsers;
  }

  /**
   * 创建新用户
   * @param user 用户信息
   * @param shouldFail 是否模拟失败场景
   * @returns 创建的用户信息
   */
  async createUser(user: Omit<User, 'id'>, shouldFail: boolean = false): Promise<User> {
    // 模拟API延迟
    await delay(400);
    
    // 模拟失败场景
    if (shouldFail) {
      throw new Error('创建用户失败');
    }
    
    // 生成新用户ID
    const newId = Math.max(...mockUsers.map(u => u.id)) + 1;
    // 创建新用户
    const newUser: User = {
      id: newId,
      ...user,
    };
    
    // 添加到模拟数据中
    mockUsers.push(newUser);
    
    return newUser;
  }
}

// 导出单例实例
export const userService = new UserService();

然后,创建 src/services/userService.test.ts 文件:

import { describe, it, expect } from 'vitest';
import { userService } from './userService';

/**
 * UserService测试
 */
describe('UserService测试', () => {
  it('应该成功获取用户列表', async () => {
    const users = await userService.getUsers();
    expect(users).toBeInstanceOf(Array);
    expect(users.length).toBeGreaterThan(0);
  });

  it('获取用户列表失败时应该抛出错误', async () => {
    await expect(userService.getUsers(true)).rejects.toThrow('获取用户列表失败');
  });

  it('应该成功创建新用户', async () => {
    const newUser = {
      name: '赵六',
      email: 'zhaoliu@example.com',
    };
    
    const createdUser = await userService.createUser(newUser);
    expect(createdUser).toBeInstanceOf(Object);
    expect(createdUser.id).toBeGreaterThan(0);
    expect(createdUser.name).toBe(newUser.name);
  });
});

测试技巧

1. 组件测试技巧

  • 使用语义化选择器:优先使用 getByRolegetByLabelText 等语义化选择器,而不是 getByClassNamegetByTestId
  • 模拟用户交互:使用 fireEvent 模拟真实的用户操作,如点击、输入等
  • 断言具体:断言应该具体,避免过于宽泛的断言

2. 异步测试技巧

  • 使用 async/await:处理异步操作时,使用 async/await 让代码更清晰
  • 测试错误场景:使用 expect(...).rejects.toThrow() 测试异步操作的错误场景
  • 模拟依赖:对于外部依赖,如 API 调用,使用 mock 或 stub 来隔离测试

3. 测试覆盖策略

  • 正常情况:测试功能的基本使用场景
  • 边界情况:测试输入的边界值,如空字符串、负数、最大值等
  • 异常情况:测试错误处理和异常状态
  • 边缘情况:测试特殊输入和场景,如网络错误、超时等

项目结构

最终,我们的项目结构如下:

vitest-demo/
├── src/
│   ├── assets/
│   ├── components/
│   │   ├── LoginForm.tsx
│   │   └── LoginForm.test.tsx
│   ├── services/
│   │   ├── userService.ts
│   │   └── userService.test.ts
│   ├── utils/
│   │   ├── math.ts
│   │   └── math.test.ts
│   ├── App.tsx
│   ├── App.test.tsx
│   ├── setupTests.ts
│   └── main.tsx
├── package.json
├── vitest.config.ts
└── README.md

运行测试

开发模式运行测试(监听文件变化)

npm test

单次运行测试(运行后退出)

npm run test:run

总结

通过本文的介绍,相信你已经对如何在 React + TypeScript 项目中使用 Vitest 进行测试有了清晰的了解。从工具函数测试到组件测试,再到异步服务测试,我们覆盖了前端测试的主要场景。

测试是一个长期的过程,不是一蹴而就的。建议从简单的工具函数和核心组件开始,逐步扩展测试覆盖范围。随着测试用例的积累,你会发现代码质量和开发效率都有显著提升。

最后,记住测试的目的不是为了追求 100% 的测试覆盖率,而是为了保证代码的核心功能稳定可靠。好的测试用例应该是有针对性的,能够捕获真正重要的 bug。

希望本文对你有所帮助,祝你测试愉快! 🎉