现代软件测试策略:从单元到端到端

每个软件团队都写测试。但不是每个团队写的测试都能预防缺陷、扛住重构,并让人有底气在周五下午发布。有用的测试和帮倒忙的测试,差别不在测试框架,也不在覆盖率数字,而在策略——知道该测什么、在哪一层测、用什么代价去测。

许多团队的默认做法是给所有东西写单元测试,然后就算完事。覆盖率报告显示 90%,持续集成是绿的,大家都心安——直到某个函数的改动打断了三个功能,而没有一个单元测试察觉。问题不在缺乏纪律,而在测错了层。一个把五个服务串起来的组件,可以有 100% 的单元覆盖率,却依然在生产环境失败,因为这些服务之间的交互从未被验证过。

本文覆盖现代测试策略的完整光谱——从快速隔离的单元测试,到缓慢但可靠的端到端测试——并说明每种策略何时带来价值、何时只增加负担。目标不是说服你写更多测试,而是帮你写出对得起自身存在的测试:抓住真正重要的缺陷,快到足以经常运行,并且能熬过代码库不可避免的重新组织。

单元测试的好做法,以及该测什么

单元测试是多数测试策略的基础,因为它们快速、确定且相互隔离。一个写得好的单元测试在毫秒内跑完,只覆盖一条逻辑行为,不依赖数据库、文件系统或网络接口这类外部系统。它失败时,你能确切知道哪一块逻辑坏了,以及为什么。

最常见的错误是测实现细节而非行为。去断言某个函数调用了特定的内部方法、或设置了某个私有字段,会让测试变脆——一次保留外部行为的重构照样会把它弄坏。测试于是从安全网变成负担。要断言函数可观察的输出或副作用,而不是它如何得到这个结果。

适合做单元测试的,是纯工具函数、不涉及输入输出的业务逻辑、校验与解析、数据转换、算法计算和状态机。不适合的,是发起网络调用的函数、渲染界面的组件,以及任何与框架内部紧密耦合的代码——这些更适合放到集成或端到端层去测。

import { describe, it, expect } from "vitest";
import { calculateDiscount, type Order } from "./pricing";

