测试与质量保障

What — 是什么

测试与质量保障是软件工程中确保代码正确性、稳定性和可维护性的系统性实践。在 Node.js 生态中,Jest 和 Vitest 是两大主流测试框架,配合 Supertest 进行 HTTP 接口测试,构成了完整的前后端测试方案。

核心概念

单元测试(Unit Test):对最小可测试单元(函数、方法、类)进行验证,隔离外部依赖,测试内部逻辑。单元测试应该快速、独立、可重复。一个典型的单元测试包含三个阶段:Arrange(准备数据)→ Act(执行操作)→ Assert(验证结果)。

集成测试(Integration Test):验证多个模块组合后的行为,涉及真实的外部依赖(数据库、文件系统等)或模块间的交互。集成测试的范围介于单元测试和 E2E 测试之间,通常测试一个完整的业务流程,如”用户注册→验证邮件→激活账号”。

E2E 测试(End-to-End Test):从用户视角模拟完整的操作流程,包括 UI 交互和后端 API 调用。E2E 测试运行在真实或接近真实的环境中,验证整个系统的功能。常用工具包括 Cypress、Playwright、Puppeteer。E2E 测试成本最高但最接近用户真实体验。

测试金字塔:一种测试策略模型,由 Mike Cohn 提出。底层是大量快速的单元测试,中间是适量的集成测试,顶层是少量的 E2E 测试。金字塔结构确保了测试效率和覆盖面的平衡,避免”冰淇淋蛋卷”反模式(大量 E2E 测试、少量单元测试)。

覆盖率(Coverage):衡量测试对代码的覆盖程度,常见指标有:

  • 语句覆盖率(Statement Coverage):被执行到的语句比例
  • 分支覆盖率(Branch Coverage):if/else 分支被执行到的比例
  • 函数覆盖率(Function Coverage):被调用到的函数比例
  • 行覆盖率(Line Coverage):被执行到的代码行比例

Mock:用模拟对象替代真实依赖,隔离被测单元。Jest 提供三种 Mock 方式:jest.fn() 创建模拟函数、jest.spyOn() 监视真实函数的调用、jest.mock() 替换整个模块。

TDD(Test-Driven Development):测试驱动开发,先写测试再写实现代码。流程为:Red(写一个失败的测试)→ Green(写最少的代码让测试通过)→ Refactor(重构代码),即”红-绿-重构”循环。

Jest

Jest 是 Facebook 开源的 JavaScript 测试框架,是 React 生态的默认测试工具,也是 Node.js 后端最广泛使用的测试框架。

核心特性:

  • 零配置开箱即用(Zero Config)
  • 内置断言库(expect)、Mock、快照测试
  • 内置代码覆盖率报告(istanbul)
  • 并行测试执行,速度较快
  • 丰富的生态和插件

Vitest

Vitest 是由 Vite 团队开发的新一代测试框架,与 Vite 深度集成,共享 Vite 的配置和插件生态。

核心特性:

  • 兼容 Jest API,迁移成本低
  • 基于 Vite 的即时热更新(HMR)
  • ESM 原生支持,无需额外配置
  • 更快的启动和执行速度(尤其在大型项目中)
  • 支持 Vite 插件生态

Supertest

Supertest 是基于 Superagent 的 HTTP 断言库,专门用于测试 Node.js HTTP 服务器。它可以在不实际启动服务器端口的情况下测试 Express/Koa 等 Web 框架的请求处理逻辑,适合 API 集成测试。

Why — 为什么用

适用场景

场景推荐方案说明
npm 库开发Vitest + ViteESM 原生支持,构建和测试共享配置
Express API 服务Jest + Supertest成熟稳定,社区资源丰富
NestJS 项目Jest(内置)NestJS 默认集成 Jest
全栈应用Vitest(后端+前端)统一测试框架,共享配置
开源项目Jest社区接受度最高,CI 兼容性好
MonorepoVitest更快的工作空间支持

对比 Jest / Vitest / Mocha

