测试与质量保障
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 + Vite | ESM 原生支持,构建和测试共享配置 |
| Express API 服务 | Jest + Supertest | 成熟稳定,社区资源丰富 |
| NestJS 项目 | Jest(内置) | NestJS 默认集成 Jest |
| 全栈应用 | Vitest(后端+前端) | 统一测试框架,共享配置 |
| 开源项目 | Jest | 社区接受度最高,CI 兼容性好 |
| Monorepo | Vitest | 更快的工作空间支持 |
对比 Jest / Vitest / Mocha
| 维度 | Jest | Vitest | Mocha |
|---|---|---|---|
| 配置 | 零配置开箱即用 | 需 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.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('<script>alert("xss")</script>');
});
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/await 或 return Promise |
| Mock 污染 | 测试之间相互影响 | jest.fn() 未在 beforeEach 中重置 | 使用 jest.clearAllMocks() 或 jest.resetAllMocks() |
| 定时器测试超时 | setTimeout 测试永远等不到 | 真实定时器导致测试等待 | 使用 jest.useFakeTimers() + jest.advanceTimersByTime() |
| ts-jest 编译慢 | TypeScript 测试执行很慢 | ts-jest 逐文件编译,开销大 | 使用 isolatedModules: true 或迁移到 Vitest |
| ESM 导入报错 | import 语法报 SyntaxError | Jest 默认不支持 ESM | 配置 transform 或使用 --experimental-vm-modules |
| 覆盖率不达标但测试通过 | 分支未覆盖但无断言 | 缺少边界条件测试 | 检查覆盖率报告,补充边界用例 |
| Supertest 端口冲突 | 多个测试文件同时监听端口 | listen() 在同一端口 | Supertest 直接传 app 对象,不需要 listen() |
| 快照测试频繁失败 | 任何细微变化导致快照不匹配 | 快照包含动态数据(时间戳、ID) | 使用 property matchers 处理动态字段 |
性能调优
| 调优项 | 说明 | 推荐配置 |
|---|---|---|
| 并行执行 | Jest 默认并行运行测试 | --maxWorkers=4 或 maxWorkers: '50%' |
| 快照优化 | 大量快照影响性能 | 避免对大对象做快照;使用 toMatchInlineSnapshot |
| 缓存 | Jest 默认缓存 transform 结果 | 确保 --no-cache 仅在调试时使用 |
| 测试隔离 | 每个测试独立环境 | 使用 beforeEach 清理状态,避免共享状态 |
| 只跑变更相关测试 | Watch 模式只跑相关文件 | jest --watch + Git 状态检测 |
| 测试超时 | 避免测试无限等待 | 设置 testTimeout: 10000,集成测试可适当放宽 |
| Vitest 线程池 | Vitest 支持多线程/多进程 | pool: 'threads',CPU 密集型用 forks |
| 覆盖率排除 | 不必要的覆盖率拖慢速度 | 排除 .d.ts、index.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/else、switch、三元运算符等分支被执行到的比例,反映条件逻辑的覆盖程度;(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/.rejects:expect(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) 等匹配器)。
相关链接
- Express — API 路由测试与中间件 Mock
- TypeScript与Node — TypeScript 项目的测试配置与类型安全
- CI与CD与自动化部署 — CI 流水线中的自动化测试集成
- Jest 官方文档 — Jest API 参考与最佳实践