Skip to main content
backend2025年11月2日6 分钟阅读

2026年的单元测试:再无借口

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

testingvitestjest
2026年的单元测试:再无借口

三年前,不写测试的借口至少还说得过去。测试框架很慢。设置模拟对象很繁琐。编写测试所需的时间比编写代码还长。对于一个三人的初创公司,在冲刺产品市场契合度时,投资回报率并不明显。我不同意这些借口,但我理解它们。

到了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的moduleNameMappertransformtransformIgnorePatterns以及不可避免地排查某些ESM包为何不工作的问题进行比较。

兼容性: Vitest实现了与Jest兼容的API。describeitexpectbeforeEachafterEachvi.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) 在服务工作线程级别拦截网络请求。这优于模拟fetchaxios,因为它测试的是你实际的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辅助测试的工作流程:

  1. 编写函数或模块。
  2. 请求AI生成包含基本用例的测试文件。
  3. 审查并修正生成的测试——AI生成的测试通常测试实现细节而非行为。
  4. 添加AI遗漏的领域特定用例。
  5. 运行测试并通过暂时破坏代码来验证它们是否真的能捕获错误。

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时,我严格遵循经典的循环:

  1. 红: 编写一个会失败的测试(因为代码尚不存在)。
  2. 绿: 编写最少的代码以使测试通过。不要进行优化。
  3. 重构: 在保持所有测试通过的同时清理代码。

第2步的纪律——编写最少的代码——是最重要且最常被违反的。立即编写“真实实现”的诱惑会违背TDD的目的,即让测试逐步驱动设计。

投资带来复利

你编写的每一个测试都有数年的保质期。今天编写的测试将捕获下周重构、下月功能添加以及明年框架升级中的回归问题。你花10分钟编写一个测试,可以节省六个月后2小时的调试时间,但你永远不会明确看到这些节省——你只是从未遇到那个bug。

测试最困难的部分不是工具或技术。而是在代码在你脑海中还很清晰的时候,立即编写测试的纪律,而不是将其添加到待办事项中,让它悄无声息地消亡。工具的借口已经消失了。速度的借口也消失了。剩下的唯一借口是优先级,而交付经过测试的代码不应该成为可以商议的事情。

DU

Danil Ulmashev

Full Stack Developer

有兴趣一起合作吗?