维度JestVitestMocha
配置零配置开箱即用需 Vite 配置需大量配置
执行速度快(并行)更快(Vite HMR)较慢(串行默认)
ESM 支持实验性原生支持需额外配置
Mock 支持内置(jest.fn/spy/mock)内置(vi.fn/spy/mock)需要 sinon
覆盖率内置(istanbul)内置(istanbul/c8)需要 nyc
快照测试内置内置需要插件
Watch 模式支持支持(更快)需要插件
生态成熟度最高快速增长中稳定但衰退
TypeScript通过 ts-jest 或 Babel原生支持(esbuild)需要 ts-node

优缺点

Jest 优点:生态最完善、社区资源最丰富、零配置开箱即用、快照测试方便、覆盖率内置。缺点:大型项目启动慢、ESM 支持不完善、ts-jest 配置繁琐、Transform 机制复杂。

Vitest 优点:启动和执行速度快、ESM 原生支持、与 Vite 生态无缝集成、API 兼容 Jest。缺点:社区和生态仍在发展、部分 Jest 插件不兼容、Vite 环境要求。

How — 怎么用

安装配置

Jest 安装配置

# 安装 Jest 及 TypeScript 支持
npm install --save-dev jest ts-jest @types/jest

# 初始化配置
npx ts-jest config:init
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src', '<rootDir>/test'],
  testMatch: ['**/__tests__/**/*.test.ts', '**/*.spec.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/index.ts',
  ],
  coverageThresholds: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1', // 路径别名
  },
  setupFilesAfterSetup: ['<rootDir>/test/setup.ts'],
};
// package.json scripts
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:ci": "jest --ci --coverage --maxWorkers=2"
  }
}

Vitest 安装配置

npm install --save-dev vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'path';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.d.ts', 'src/index.ts'],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
    setupFiles: ['./test/setup.ts'],
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});
// package.json scripts
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}

代码示例1:Jest 单元测试 + Mock

// ===== 被测模块:src/userService.ts =====
class UserService {
  constructor(userRepository, emailService) {
    this.userRepository = userRepository;
    this.emailService = emailService;
  }

  async createUser(userData) {
    // 校验
    if (!userData.name || !userData.email) {
      throw new Error('姓名和邮箱不能为空');
    }
    // 检查邮箱是否已存在
    const existing = await this.userRepository.findByEmail(userData.email);
    if (existing) {
      throw new Error('邮箱已被注册');
    }
    // 创建用户
    const user = await this.userRepository.create(userData);
    // 发送欢迎邮件
    await this.emailService.sendWelcome(user.email, user.name);
    return user;
  }

  async getUserById(id) {
    const user = await this.userRepository.findById(id);
    if (!user) {
      throw new Error('用户不存在');
    }
    return user;
  }

  async deleteUser(id) {
    const user = await this.userRepository.findById(id);
    if (!user) {
      throw new Error('用户不存在');
    }
    await this.userRepository.delete(id);
    return { success: true };
  }
}

module.exports = { UserService };

// ===== 测试文件:test/userService.test.ts =====
const { UserService } = require('../src/userService');

// 创建 Mock 函数和对象
const mockUserRepository = {
  findByEmail: jest.fn(),
  findById: jest.fn(),
  create: jest.fn(),
  delete: jest.fn(),
};

const mockEmailService = {
  sendWelcome: jest.fn(),
};

