2026年的单元测试:再无借口
工具已经跟上。AI编写测试骨架,框架运行迅速,现在再也没有理由交付未经测试的代码了。

三年前,不写测试的借口至少还说得过去。测试框架很慢。设置模拟对象很繁琐。编写测试所需的时间比编写代码还长。对于一个三人的初创公司,在冲刺产品市场契合度时,投资回报率并不明显。我不同意这些借口,但我理解它们。
到了2026年,这些借口都消失了。Vitest运行完整测试套件所需的时间,与过去Jest启动所需的时间相同。AI工具可以从函数签名生成全面的测试文件。TypeScript在编译时就能捕获整类错误,从而缩小了实际需要测试的范围。Docker Compose可以在几秒钟内为集成测试启动真实的数据库。"无测试"与"充分测试"之间的差距从未如此之小。
现代测试格局
测试生态系统已经围绕少数优秀的工具进行了整合,过去让测试设置成为一项研究项目的碎片化问题已基本消失。
Vitest vs Jest:选择已定
对于任何新项目,Vitest都是默认选择。这并非因为Jest不好——Jest是一个坚实、经过实战检验的框架——而是因为Vitest在现代开发中所有重要维度上都明显更优。
速度: Vitest使用esbuild进行转换,并在支持原生ESM的worker线程中运行测试。在我的项目中,相同的测试套件在Vitest下运行比在Jest下快3-5倍。一个在Jest中需要45秒的套件,在Vitest中只需12秒即可完成。
配置: Vitest复用你的Vite配置。如果你已经使用Vite进行构建(在2026年,大多数项目都是如此),则无需额外配置。将其与Jest的moduleNameMapper、transform、transformIgnorePatterns以及不可避免地排查某些ESM包为何不工作的问题进行比较。
兼容性: Vitest实现了与Jest兼容的API。describe、it、expect、beforeEach、afterEach、vi.fn()、vi.mock()——所有这些都按预期工作。从Jest迁移到Vitest主要是将jest.fn()查找替换为vi.fn()。
// vitest.config.ts — this is often all you need
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', '**/*.d.ts', '**/*.config.*'],
},
},
});
何时坚持使用Jest: 如果你有一个大型的现有Jest测试套件(500+测试)并且没有立即的痛点,那么迁移并不紧急。Jest会继续接收更新并且运行良好。但对于新项目或测试数量少于100个的项目,Vitest是显而易见的选择。
实践中的测试金字塔
传统的测试金字塔(大量单元测试,较少集成测试,少量端到端测试)原则上仍然正确,但其边界已经发生了变化。
单元测试 验证单个函数、实用方法和纯业务逻辑。它们应该快速(每个低于10毫秒),没有外部依赖,并且测试行为而非实现。
集成测试 验证组件协同工作——API路由与数据库查询、服务方法与外部API调用、React组件与它们的状态管理。这些测试较慢(每个100-500毫秒),但能捕获单元测试遗漏的错误。
端到端测试 通过实际应用程序验证完整的用户工作流程。这些测试最慢(每个5-30秒)且最脆弱,但它们能捕获其他测试无法发现的错误。Playwright等工具使端到端测试比Selenium时代可靠得多。
我设定的比例是:70%单元测试,20%集成测试,10%端到端测试。具体数字不如在每个层面都有所覆盖重要。
测试什么,跳过什么
并非所有代码都需要相同的测试严谨性。知道在哪里投入测试精力与知道如何编写测试同样重要。
始终测试
业务逻辑和领域规则。 如果代码实现了业务规则——价格计算、权限检查、数据验证、状态机转换——它就需要测试。这些规则一旦出错,就会造成金钱损失或产生安全漏洞。
// pricing.ts
export function calculateOrderTotal(items: OrderItem[], discount?: Discount): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
if (!discount) return subtotal;
if (discount.type === 'percentage') {
return subtotal * (1 - discount.value / 100);
}
if (discount.type === 'fixed') {
return Math.max(0, subtotal - discount.value);
}
return subtotal;
}
// pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateOrderTotal } from './pricing';
describe('calculateOrderTotal', () => {
const items: OrderItem[] = [
{ id: '1', name: 'Burger', price: 12.99, quantity: 2 },
{ id: '2', name: 'Fries', price: 4.99, quantity: 1 },
];
it('calculates subtotal without discount', () => {
expect(calculateOrderTotal(items)).toBe(30.97);
});
it('applies percentage discount', () => {
const discount = { type: 'percentage' as const, value: 10 };
expect(calculateOrderTotal(items, discount)).toBeCloseTo(27.873);
});
it('applies fixed discount', () => {
const discount = { type: 'fixed' as const, value: 5 };
expect(calculateOrderTotal(items, discount)).toBe(25.97);
});
it('does not go below zero with fixed discount', () => {
const discount = { type: 'fixed' as const, value: 50 };
expect(calculateOrderTotal(items, discount)).toBe(0);
});
it('handles empty items array', () => {
expect(calculateOrderTotal([])).toBe(0);
});
});
数据转换函数。 任何将数据从一种形式转换为另一种形式的函数——API响应映射器、表单数据序列化器、CSV解析器——都需要使用代表性输入和边缘情况进行测试。
错误处理路径。 当API返回500时会发生什么?当输入为null时呢?当文件不存在时呢?错误路径是bug隐藏的地方,因为开发人员通常手动测试“快乐路径”,并假设错误路径也能正常工作。
实用函数。 字符串格式化器、日期辅助函数、数组实用工具、验证函数。这些函数在代码库中随处可见,一个实用函数中的bug会在整个代码库中扩散。
跳过或轻量测试
直接框架封装。 如果你的函数只是对一个经过充分测试的框架方法的薄封装,那么测试它是在测试框架,而不是你的代码。一个渲染<h1>{title}</h1>的React组件不需要测试来验证h1是否渲染。
配置文件。 静态配置、常量、类型定义——这些不需要测试。TypeScript在编译时已经对它们进行了验证。
第三方库集成胶水代码。 调用stripe.charges.create()并传入你的数据模型参数的代码,不需要一个模拟Stripe并验证你是否调用了它的单元测试。它需要一个集成测试来验证端到端的支付流程。
模拟策略
模拟是测试从“验证行为”转向“测试实现细节”的地方。目标是在保持测试快速和确定性的同时,尽可能少地进行模拟。
依赖注入方法
与其模拟模块,不如将依赖项作为参数传递。这使得测试变得自然,并避免了将测试与模块结构耦合的vi.mock()魔法。
// user-service.ts
export function createUserService(db: Database, emailClient: EmailClient) {
return {
async createUser(data: CreateUserInput): Promise<User> {
const user = await db.users.create({ data });
await emailClient.send({
to: user.email,
template: 'welcome',
data: { name: user.name },
});
return user;
},
};
}
// user-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createUserService } from './user-service';
describe('createUserService', () => {
it('creates user and sends welcome email', async () => {
const mockUser = { id: '1', name: 'Jane', email: 'jane@example.com' };
const db = {
users: { create: vi.fn().mockResolvedValue(mockUser) },
};
const emailClient = {
send: vi.fn().mockResolvedValue(undefined),
};
const service = createUserService(db as any, emailClient as any);
const result = await service.createUser({ name: 'Jane', email: 'jane@example.com' });
expect(result).toEqual(mockUser);
expect(emailClient.send).toHaveBeenCalledWith({
to: 'jane@example.com',
template: 'welcome',
data: { name: 'Jane' },
});
});
});
没有vi.mock()调用,没有模块路径字符串,没有依赖于顺序的设置。测试明确了什么是真实的,什么是假的。
何时使用vi.mock()
模块级别的模拟适用于你无法控制依赖注入的情况——通常是在测试直接导入模块的React组件时,或者在测试使用特定于环境的全局变量的代码时。
// When you genuinely need module mocking
vi.mock('./api-client', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test User' }),
}));
MSW用于API模拟
对于发出HTTP请求的测试,Mock Service Worker (MSW) 在服务工作线程级别拦截网络请求。这优于模拟fetch或axios,因为它测试的是你实际的HTTP客户端代码。
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Test User',
email: 'test@example.com',
});
}),
http.post('https://api.example.com/orders', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 'order-1', ...body, status: 'created' },
{ status: 201 }
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
AI辅助测试生成
AI工具确实改变了测试编写的经济效益。生成初始测试骨架——样板代码、基本“快乐路径”用例、标准边缘用例——正是LLM擅长处理的那种重复的、基于模式的工作。
AI擅长什么
- 从函数签名和类型定义生成测试文件
- 建议你可能想不到的边缘情况(空数组、null值、边界数字)
- 编写重复的设置/拆卸样板代码
- 创建符合接口形状的模拟对象
- 为具有多种输入组合的函数生成参数化测试用例
AI不擅长什么
- 理解业务上下文(“这应该失败,因为用户不能在午夜后下单”是领域知识)
- 测试跨多个函数调用的复杂状态交互
- 编写有意义的、能测试真实系统边界的集成测试
- 决定测试什么和不测试什么
- 编写验证行为而非实现的测试
实际工作流程
我使用AI辅助测试的工作流程:
- 编写函数或模块。
- 请求AI生成包含基本用例的测试文件。
- 审查并修正生成的测试——AI生成的测试通常测试实现细节而非行为。
- 添加AI遗漏的领域特定用例。
- 运行测试并通过暂时破坏代码来验证它们是否真的能捕获错误。
AI完成第2步(这曾是繁琐的部分),我则专注于第3-5步(这需要判断力)。这使得标准实用程序和服务代码的测试编写时间大约减少了50-60%。
测试React组件
组件测试已经显著发展。从Enzyme的浅渲染到React Testing Library以用户为中心的测试理念的转变,改变了我对组件测试的看法。
组件中要测试什么
- 组件是否能用不同的props正确渲染?
- 用户交互(点击、输入、选择)是否触发了预期行为?
- 组件是否处理了加载、错误和空状态?
- 条件渲染是否正常工作?
组件中不测试什么
- 内部状态值(测试用户看到的内容,而不是React跟踪的内容)
- 实现细节(调用了哪个函数,发生了多少次重新渲染)
- 样式(为此使用视觉回归测试)
// OrderSummary.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OrderSummary } from './OrderSummary';
describe('OrderSummary', () => {
const defaultProps = {
items: [
{ id: '1', name: 'Burger', price: 12.99, quantity: 2 },
{ id: '2', name: 'Fries', price: 4.99, quantity: 1 },
],
onCheckout: vi.fn(),
};
it('displays order items with prices', () => {
render(<OrderSummary {...defaultProps} />);
expect(screen.getByText('Burger')).toBeInTheDocument();
expect(screen.getByText('$25.98')).toBeInTheDocument(); // 12.99 * 2
expect(screen.getByText('Fries')).toBeInTheDocument();
expect(screen.getByText('$4.99')).toBeInTheDocument();
});
it('displays total', () => {
render(<OrderSummary {...defaultProps} />);
expect(screen.getByText('Total: $30.97')).toBeInTheDocument();
});
it('calls onCheckout when checkout button is clicked', async () => {
const user = userEvent.setup();
render(<OrderSummary {...defaultProps} />);
await user.click(screen.getByRole('button', { name: /checkout/i }));
expect(defaultProps.onCheckout).toHaveBeenCalledOnce();
});
it('shows empty state when no items', () => {
render(<OrderSummary {...defaultProps} items={[]} />);
expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /checkout/i })).not.toBeInTheDocument();
});
it('disables checkout button during loading', () => {
render(<OrderSummary {...defaultProps} isLoading={true} />);
expect(screen.getByRole('button', { name: /checkout/i })).toBeDisabled();
});
});
注意:除非必要,不使用getByTestId,不检查内部状态,不验证CSS类。这些测试读起来就像用户看到和做了什么的描述。
测试API路由
API路由测试是集成测试,用于验证你的HTTP处理程序与其中间件、验证和响应格式化是否正常工作。
// Using supertest with an Express app
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { db } from '../database';
describe('POST /api/orders', () => {
let authToken: string;
beforeAll(async () => {
await db.migrate.latest();
await db.seed.run();
// Get auth token for test user
const loginResponse = await request(app)
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'testpassword' });
authToken = loginResponse.body.token;
});
afterAll(async () => {
await db.destroy();
});
it('creates an order with valid data', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [{ productId: 'prod-1', quantity: 2 }],
deliveryAddress: '123 Main St',
});
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.any(String),
status: 'pending',
items: expect.arrayContaining([
expect.objectContaining({ productId: 'prod-1', quantity: 2 }),
]),
});
});
it('returns 401 without auth token', async () => {
const response = await request(app)
.post('/api/orders')
.send({ items: [{ productId: 'prod-1', quantity: 2 }] });
expect(response.status).toBe(401);
});
it('returns 400 with invalid data', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({ items: [] });
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
});
it('returns 400 when product does not exist', async () => {
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [{ productId: 'nonexistent', quantity: 1 }],
deliveryAddress: '123 Main St',
});
expect(response.status).toBe(400);
expect(response.body.message).toContain('not found');
});
});
测试数据库操作
测试数据库操作需要一个真实的数据库。模拟SQL查询测试的是模拟对象,而不是查询本身。使用一个在每次测试运行后都会重新创建的测试数据库。
// database.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
datasources: { db: { url: process.env.TEST_DATABASE_URL } },
});
beforeEach(async () => {
// Clean tables in dependency order
await prisma.orderItem.deleteMany();
await prisma.order.deleteMany();
await prisma.user.deleteMany();
});
describe('Order repository', () => {
it('creates order with items and calculates total', async () => {
const user = await prisma.user.create({
data: { email: 'test@example.com', name: 'Test User' },
});
const order = await prisma.order.create({
data: {
userId: user.id,
items: {
create: [
{ productName: 'Burger', price: 12.99, quantity: 2 },
{ productName: 'Fries', price: 4.99, quantity: 1 },
],
},
total: 30.97,
},
include: { items: true },
});
expect(order.items).toHaveLength(2);
expect(order.total).toBe(30.97);
expect(order.userId).toBe(user.id);
});
it('enforces unique email constraint', async () => {
await prisma.user.create({
data: { email: 'duplicate@example.com', name: 'User 1' },
});
await expect(
prisma.user.create({
data: { email: 'duplicate@example.com', name: 'User 2' },
})
).rejects.toThrow();
});
});
CI中的测试数据库设置
# In GitHub Actions
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
使用Prisma时,在测试前运行npx prisma migrate deploy来应用schema。使用原始SQL时,运行迁移脚本。测试数据库在每次CI运行时都会重新开始。
重要的覆盖率指标
代码覆盖率是一个有用的信号,但它是一个糟糕的目标。
覆盖率陷阱
追求100%覆盖率会导致测试的存在只是为了触及未覆盖的代码行,而不是为了验证行为。我见过这样的测试:
// This test exists purely for coverage
it('should have a default export', () => {
expect(module).toBeDefined();
});
这个测试没有验证任何有意义的东西。它增加了覆盖率数字,但没有增加任何信心。
有意义的覆盖率目标
我不使用一概而论的覆盖率阈值,而是为不同的代码类别设定不同的目标:
| 类别 | 目标 | 理由 |
|---|---|---|
| 业务逻辑 / 领域 | 90%+ | 这里的bug会造成金钱损失 |
| API路由 / 控制器 | 80%+ | 集成边界 |
| 实用函数 | 95%+ | 广泛使用,影响大 |
| UI组件 | 70%+ | 视觉bug由E2E捕获 |
| 配置 / 设置 | 无目标 | 静态,很少出错 |
按目录配置覆盖率阈值:
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
thresholds: {
'src/domain/**': { statements: 90, branches: 85 },
'src/api/**': { statements: 80, branches: 75 },
'src/utils/**': { statements: 95, branches: 90 },
},
},
},
});
突变测试替代方案
如果覆盖率数字让你觉得空洞,突变测试提供了一种更诚实的评估。Stryker等工具会向你的代码引入微小更改(突变),并验证你的测试是否能捕获这些更改。如果一个突变幸存下来(测试仍然通过),则表明你的测试套件存在漏洞。
npx stryker run
突变测试很慢(对每个突变都会运行整个测试套件),但它具有启发性。我每月运行一次,而不是在每次提交时都运行。
实践中的测试驱动开发
我有选择地实践TDD,而非教条式地。它对某些代码非常有效,但对另一些代码则会增加摩擦。
TDD的优势所在
具有明确规范的纯函数。 如果你在编写代码之前就知道输入和预期输出,那么先编写测试是自然且高效的。
Bug修复。 在修复bug之前,先编写一个能重现bug的测试。然后修复代码直到测试通过。这能确保bug得到彻底修复。
重构。 在重构之前为现有行为编写测试。测试充当安全网,验证重构后的代码产生相同的结果。
TDD增加摩擦的地方
探索性代码。 当你正在摸索某个功能应该如何工作时——比如试验API、原型化UI、探索数据结构——先编写测试会拖慢你的速度。先编写代码,稳定接口,然后再添加测试。
UI组件。 React组件的Testing Library测试最好在组件存在之后编写,因为组件的渲染输出(测试所断言的内容)在你构建它之前是未知的。
红-绿-重构循环
当我进行TDD时,我严格遵循经典的循环:
- 红: 编写一个会失败的测试(因为代码尚不存在)。
- 绿: 编写最少的代码以使测试通过。不要进行优化。
- 重构: 在保持所有测试通过的同时清理代码。
第2步的纪律——编写最少的代码——是最重要且最常被违反的。立即编写“真实实现”的诱惑会违背TDD的目的,即让测试逐步驱动设计。
投资带来复利
你编写的每一个测试都有数年的保质期。今天编写的测试将捕获下周重构、下月功能添加以及明年框架升级中的回归问题。你花10分钟编写一个测试,可以节省六个月后2小时的调试时间,但你永远不会明确看到这些节省——你只是从未遇到那个bug。
测试最困难的部分不是工具或技术。而是在代码在你脑海中还很清晰的时候,立即编写测试的纪律,而不是将其添加到待办事项中,让它悄无声息地消亡。工具的借口已经消失了。速度的借口也消失了。剩下的唯一借口是优先级,而交付经过测试的代码不应该成为可以商议的事情。