describe("calculateDiscount", () => {
  it("returns 0 for orders below the threshold", () => {
    const order: Order = {
      items: [{ price: 30, quantity: 1 }],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(0);
  });

  it("applies 10 percent for orders over $100", () => {
    const order: Order = {
      items: [
        { price: 80, quantity: 1 },
        { price: 40, quantity: 1 },
      ],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(12);
  });

  it("applies loyalty bonus for customers over 2 years", () => {
    const order: Order = {
      items: [{ price: 100, quantity: 1 }],
      customerSince: new Date("2022-06-01"),
    };

    expect(calculateDiscount(order)).toBe(15);
  });

  it("does not apply discount when items are on sale", () => {
    const order: Order = {
      items: [{ price: 200, quantity: 1, onSale: true }],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(0);
  });
});

上面的例子用不同的输入与期望输出,测试了同一个函数的四种不同行为。每个测试彼此独立、结果确定,运行不到 10 毫秒。若定价逻辑变了,这些测试会准确告诉你哪些场景受影响。这正是单元测试的价值主张:对纯逻辑给出快速反馈。

  • 测行为,不测实现。断言输出与副作用,而不是内部方法调用或私有状态。
  • 每个用例只做一条逻辑断言。一个测试应当只因一个原因失败,这样一眼就知道是什么坏了。
  • 用能像句子一样读的描述性命名。「低于门槛的订单返回 0」优于「test_discount_low_amount」。
  • 避免测试之间共享可变状态。每个测试自行准备数据,并在结束后清理干净。
  • 让测试数据简单而有意义。使用贴近真实领域对象的数据。

使用真实依赖的集成测试

集成测试在速度与范围上介于单元测试和端到端测试之间。它们检验多个单元如何协作:仓储访问真实数据库,服务调用实际接口,或组件配合真实状态仓库渲染。与单元测试的关键区别在于,它们使用真实或容器化的依赖,而不是替身。

最有价值的集成测试,验证的是你的代码是否正确地与外部系统打交道。一个模拟了数据库层的单元测试,能告诉你查询逻辑单独看是否正确,却不能告诉你真正的 SQL 语句在真实表结构上是否跑得通。借助 Testcontainers 或一个测试数据库的集成测试,能抓住表结构不一致、约束冲突、事务边界错误和序列化问题——这些单元测试完全看不见。

它们在仓储层、API 路由处理器、消息队列消费者、数据库迁移,以及任何跨越边界序列化或反序列化数据的代码上最见功力。代价是速度——一个要启动 Postgres 容器的测试要花几秒而非几毫秒——但它们抓到的缺陷,如果拖到生产环境才发现,代价也按比例更高。

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createDatabase, teardownDatabase } from "./test-utils";
import { OrderRepository } from "./order-repository";

describe("OrderRepository", () => {
  let db: Awaited<ReturnType<typeof createDatabase>>;
  let repo: OrderRepository;

  beforeAll(async () => {
    db = await createDatabase();
    repo = new OrderRepository(db);
  });

  afterAll(async () => {
    await teardownDatabase(db);
  });

  it("persists and retrieves an order", async () => {
    const order = {
      id: "ord_001",
      customerId: "cus_001",
      total: 1250,
      status: "pending" as const,
      createdAt: new Date("2025-06-01"),
    };

    await repo.save(order);
    const retrieved = await repo.findById("ord_001");

    expect(retrieved).toEqual(order);
  });

  it("returns null for a non-existent order", async () => {
    const result = await repo.findById("ord_nonexistent");
    expect(result).toBeNull();
  });

  it("updates order status", async () => {
    await repo.save({
      id: "ord_002",
      customerId: "cus_001",
      total: 500,
      status: "pending",
      createdAt: new Date(),
    });

    await repo.updateStatus("ord_002", "shipped");
    const updated = await repo.findById("ord_002");

    expect(updated?.status).toBe("shipped");
  });
});

集成测试的关键规则是:在不同运行之间保持数据库状态隔离。每套测试都应创建自己的表结构或数据库,执行迁移,跑完测试,然后全部拆掉。在共享数据库上运行会造成不稳定的测试,一个测试的数据会渗进另一个测试的断言。用 Testcontainers、Docker Compose 或框架自带的测试数据库支持,确保每次运行都从已知状态开始。

一个使用真实数据库的集成测试,抵得上一百个模拟数据库的单元测试。模拟告诉你你期望数据库做什么,真实数据库告诉你它实际做了什么。第一次表结构迁移之后,这两者就很少再一致了。

用 Playwright 做端到端测试

端到端测试模拟真实用户在整个技术栈上的操作——浏览器、前端、接口、数据库和外部服务。它们最慢也最贵,却能抓到最贴近现实的缺陷:导航断裂、表单校验缺失、接口响应显示错误、认证流程失败,以及跨浏览器的渲染差异。

凭借多浏览器支持、自动等待的断言、网络拦截和良好的使用体验,Playwright 已成为 Web 应用端到端测试的主流工具。与需要显式等待和重试的早期工具不同,Playwright 会等元素真正可操作后才与之交互,这消除了测试不稳定最常见的根源。

import { test, expect } from "@playwright/test";

test("user completes a purchase flow", async ({ page }) => {
  await page.goto("/products");

  await page.getByRole("link", { name: "Wireless Headphones" }).click();
  await page.getByRole("button", { name: "Add to Cart" }).click();
  await page.getByRole("button", { name: "View Cart" }).click();

  await expect(page.getByText("Wireless Headphones")).toBeVisible();
  await expect(page.getByText("$89.99")).toBeVisible();

  await page.getByRole("button", { name: "Checkout" }).click();

  await page.getByLabel("Email").fill("test@example.com");
  await page.getByLabel("Card Number").fill("4242424242424242");
  await page.getByLabel("Expiry").fill("12/28");
  await page.getByLabel("CVC").fill("123");
  await page.getByRole("button", { name: "Pay Now" }).click();

  await expect(page.getByText("Order confirmed")).toBeVisible();
  await expect(page.getByText("ord_001")).toBeVisible();
});

团队在端到端测试上最大的失误,是写得太多。每个端到端测试都会给持续集成流水线加上几分钟,两百个的测试集轻易就要跑半小时。它的成本结构要求你有所取舍。把精力放在关键用户路径上——注册、登录、购买、搜索、支付——把边界情况和错误状态交给更低层的测试。

  • 优先覆盖与收入直接相关的流程:结账、订阅升级、支付处理。
  • 保持测试彼此独立。每个测试自行创建数据,并在结束后清理。
  • 在准备阶段用接口调用而非界面操作,更快到达测试起始状态。
  • 在预发或预览环境上运行,而不是生产环境。用功能开关控制测试数据的可见性。
  • 投资于测试的可观察性——失败测试的截图、追踪和录像,能省下大量排查时间。

测试驱动开发的实际做法

测试驱动开发讲的不是测试,而是设计。红、绿、重构的循环,迫使你在实现之前先想清楚代码的接口。先写测试,就得回答一个问题:这个函数应该接受什么、返回什么?这道约束带来更好的接口、更松的耦合和更模块化的代码。

对它的抵触通常来自两处。要么是有人把它用在了不合适的问题上(界面代码、探索性工作),要么是教条式地照搬,花在与测试框架搏斗上的时间比写有用代码还多。它是工具,不是宗教。它最适合算法逻辑、数据转换、接口设计,以及任何在动手之前输入输出契约就已清晰的代码。

务实的流程是这样的:写一个测试,描述你接下来想实现的行为。运行它,看着它失败——这确认了测试有效,能察觉功能的缺席。写最少的代码让它通过,不要过度设计;满足测试的最简实现就是对的。然后在不改变行为的前提下改进结构,依靠通过的测试来发现回归。

测试驱动开发不会让你写更多测试,而是让你写出更好的代码。测试是设计过程的副产品,不是目的。在实现之后写测试,是在测试;在实现之前写测试,是在设计。

它在修复缺陷时尤其有效。收到缺陷报告后,先写一个能复现它的测试(红),再修代码(绿),然后确认修复没有破坏既有行为(所有测试仍然通过)。你写下的这个测试,就成了防止它再次出现的长期保障。日积月累,测试集会反映出项目真实的缺陷史,这比为了达到覆盖率目标而生成的测试集有价值得多。

基于属性的测试与模糊测试

传统的基于示例的测试,用具体输入去比对期望输出。基于属性的测试走的是另一条路:定义一条对所有输入都应成立的性质,然后生成成百上千个随机输入来验证它。这种技术能抓到没人会想到写成示例的边界情况。

例如,与其为一个排序函数写五个具体用例,不如定义一条性质:对任意可比较元素的列表,结果都应包含相同的元素,且按非递减顺序排列。测试框架会生成各种长度的随机列表——包含重复项、空列表和极端值——并逐一验证这条性质。

import { describe, it, expect } from "vitest";
import { faker } from "@faker-js/faker";

describe("sortByPrice", () => {
  it("returns items in ascending price order for any input", () => {
    for (let i = 0; i < 100; i++) {
      const items = Array.from(
        { length: faker.number.int({ min: 0, max: 50 }) },
        () => ({
          name: faker.commerce.productName(),
          price: faker.number.int({ min: 1, max: 1000 }),
        })
      );

      const sorted = sortByPrice(items);

      for (let j = 1; j < sorted.length; j++) {
        expect(sorted[j - 1].price).toBeLessThanOrEqual(sorted[j].price);
      }
    }
  });

  it("preserves all original elements", () => {
    for (let i = 0; i < 100; i++) {
      const items = Array.from(
        { length: faker.number.int({ min: 0, max: 50 }) },
        () => ({
          name: faker.commerce.productName(),
          price: faker.number.int({ min: 1, max: 1000 }),
        })
      );

      const sorted = sortByPrice(items);

      expect(sorted).toHaveLength(items.length);
      expect(sorted.map((i) => i.name).sort()).toEqual(
        items.map((i) => i.name).sort()
      );
    }
  });
});

fast-check 是 TypeScript 中常用的基于属性的测试库,可与 Vitest 和 Jest 配合。它提供常见数据类型的生成器、复杂结构的组合子,以及自动收缩:一旦发现失败用例,它会尝试把它缩到仍会失败的最小输入,大大降低排查难度。

这种做法在序列化与反序列化、排序与过滤算法、校验规则、状态机迁移,以及任何输入空间很大的函数上格外有价值。再结合往系统里灌入畸形或意外数据的模糊测试手法,基于属性的测试能暴露出安全漏洞与崩溃,而这些原本只会在生产环境才被发现。

视觉回归测试

功能测试验证应用行为是否正确,视觉回归测试验证它看上去是否正确。CSS 改动、依赖升级和重构过的组件,都可能带来细微的布局位移、颜色偏差、字体变化或断点问题,而这些功能测试一概抓不到。视觉回归会截取组件或页面的图像,与基准图对比,把任何差异标出来交给人审阅。

Chromatic、Percy 以及 Playwright 内置的截图能力,在不同层次上实现了这件事。Chromatic 与 Storybook 集成,逐像素比对,并用智能差异算法忽略抗锯齿与次像素渲染的差别。Percy 提供类似能力,支持的框架更广。Playwright 的 toHaveScreenshot 断言,则让你无需额外工具,直接在端到端测试里写视觉断言。

import { test, expect } from "@playwright/test";

test("product page renders correctly", async ({ page }) => {
  await page.goto("/products/wireless-headphones");

  await expect(page).toHaveScreenshot("product-page.png", {
    maxDiffPixelRatio: 0.01,
    threshold: 0.2,
    fullPage: true,
  });
});

test("checkout form is visually stable across states", async ({ page }) => {
  await page.goto("/checkout");

  await expect(page).toHaveScreenshot("checkout-empty.png");

  await page.getByLabel("Email").fill("invalid-email");
  await page.getByRole("button", { name: "Continue" }).click();

  await expect(page).toHaveScreenshot("checkout-validation-error.png");
});

视觉回归最大的难题是管理基准图。每次有意的视觉改动都要更新基准,对频繁改界面的团队来说,审批流程很容易成为瓶颈。解法是把更新基准当作开发流程的一部分:在与代码相同的评审周期里批准视觉差异,使用一个能接入拉取请求流程的视觉审阅平台。

  • 从关键页面开始:首页、结账、登录、商品详情,以及任何版式复杂的页面。
  • 设置合适的阈值,忽略抗锯齿产物和不同操作系统间的字体渲染差异。
  • 用组件级截图(通过 Storybook)做有针对性的覆盖,而不是对任何改动都敏感的整页截图。
  • 把视觉审阅接入拉取请求流程。对改动页面上的视觉差异,要求人工批准。
  • 在统一的环境(Docker)中运行视觉测试,消除不同开发机之间的渲染差异。

测试 React 服务端组件与 Next.js

React 服务端组件改变了测试的格局。它们只在服务端运行,不向客户端发送任何 JavaScript,可以直接访问数据库、文件系统和后端服务,而不把这些逻辑暴露给浏览器。这让它们可以用与客户端组件不同的方式来测试。

服务端组件本质上是返回 JSX 的异步函数。你可以像测试任何异步函数那样测它们:传入属性调用、等待结果、断言渲染输出。不需要浏览器环境;如果你面对的是一个真实的测试数据库,也不需要模拟 fetch 或数据库调用。这种简洁是服务端组件被低估的好处之一:测试准备远比需要 DOM 环境的客户端组件轻。

import { describe, it, expect } from "vitest";
import { ProductList } from "./product-list";

describe("ProductList (Server Component)", () => {
  it("renders products from the database", async () => {
    // No mocking needed - the component calls the database directly
    const { props } = await ProductList({ category: "electronics" });

    expect(props.products.length).toBeGreaterThan(0);
    expect(props.products.every((p) => p.category === "electronics")).toBe(
      true
    );
  });

  it("shows empty state when no products match", async () => {
    const { props } = await ProductList({ category: "nonexistent" });

    expect(props.products).toHaveLength(0);
  });
});

使用 useState、useEffect、浏览器接口或事件处理器的客户端组件,需要一个 DOM 测试环境。Vitest 提供 happy-dom 或 jsdom 来模拟浏览器。Testing Library 提供查询渲染结果和模拟用户交互的工具。与服务端组件测试的关键差别在于:你需要渲染组件、等待副作用执行,然后断言最终的 DOM 状态。

Next.js 应用还带来了路由、数据获取和服务端动作,需要额外考量。对使用 getServerSideProps 或 generateStaticParams 的页面,把数据获取函数与页面组件分开测试。服务端动作就当作普通异步函数来测——它们本来就是。对 App Router 的布局和页面,把数据层与展示层分开测试,避免让测试与框架的渲染内部耦合。

import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckoutForm } from "./checkout-form";

describe("CheckoutForm", () => {
  it("shows validation errors for empty required fields", async () => {
    const user = userEvent.setup();

    render(<CheckoutForm />);

    await user.click(screen.getByRole("button", { name: "Submit" }));

    expect(screen.getByText("Email is required")).toBeVisible();
    expect(screen.getByText("Card number is required")).toBeVisible();
  });

  it("submits the form with valid data", async () => {
    const onSubmit = vi.fn();
    const user = userEvent.setup();

    render(<CheckoutForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText("Email"), "test@example.com");
    await user.type(screen.getByLabelText("Card Number"), "4242424242424242");
    await user.type(screen.getByLabelText("Expiry"), "12/28");
    await user.type(screen.getByLabelText("CVC"), "123");

    await user.click(screen.getByRole("button", { name: "Submit" }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "test@example.com",
      cardNumber: "4242424242424242",
      expiry: "12/28",
      cvc: "123",
    });
  });
});

新 React 模型中最重要的区分,是把服务端组件的测试与客户端组件的测试分开。服务端组件是纯异步函数——不需要 DOM 环境去测。客户端组件需要模拟用户交互——用 Testing Library 和 userEvent 去测。把两者混在一起,只会让你的测试比必要的更慢、更复杂。

模拟的取舍,以及何时不该模拟

模拟是测试中最具争议的话题之一。替身用受控的替代品顶替真实依赖,让你能在不搭建数据库、不调用外部接口、不等待定时器的情况下隔离地测试代码。代价是:你验证的是代码对依赖的模拟版本,而不是真家伙。如果模拟没有如实反映真实行为,测试通过了,真实代码却会失败。

一般准则是:在系统边界处做模拟。替换你无法掌控的外部服务(第三方接口、支付处理商、邮件服务),以及在测试中代价高昂或不确定的基础设施(数据库、文件系统、时钟)。不要去模拟内部模块、工具函数或你自己的领域对象——它们应当在集成测试中以真实实现接受检验。

Vitest 提供了 vi.mock 机制,它会把模拟声明提升到文件顶部,在测试运行前替换模块实现。这对环境变量、第三方 SDK 和全局对象很有用。风险在于:模块级模拟是隐形的,可能造成测试单独跑能过、一起跑却因相互干扰而失败。

import { describe, it, expect, vi } from "vitest";
import { PaymentService } from "./payment-service";

const mockStripe = {
  charges: {
    create: vi.fn(),
  },
};

vi.mock("stripe", () => ({
  default: vi.fn(() => mockStripe),
}));

describe("PaymentService", () => {
  it("creates a charge successfully", async () => {
    mockStripe.charges.create.mockResolvedValue({
      id: "ch_001",
      status: "succeeded",
    });

    const service = new PaymentService();
    const result = await service.charge(5000, "tok_visa");

    expect(result.status).toBe("succeeded");
    expect(mockStripe.charges.create).toHaveBeenCalledWith({
      amount: 5000,
      currency: "usd",
      source: "tok_visa",
    });
  });

  it("throws on failed payment", async () => {
    mockStripe.charges.create.mockRejectedValue(
      new Error("card_declined")
    );

    const service = new PaymentService();

    await expect(service.charge(5000, "tok_visa")).rejects.toThrow(
      "card_declined"
    );
  });
});

比模拟更可靠的替代方案,是实现相同接口、但用内存做简化实现的测试替身。与其模拟数据库模块,不如写一个把数据放进 Map 的内存版仓储。这个替身轻量却忠实,走的是与真实数据库相同的代码路径,却没有搭建成本。

每一次模拟,都在你的测试与现实之间撕开一道缝。缺陷就藏在那道缝里。在伸手去拿模拟之前先问一句:我能不能在受控环境里用真实依赖来测这件事?如果能,就用真的。如果不能——太慢、太贵、不确定——那就模拟,但要窄,只在系统边界上模拟。

  • 在系统边界处模拟:第三方接口、SDK、支付网关,以及无法在本地运行的外部服务。
  • 对自己的抽象(仓储、缓存、队列)使用内存替身,而不是去模拟它们。
  • 不要模拟不属于你的东西——模拟库的内部实现,会把测试与实现细节绑死。
  • 优先使用依赖注入,而不是模块级模拟。显式传入依赖,会让测试准备一目了然。
  • 把每个模拟的作用范围限制在需要它的那个测试里。测试之间重置所有模拟。

在 CI/CD 流水线中运行测试

测试集只有在稳定运行并快速反馈时才有价值。缓慢、不稳定或只在开发者机器上跑的测试是一种负担。把测试接入 CI/CD 流水线,能确保它们在每个拉取请求、每次并入主分支时运行,关键路径上则在每次发布前运行。

测试金字塔天然映射到流水线的各个阶段。单元测试最先跑,每次推送都跑,因为它们快且能抓出基本的逻辑错误。接着是集成测试,尽可能并行,配合容器化依赖。端到端测试放到最后,只在拉取请求和发布前跑,因为它们又慢又贵。视觉回归测试与端到端测试并行,把截图与主分支基准对比。

流水线的耗时很关键。一套要跑 45 分钟的测试,会促使开发者跳过本地测试、直接推上坏代码。让持续集成保持快速的办法包括:只跑受当前改动影响的测试、把测试集分散到并行执行器、在运行之间缓存 node_modules 和 Playwright 浏览器,以及把耗时的端到端测试改为定时运行而非每次提交都跑。

# .github/workflows/test.yml - example pipeline structure
name: test
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx vitest run --project unit

  integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test
          POSTGRES_PASSWORD: test
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx vitest run --project integration

  e2e:
    needs: [unit, integration]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

不稳定的测试是持续集成信任度的最大威胁。一个与代码改动无关、时不时就失败的测试,会侵蚀团队对整条流水线的信任。大家开始无视失败,在流水线红着的时候合并代码,最终整个测试集沦为噪音。投入精力去识别它们:标记、隔离,并优先修复或删除。一个不可信的测试比没有测试更糟,因为它培养出无视失败的习惯。

测试结果的呈现是另一项被低估的实践。一句「3 个测试失败」而没有上下文,会逼着开发者翻日志去找真正的错误信息。好的报告会显示失败的断言、期望值与实际值、文件与行号,以及截图或调用栈这类有用的上下文。Vitest 与 Playwright 的 HTML 报告,或 GitHub Actions 的注释,都能让结果直接呈现在拉取请求流程中。

奖杯与金字塔之争

传统的测试金字塔——底层是单元测试,中间是集成测试,顶端是端到端测试——引导了二十年的测试策略。它传达了一个简单的想法:写大量快速隔离的测试、较少较慢的集成测试,以及极少的慢速端到端测试。对于以函数或类为交付单元、输入输出边界清晰的后端服务,这个模型很合用。

作为替代模型,测试奖杯被提出来,更贴合现代前端开发。它把金字塔重塑成一个菱形:集成测试构成最宽的一层,单元测试与端到端测试都更少。理由是,对前端应用而言,最大的价值来自检验组件如何协同工作——这恰恰是集成测试所做的事——而不是测试孤立的函数或完整的用户旅程。

奖杯模型反映了一个实际情况:React 应用中最有价值的测试,是那些渲染组件、与之交互、再断言结果的测试。它们同时检验了组件、它的子组件、它的 hook、它的状态管理和它的接口调用。它们比端到端测试快,比单元测试更贴近真实。在前端场景下,一组写得好的集成测试,每个测试带来的信心都高于单元测试或端到端测试。

实际情况是,两个模型都把更复杂的真相简化了。正确的测试策略取决于你的应用架构。业务逻辑繁重的后端微服务适合金字塔:对领域逻辑做深度单元覆盖,为接口层写较少的集成测试,再为外部服务交互写少量契约测试。交互复杂的前端应用适合奖杯:对组件与页面做广泛的集成覆盖,对工具逻辑做有选择的单元测试,再为关键路径写端到端测试。

如果你教条地照搬,金字塔和奖杯都是错的。正确的测试策略,是那套能抓到你的缺陷、对你的团队足够快、并且熬得过你的架构的策略。它可能长得像金字塔,像奖杯,或者像一个还没人命名的形状。别再争论形状了,去看看你的测试到底抓到了什么。

两个模型背后真正重要的洞见是:不同层级的成本收益结构不同。写一个单元测试、跑一次,成本是毫秒级;集成测试是秒级;端到端测试是分钟级。你应当按照每一层对你这个具体应用所提供的价值,按比例分配测试预算。如果你的应用是业务逻辑复杂、数据密集的仪表盘,就投资单元与集成测试。如果是交互很少的内容站点,那么关键流程的端到端测试加上版式的视觉回归测试,可能才是回报最高的投入。

建立测试文化

如果团队本身不相信测试,再好的策略也会落空。测试文化不是靠强制覆盖率门槛或强推测试驱动开发建立的,而是靠营造一种环境:写测试成为阻力最小的自然选择。当测试容易写、跑得快、价值明显时,开发者自然会写。当测试脆弱、缓慢、动不动误报时,大家就会想办法绕开它们。

先把测试体验做到位。投资测试基础设施——快速的执行机、可靠的测试数据库、不稳定测试的检测机制。写一些测试工具,让常见断言写起来更简洁。把测试模式写进团队的测试指南,让每位开发者都知道如何测试一个新的接口、一个 React 组件或一次数据库迁移,而不必从零摸索。把好的测试基础设施建好一次的代价,远低于团队每天与糟糕测试搏斗所累积的代价。

代码评审应当包含对测试的评审。评审者要问:这个测试真的覆盖了它声称覆盖的行为吗?即使实现是错的,它会不会照样通过?它测的东西对不对,层级选得对不对?它可读、可维护吗?把测试当作一等公民的代码对待——与生产代码接受同样的评审标准、风格规范和质量要求。

覆盖率指标是好用的诊断工具,却是糟糕的目标。你若定下 90% 的覆盖率目标,你就会得到 90% 的覆盖率——而不是更好的测试。开发者会去给取值方法和空构造函数写些无关痛痒的测试来凑数字,安全网并没有变厚。用覆盖率报告去找没被测到的代码路径,而不是拿它来强制一个武断的门槛。位置得当的集成测试撑起的 70%,比浮于表面的单元测试堆出来的 95% 更有价值。

  • 提供测试工具、工厂函数和辅助方法,减少每个测试文件里的样板代码,让写测试变得容易。
  • 像庆祝功能交付一样庆祝测试上的改进。一个能消灭一整类缺陷的测试,本身就是一项功能。
  • 让测试基础设施的负责人轮换,使知识在团队中流动,而不是集中在一个人身上。
  • 定期开一次不稳定测试的分诊会。把不稳定测试的数量当作团队健康指标来跟踪,并优先修复。
  • 写一份团队的测试宣言,明确每一层该测什么、不该测什么,以及大家认可的取舍。

测试文化的终极目标,既不是某个覆盖率数字,也不是一条完美的流水线,而是信心。相信重构不会弄坏生产环境;相信周五下午发布是安全的;相信缺陷一旦被修复,就不会再回来。能给团队这份信心的测试策略,就是好策略——不管它看上去像金字塔、像奖杯,还是介于两者之间。

把它们串起来

建立有效的测试策略,不是在几种方法里挑一个,而是理解取舍,并为每种情形选用合适的工具。单元测试在毫秒内抓出逻辑错误。集成测试用真实依赖抓出交互缺陷。端到端测试在整个技术栈上抓出用户能看见的故障。基于属性的测试抓出你压根不知道存在的边界情况。视觉回归测试抓出无意间的外观变化。

能够稳定交付的团队,并不是覆盖率数字最高的团队。他们是认真思考过每一层测试能提供什么、让测试投入与自身风险画像相匹配,并建立起一种文化的团队——在那里写测试是因为它带来价值,而不是因为规章要求。先从盘点现有测试集开始。对每个测试问一句:它对得起自己的存在吗?如果答案不是明确的「是」,就删掉或替换它。最好的测试集不是最大的那一套,而是每分钟运行时间带来最多信心的那一套。

See what your own repository can account for.

Thirty minutes on a repository you choose, including the part the record cannot attribute.