describe('UserService', () => {
  let userService;

  beforeEach(() => {
    // 每个测试前重置所有 Mock
    jest.clearAllMocks();
    userService = new UserService(mockUserRepository, mockEmailService);
  });

  describe('createUser', () => {
    const validUserData = { name: '张三', email: 'zhangsan@test.com' };

    test('应成功创建用户并发送欢迎邮件', async () => {
      // Arrange
      mockUserRepository.findByEmail.mockResolvedValue(null);
      mockUserRepository.create.mockResolvedValue({ id: 1, ...validUserData });
      mockEmailService.sendWelcome.mockResolvedValue(undefined);

      // Act
      const user = await userService.createUser(validUserData);

      // Assert
      expect(user).toEqual({ id: 1, ...validUserData });
      expect(mockUserRepository.findByEmail).toHaveBeenCalledWith(validUserData.email);
      expect(mockUserRepository.create).toHaveBeenCalledWith(validUserData);
      expect(mockEmailService.sendWelcome).toHaveBeenCalledWith(
        validUserData.email, validUserData.name
      );
      expect(mockEmailService.sendWelcome).toHaveBeenCalledTimes(1);
    });

    test('缺少姓名或邮箱时应抛出错误', async () => {
      await expect(userService.createUser({ name: '' }))
        .rejects.toThrow('姓名和邮箱不能为空');
      await expect(userService.createUser({ email: '' }))
        .rejects.toThrow('姓名和邮箱不能为空');
      // 确保未调用 create
      expect(mockUserRepository.create).not.toHaveBeenCalled();
    });

    test('邮箱已存在时应抛出错误', async () => {
      mockUserRepository.findByEmail.mockResolvedValue({ id: 1, email: validUserData.email });

      await expect(userService.createUser(validUserData))
        .rejects.toThrow('邮箱已被注册');
      expect(mockUserRepository.create).not.toHaveBeenCalled();
      expect(mockEmailService.sendWelcome).not.toHaveBeenCalled();
    });

    test('邮件发送失败不应影响用户创建', async () => {
      mockUserRepository.findByEmail.mockResolvedValue(null);
      mockUserRepository.create.mockResolvedValue({ id: 1, ...validUserData });
      mockEmailService.sendWelcome.mockRejectedValue(new Error('邮件服务不可用'));

      const user = await userService.createUser(validUserData);
      expect(user).toEqual({ id: 1, ...validUserData });
    });
  });

  describe('getUserById', () => {
    test('应返回用户信息', async () => {
      const mockUser = { id: 1, name: '张三', email: 'zhangsan@test.com' };
      mockUserRepository.findById.mockResolvedValue(mockUser);

      const user = await userService.getUserById(1);
      expect(user).toEqual(mockUser);
      expect(mockUserRepository.findById).toHaveBeenCalledWith(1);
    });

    test('用户不存在时应抛出错误', async () => {
      mockUserRepository.findById.mockResolvedValue(null);

      await expect(userService.getUserById(999))
        .rejects.toThrow('用户不存在');
    });
  });
});

// ===== jest.mock() 模块级 Mock =====
// mock 整个模块
jest.mock('../src/logger', () => ({
  info: jest.fn(),
  error: jest.fn(),
  warn: jest.fn(),
}));

// jest.spyOn() 监视真实方法
describe('spyOn 示例', () => {
  test('应监视 console.log 调用', () => {
    const spy = jest.spyOn(console, 'log').mockImplementation(() => {});
    console.log('test message');
    expect(spy).toHaveBeenCalledWith('test message');
    spy.mockRestore(); // 恢复原始实现
  });
});

代码示例2:Vitest 测试 + 覆盖率

// ===== 被测模块:src/utils/validator.ts =====
export function validateEmail(email) {
  if (typeof email !== 'string') return false;
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(email);
}

export function validatePassword(password) {
  if (typeof password !== 'string') return { valid: false, errors: ['密码必须是字符串'] };
  const errors = [];
  if (password.length < 8) errors.push('密码至少8位');
  if (!/[A-Z]/.test(password)) errors.push('密码必须包含大写字母');
  if (!/[a-z]/.test(password)) errors.push('密码必须包含小写字母');
  if (!/[0-9]/.test(password)) errors.push('密码必须包含数字');
  return { valid: errors.length === 0, errors };
}

export function sanitizeInput(input) {
  if (typeof input !== 'string') return '';
  return input
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;')
    .trim();
}

export function paginate(array, page = 1, pageSize = 10) {
  if (!Array.isArray(array)) return { data: [], total: 0, page: 1, pageSize: 10 };
  const total = array.length;
  const totalPages = Math.ceil(total / pageSize);
  const offset = (page - 1) * pageSize;
  return {
    data: array.slice(offset, offset + pageSize),
    total,
    page,
    pageSize,
    totalPages,
  };
}

// ===== 测试文件:src/utils/validator.test.ts =====
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { validateEmail, validatePassword, sanitizeInput, paginate } from './validator';

describe('validateEmail', () => {
  it('应验证合法邮箱', () => {
    expect(validateEmail('user@example.com')).toBe(true);
    expect(validateEmail('test.name+tag@domain.co')).toBe(true);
  });

  it('应拒绝非法邮箱', () => {
    expect(validateEmail('')).toBe(false);
    expect(validateEmail('invalid')).toBe(false);
    expect(validateEmail('@domain.com')).toBe(false);
    expect(validateEmail('user@')).toBe(false);
    expect(validateEmail(null)).toBe(false);
    expect(validateEmail(undefined)).toBe(false);
    expect(validateEmail(123)).toBe(false);
  });
});

describe('validatePassword', () => {
  it('应通过强密码', () => {
    expect(validatePassword('Abc12345')).toEqual({ valid: true, errors: [] });
  });

  it('应检测所有弱密码条件', () => {
    const result = validatePassword('abc');
    expect(result.valid).toBe(false);
    expect(result.errors).toContain('密码至少8位');
    expect(result.errors).toContain('密码必须包含大写字母');
    expect(result.errors).toContain('密码必须包含数字');
  });
});

describe('sanitizeInput', () => {
  it('应转义 HTML 特殊字符', () => {
    expect(sanitizeInput('<script>alert("xss")</script>'))
      .toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
  });

  it('应去除首尾空格', () => {
    expect(sanitizeInput('  hello  ')).toBe('hello');
  });

  it('非字符串应返回空串', () => {
    expect(sanitizeInput(null)).toBe('');
    expect(sanitizeInput(123)).toBe('');
  });
});

describe('paginate', () => {
  const items = Array.from({ length: 25 }, (_, i) => ({ id: i + 1 }));

  it('应正确分页第一页', () => {
    const result = paginate(items, 1, 10);
    expect(result.data).toHaveLength(10);
    expect(result.total).toBe(25);
    expect(result.totalPages).toBe(3);
    expect(result.data[0].id).toBe(1);
  });

  it('应正确分页最后一页', () => {
    const result = paginate(items, 3, 10);
    expect(result.data).toHaveLength(5);
    expect(result.data[0].id).toBe(21);
  });

  it('空数组应返回空结果', () => {
    const result = paginate([], 1, 10);
    expect(result.data).toEqual([]);
    expect(result.total).toBe(0);
  });
});

// ===== 快照测试 =====
describe('快照测试', () => {
  it('配置对象应匹配快照', () => {
    const config = {
      host: 'localhost',
      port: 3000,
      features: ['auth', 'logging', 'cache'],
    };
    expect(config).toMatchSnapshot();
  });

  it('内联快照', () => {
    const greeting = 'Hello, World!';
    expect(greeting).toMatchInlineSnapshot(`"Hello, World!"`);
  });
});

// ===== 异步测试 =====
describe('异步测试', () => {
  it('应正确处理 async/await', async () => {
    const fetchData = vi.fn().mockResolvedValue({ name: 'test' });
    const result = await fetchData();
    expect(result).toEqual({ name: 'test' });
  });

  it('应正确处理 Promise rejection', async () => {
    const fetchError = vi.fn().mockRejectedValue(new Error('网络错误'));
    await expect(fetchError()).rejects.toThrow('网络错误');
  });

  it('应使用回调测试定时器', () => {
    vi.useFakeTimers();
    const callback = vi.fn();
    setTimeout(callback, 1000);
    vi.advanceTimersByTime(1000);
    expect(callback).toHaveBeenCalled();
    vi.useRealTimers();
  });
});

代码示例3:Supertest API 集成测试

// ===== 应用入口:src/app.ts =====
const express = require('express');
const bodyParser = require('body-parser');

function createApp(userService, authMiddleware) {
  const app = express();
  app.use(bodyParser.json());

  // 公开路由
  app.post('/api/auth/register', async (req, res) => {
    try {
      const { name, email, password } = req.body;
      if (!name || !email || !password) {
        return res.status(400).json({ error: '所有字段必填' });
      }
      const user = await userService.register({ name, email, password });
      res.status(201).json(user);
    } catch (err) {
      res.status(409).json({ error: err.message });
    }
  });

  app.post('/api/auth/login', async (req, res) => {
    try {
      const { email, password } = req.body;
      const token = await userService.login(email, password);
      res.json({ token });
    } catch (err) {
      res.status(401).json({ error: err.message });
    }
  });

  // 受保护路由
  app.get('/api/users/me', authMiddleware, async (req, res) => {
    res.json(req.user);
  });

  app.get('/api/users/:id', authMiddleware, async (req, res) => {
    try {
      const user = await userService.getUserById(req.params.id);
      res.json(user);
    } catch (err) {
      res.status(404).json({ error: err.message });
    }
  });

  // 健康检查
  app.get('/health', (req, res) => {
    res.json({ status: 'ok', timestamp: new Date().toISOString() });
  });

  return app;
}

module.exports = { createApp };

// ===== 集成测试:test/api.integration.test.ts =====
const request = require('supertest');
const { createApp } = require('../src/app');

describe('API 集成测试', () => {
  let app;
  let mockUserService;
  let mockAuthMiddleware;

  beforeEach(() => {
    mockUserService = {
      register: jest.fn(),
      login: jest.fn(),
      getUserById: jest.fn(),
    };

    mockAuthMiddleware = jest.fn((req, res, next) => {
      req.user = { id: '1', name: '测试用户', email: 'test@test.com' };
      next();
    });

    app = createApp(mockUserService, mockAuthMiddleware);
  });

  describe('POST /api/auth/register', () => {
    test('应成功注册新用户', async () => {
      const newUser = { id: '1', name: '张三', email: 'zhangsan@test.com' };
      mockUserService.register.mockResolvedValue(newUser);

      const response = await request(app)
        .post('/api/auth/register')
        .send({ name: '张三', email: 'zhangsan@test.com', password: 'Password123' })
        .expect('Content-Type', /json/)
        .expect(201);

      expect(response.body).toEqual(newUser);
      expect(mockUserService.register).toHaveBeenCalledWith({
        name: '张三', email: 'zhangsan@test.com', password: 'Password123'
      });
    });

    test('缺少字段应返回 400', async () => {
      const response = await request(app)
        .post('/api/auth/register')
        .send({ name: '张三' })
        .expect(400);

      expect(response.body.error).toBe('所有字段必填');
    });

    test('重复邮箱应返回 409', async () => {
      mockUserService.register.mockRejectedValue(new Error('邮箱已存在'));

      const response = await request(app)
        .post('/api/auth/register')
        .send({ name: '张三', email: 'zhangsan@test.com', password: 'Password123' })
        .expect(409);

      expect(response.body.error).toBe('邮箱已存在');
    });
  });

  describe('POST /api/auth/login', () => {
    test('应成功登录并返回 token', async () => {
      mockUserService.login.mockResolvedValue('jwt-token-xxx');

      const response = await request(app)
        .post('/api/auth/login')
        .send({ email: 'zhangsan@test.com', password: 'Password123' })
        .expect(200);

      expect(response.body.token).toBe('jwt-token-xxx');
    });

    test('错误凭证应返回 401', async () => {
      mockUserService.login.mockRejectedValue(new Error('邮箱或密码错误'));

      const response = await request(app)
        .post('/api/auth/login')
        .send({ email: 'wrong@test.com', password: 'wrong' })
        .expect(401);

      expect(response.body.error).toBe('邮箱或密码错误');
    });
  });

  describe('GET /api/users/me', () => {
    test('已认证用户应返回用户信息', async () => {
      const response = await request(app)
        .get('/api/users/me')
        .expect(200);

      expect(response.body.name).toBe('测试用户');
      expect(mockAuthMiddleware).toHaveBeenCalled();
    });

    test('未认证请求应被拦截', async () => {
      mockAuthMiddleware.mockImplementation((req, res) => {
        res.status(401).json({ error: '未认证' });
      });

      const response = await request(app)
        .get('/api/users/me')
        .expect(401);

      expect(response.body.error).toBe('未认证');
    });
  });

  describe('GET /health', () => {
    test('应返回健康状态', async () => {
      const response = await request(app)
        .get('/health')
        .expect(200);

      expect(response.body.status).toBe('ok');
      expect(response.body.timestamp).toBeDefined();
    });
  });
});

// ===== 数据库集成测试(使用真实数据库)=====
describe('数据库集成测试', () => {
  let db;
  let app;

  beforeAll(async () => {
    // 连接测试数据库
    db = await connectTestDB();
    // 清空测试数据
    await db.query('TRUNCATE users CASCADE');
  });

  afterAll(async () => {
    await db.query('TRUNCATE users CASCADE');
    await db.close();
  });

  test('注册后应能在数据库中查到', async () => {
    const realUserService = new UserService(db);
    app = createApp(realUserService, mockAuthMiddleware);

    await request(app)
      .post('/api/auth/register')
      .send({ name: '集成测试', email: 'integration@test.com', password: 'Password123' })
      .expect(201);

    const result = await db.query('SELECT * FROM users WHERE email = $1', ['integration@test.com']);
    expect(result.rows).toHaveLength(1);
    expect(result.rows[0].name).toBe('集成测试');
  });
});

踩坑表

坑点现象原因解决方案
异步测试未返回 Promise测试提前通过,断言未执行it() 回调未使用 async/await 或未 return Promise使用 async/awaitreturn Promise
Mock 污染测试之间相互影响jest.fn() 未在 beforeEach 中重置使用 jest.clearAllMocks()jest.resetAllMocks()
定时器测试超时setTimeout 测试永远等不到真实定时器导致测试等待使用 jest.useFakeTimers() + jest.advanceTimersByTime()
ts-jest 编译慢TypeScript 测试执行很慢ts-jest 逐文件编译,开销大使用 isolatedModules: true 或迁移到 Vitest
ESM 导入报错import 语法报 SyntaxErrorJest 默认不支持 ESM配置 transform 或使用 --experimental-vm-modules
覆盖率不达标但测试通过分支未覆盖但无断言缺少边界条件测试检查覆盖率报告,补充边界用例
Supertest 端口冲突多个测试文件同时监听端口listen() 在同一端口Supertest 直接传 app 对象,不需要 listen()
快照测试频繁失败任何细微变化导致快照不匹配快照包含动态数据(时间戳、ID)使用 property matchers 处理动态字段

性能调优

调优项说明推荐配置
并行执行Jest 默认并行运行测试--maxWorkers=4maxWorkers: '50%'
快照优化大量快照影响性能避免对大对象做快照;使用 toMatchInlineSnapshot
缓存Jest 默认缓存 transform 结果确保 --no-cache 仅在调试时使用
测试隔离每个测试独立环境使用 beforeEach 清理状态,避免共享状态
只跑变更相关测试Watch 模式只跑相关文件jest --watch + Git 状态检测
测试超时避免测试无限等待设置 testTimeout: 10000,集成测试可适当放宽
Vitest 线程池Vitest 支持多线程/多进程pool: 'threads',CPU 密集型用 forks
覆盖率排除不必要的覆盖率拖慢速度排除 .d.tsindex.ts、配置文件等

面试题

1. 测试金字塔的概念是什么?

测试金字塔由 Mike Cohn 提出,分为三层:底层是大量快速的单元测试(占 70%),中间是适量的集成测试(占 20%),顶层是少量的 E2E 测试(占 10%)。金字塔原则强调:单元测试成本低、速度快、定位问题精确,应占最大比例;E2E 测试成本高、速度慢、失败难以定位,应尽量少写。违反金字塔原则的”冰淇淋蛋卷”反模式(大量 E2E、少量单元测试)会导致测试维护成本高、反馈速度慢。

2. Jest 和 Vitest 的区别是什么?

Jest 是 Facebook 开源的老牌测试框架,零配置开箱即用,生态最完善,社区资源最丰富。Vitest 是 Vite 团队开发的新一代框架,API 兼容 Jest,迁移成本低。核心区别:(1) Vitest 基于 Vite 构建,启动和 HMR 更快;(2) Vitest 原生支持 ESM,Jest 的 ESM 支持仍为实验性;(3) Vitest 的 TypeScript 支持基于 esbuild/swc,比 ts-jest 快得多;(4) Vitest 共享 Vite 配置和插件,适合 Vite 项目统一技术栈;(5) Jest 生态更成熟,部分高级功能(如自定义 runner)Vitest 尚在完善。

3. Mock 的三种方式是什么?

(1) jest.fn() / vi.fn():创建一个模拟函数,可以追踪调用次数、参数和返回值,用于替代回调函数或简单依赖。(2) jest.spyOn() / vi.spyOn():监视一个真实对象的方法,记录调用信息但不改变原始行为(除非调用 mockImplementation),适合部分 Mock 场景。(3) jest.mock() / vi.mock():替换整个模块,所有导入该模块的代码都会得到 Mock 版本,适合隔离外部依赖(如数据库、HTTP 客户端)。

4. Supertest 的原理是什么?

Supertest 基于 Superagent HTTP 库,它的工作原理是:将 Express/Koa 应用实例直接传入 request(app),Supertest 会调用 app.listen(0) 在随机端口启动服务器(或直接使用 http.createServer 不监听端口),然后通过 Superagent 发送 HTTP 请求到该服务器。这样无需手动启动端口,避免端口冲突,且请求-响应周期在进程内完成,速度更快。测试结束后自动关闭服务器。

5. 覆盖率的四种指标是什么?

(1) 语句覆盖率(Statement Coverage):被执行到的代码语句占总语句数的比例,最基础的指标;(2) 分支覆盖率(Branch Coverage)if/elseswitch、三元运算符等分支被执行到的比例,反映条件逻辑的覆盖程度;(3) 函数覆盖率(Function Coverage):被调用到的函数占总函数数的比例;(4) 行覆盖率(Line Coverage):被执行到的代码行占总行数的比例,与语句覆盖率类似但粒度不同。一般要求 80% 以上,分支覆盖率是最容易被忽略但最重要的指标。

6. TDD 的流程是什么?

TDD(测试驱动开发)的核心流程是”红-绿-重构”循环:(1) Red(红):先写一个会失败的测试,描述期望的行为;(2) Green(绿):写最少的代码让测试通过,不考虑代码质量;(3) Refactor(重构):在测试保护下重构代码,消除重复、改善设计。每轮循环应非常短(几分钟),频繁运行测试。TDD 的好处是:代码天然有测试覆盖、设计更简洁(只实现需要的功能)、重构有安全网、文档即测试。

7. 测试异步代码有哪些方式?

(1) async/await:最推荐的方式,在 it() 回调前加 async,内部使用 await 等待异步操作;(2) 返回 Promise:it() 回调返回 Promise,Jest 会等待 resolve;(3) 回调函数:使用 done 参数,手动调用 done() 表示测试完成;(4) .resolves/.rejectsexpect(promise).resolves.toBe(value)expect(promise).rejects.toThrow();(5) 假定时器:jest.useFakeTimers() + jest.advanceTimersByTime() 测试定时器相关代码。推荐优先使用 async/await

8. 快照测试的适用场景是什么?

快照测试适合测试序列化输出稳定的场景:(1) React/Vue 组件的渲染输出;(2) API 响应结构的回归测试;(3) 配置文件、错误消息的格式;(4) 代码生成器的输出。快照测试将输出序列化后与之前保存的快照对比,任何变化都会导致失败。不适合的场景:包含动态数据(时间戳、随机数、UUID)的输出、频繁变化的数据结构、需要精确断言的业务逻辑。对于动态字段应使用 property matchers(Jest 的 toMatchSnapshot 中使用 expect.any(Date) 等匹配器)。

相关链接