本页定位 · Software Testing & QA

"能跑"不等于"对"。测试是把"我觉得没问题"变成"有证据没问题"的 discipline。本页覆盖单元/集成/端到端测试、TDD、Mock、契约测试、性能压测、自动化与 CI、质量门禁,并以 pytest / JUnit 5 / Playwright / k6 为例。学完你能在任何项目里搭起一条"改一行代码就有测试兜底"的安全网,并且知道"测多少、测到哪一层"才是划算的。

1

测试类型与金字塔:钱该花在哪一层

Test Pyramid · 分层策略 · TDD

测试不是越多越好,而是分层配比。经典"测试金字塔":底层大量单元测试(快、便宜),中层适量集成测试,顶层少量端到端(E2E)测试(慢、贵)。倒金字塔(E2E 一堆)会又慢又脆。这一章先把"有哪些测试、各自测什么、怎么配"讲清。

测试金字塔:钱该花在哪一层

金字塔的核心直觉是:越靠近代码底层,测试越便宜、越快、越稳;越往上越贵、越慢、越容易因环境问题随机失败。所以把保障重心放在底层,上层只覆盖最核心的用户路径。

层测什么速度/成本占比工具例
单元 Unit单个函数/类快/低70%+pytest、JUnit 5
集成 Integration模块间协作、数据库中/中20%Testcontainers
端到端 E2E整条用户路径慢/高5–10%Playwright、Cypress

论为什么反对"倒金字塔"

如果反着来——几百个 E2E、几乎没单测——每次改一行都要等几十分钟、还经常因为"测试环境抖了一下"而红。团队会逐渐不信任测试、手动跳过,安全网就废了。金字塔宁可多写单测,也别堆 E2E。

测试的全景:不止单元/集成/E2E

金字塔讲的是"功能测试"的分层。完整质量网还包括静态分析、契约、性能、混沌。别把它们混为一谈——每种回答不同问题。

测试类型回答的问题何时跑
静态分析 / Lint代码风格、明显坏味道每次提交(最快)
单元测试这个函数逻辑对不对每次提交
集成测试拼起来协作对不对每次提交 / 合并前
契约测试服务间接口约定变没变每次提交(消费者+提供方)
E2E 测试用户真实路径通不通合并后 / 预发
性能 / 压测扛不扛得住流量预发 / 定期

TDD:先写测试再写码

TDD(测试驱动开发)把"测试"从"写完才补"变成"写码之前先写"。流程是红 → 绿 → 重构:先写一个会失败的测试(红),写刚好让它通过的最少实现(绿),再在测试保护下重构。

# 红:先写测试,此时 calc_price 还没实现,运行必失败 def test_discount(): assert calc_price(100, coupon="8折") == 80 # 绿:最小实现,刚好通过 def calc_price(price, coupon=None): if coupon == "8折": return price * 0.8 return price # 重构:抽掉魔法字符串、加类型、补边界……测试护着不敢乱动

论TDD 的真正价值不是"测试更多"

而是逼你把接口设计得可测——可测的代码天然低耦合、依赖清晰。先红后绿再重构,是小步快跑时的安全绳:每走一步都有红绿反馈,不会写着写着发现"这坨东西根本测不了"。

验收测试与回归测试:从"对不对"到"别搞坏"

两种常见视角:验收测试(Acceptance)站在业务角度验证"需求满足了吗"(常由 BDD 工具如 Cucumber 表达成"Given/When/Then");回归测试(Regression)则是把历史上所有发现过的 bug 固化成测试,保证"以前修好的别再犯"。金字塔里的每一层,最终都会沉淀为回归集的一部分。

论回归集是"会越长越慢"的

每个 bug 修完都该补一条测试。久而久之单测可能上千条——这正是金字塔的意义:单测快,几千条也能在几分钟内跑完;若都写成 E2E,CI 会慢到没人愿意跑。

怎么给团队定测试配比:风险驱动

金字塔是"默认推荐",但不是铁律。钱该花在"最容易错、错了最贵"的地方:支付/权限/核心算法要多写单测;UI 交互细节用少量 E2E 兜底即可。配比是可以按模块风险调整的,别机械照搬 70/20/10。

新手最常犯:把金字塔当 KPI

为了"看起来比例对"硬凑单测数量、或硬塞 E2E 凑占比,结果测试既慢又没价值。配比服务于"信心成本比",而不是数字本身。

静态分析与 Lint:最便宜的第一道关

功能测试之前,还有一道几乎零成本的质量关:静态分析。它在不运行代码的情况下揪出风格问题、未使用变量、明显坏味道,CI 里几秒跑完,是金字塔"尖"下面的地基。

# ruff(Python)一行扫描整个项目,比跑测试快得多 ruff check . # 风格 + 可疑写法 ruff check . --fix # 能自动修的顺手修了 # 配进 pre-commit,提交前本地就拦,不浪费 CI

论静态分析是"免费的安全网"

它不验证业务正确,但能挡住大量低级错误和风格争议("该用 tabs 还是 spaces"交给工具,别在 PR 里吵)。把它放在金字塔最底层、跑得最快,性价比最高。

BDD 与验收标准:测试从需求来

单测验证"函数对",但"函数对"不等于"需求满足"。BDD(行为驱动开发)用 Given/When/Then 把验收标准写成可执行用例,让产品、开发、测试说同一套语言。

# 用自然语言描述验收(Cucumber 式),背后绑定步骤实现 场景: 八折价下单 假如 购物车有一件 100 元商品 当 用户使用"8折"优惠券结算 那么 应付金额为 80 元

论验收标准先对齐,再写码

很多返工源于"需求理解不一致"。把验收写成可执行的 BDD 场景,开发前就和产品的预期对齐——场景过了,需求就满足了,扯皮少一大半。

2

单元测试:Mock、覆盖率、边界与参数化

Unit Test · AAA · Test Doubles · Coverage

好单测遵循 AAA 结构:Arrange(准备)→ Act(执行)→ Assert(断言)。这一章讲清"单测到底测什么、怎么隔离外部依赖、覆盖率怎么读、边界和异常路径怎么覆盖",并给出 pytest 与 JUnit 5 两套示例。

AAA 结构:好单测长什么样

把测试体清晰分成三段:准备数据与环境、执行被测函数、断言结果。读起来像"给定…当…那么…",一眼能懂在测什么。

# pytest:一个"折扣计算"的 AAA 单测 def test_discount(): # Arrange:准备输入 price = 100 # Act:执行被测逻辑 result = calc_price(price, coupon="8折") # Assert:断言结果 assert result == 80

论一个测试只断言一件事

别在一个测试里塞十几个 assert 覆盖所有分支。一个测试聚焦一个行为,失败时才知道"到底哪坏了"。需要覆盖多分支,就写多个测试,名字说清场景。

测试替身家族:Dummy / Stub / Mock / Spy / Fake

"测试替身(Test Double)"是统称。它们长得像真实依赖,实际是替身。分清五种,才知道该用哪个。

替身干什么典型用途
Dummy纯占位,从不调用凑构造函数参数
Stub返回预制答案制造"依赖现在返回 X"
Mock预设期望 + 验证调用验证"是否调了一次 charge"
Spy真做事 + 记录行为记录发了几封邮件
Fake轻量但真实实现内存版数据库(更快)
# pytest-mock:Spy 记录真实调用次数,Mock 验证交互 def test_notify(mocker): mailer = mocker.Mock() checkout(order, mailer) # 真跑一遍业务逻辑 mailer.send.assert_called_once() # Mock:确实发了一封 assert mailer.send.call_args[0][0] == order.user.email

参数化测试:用一份逻辑测多种输入

同样的逻辑、不同输入输出,别复制粘贴十遍测试。参数化让一份测试体跑多组数据,哪组挂了一目了然。

# pytest 参数化:一组逻辑覆盖折扣边界 import pytest @pytest.mark.parametrize("price,coupon,expect", [ (100, "8折", 80), (0, "8折", 0), # 边界:0 元 (100, None, 100), # 无券不打折 (-5, "8折", None), # 负价应抛异常(见下节) ]) def test_calc(price, coupon, expect): if expect is None: with pytest.raises(ValueError): calc_price(price, coupon) else: assert calc_price(price, coupon) == expect

边界值与异常路径:单测最易漏的地方

新手爱测 happy path(输入正常、返回正常),但真正的 bug 藏在边界和异常里:空值、0、负数、超长字符串、依赖抛错时我怎么处理。这些才是单测该重点覆盖的。

# 不仅要测"成功",还要测"依赖挂了我会不会优雅失败" def test_pay_gateway_down(mocker): gw = mocker.Mock() gw.charge.side_effect = TimeoutError("gateway timeout") with pytest.raises(PaymentFailed): checkout(gw) # 期望我封装成自己的业务异常 # 而不是让 TimeoutError 直接炸到用户面前
只测 happy path 的假安全感

覆盖率 90% 但全是正常输入,等于"只验证了不会错的路径"。异常分支、边界值才是线上出事的高频区,务必显式覆盖。

覆盖率怎么读:行覆盖 / 分支覆盖 / 突变覆盖

覆盖率不是一个数,分几层。理解差别才不会误读报告。

覆盖率类型含义局限
行覆盖 Line多少行被执行过执行了 ≠ 断言对了
分支覆盖 Branchif/else 各分支是否都走仍不保证逻辑正确
突变覆盖 Mutation故意改代码,测试能否变红最严,但慢
# pytest-cov:看分支覆盖,并标出没覆盖的行 pytest --cov=app --cov-branch --cov-report=term-missing

JUnit 5 示例:同一套思想换 Java

金字塔和 AAA 是语言无关的。下面是 JUnit 5 版本,注意 @ParameterizedTest 和断言风格与 pytest 一一对应。

// JUnit 5:参数化 + 异常断言 class DiscountTest { private final PriceService svc = new PriceService(); @ParameterizedTest @CsvSource({"100, 8折, 80", "0, 8折, 0", "100, NONE, 100"}) void calc(int price, String coupon, int expect) { assertEquals(expect, svc.calc(price, coupon)); } @Test void negativePriceThrows() { assertThrows(IllegalArgumentException.class, () -> svc.calc(-5, "8折")); } }

Fixture 与测试隔离:准备一次,多处复用

多个测试都要"一个干净的临时库 / 一个已登录用户",重复写很累。Fixture 把"准备+清理"抽出来,测试只关心自己那点逻辑,且每个测试独立、互不污染。

# pytest fixture:建一个临时库,测试完自动回收 @pytest.fixture def client(db): db.seed(users=[User(name="u")]) # 准备 yield make_client(db) # 给测试用 db.clean() # 清理(yield 后执行) def test_profile(client): assert client.get("/me").json()["name"] == "u"

论隔离 = 可重复

fixture 的清理让每个测试"从同一起点出发",顺序无关、重复无关。这正是前面说的"可重复 = 可信任"——CI 随机跑、单独跑都该稳。

3

集成、E2E 与契约测试

Integration · E2E · Contract · Pact

单测保证"零件对",但零件拼起来可能对不上。集成测试验证"模块拼起来对不对"(如 API + 真实数据库);E2E 模拟用户点真实跑一遍;契约测试(Contract Test)专门守护"服务间接口约定"——消费者和提供方各自校验同一份契约,避免联调才爆雷。

集成测试:真连数据库,但用容器

集成测试不能 Mock 掉数据库(那还测什么集成),但也不能依赖"某台共享测试库"。最佳实践是每次测试起一个真实数据库容器(Testcontainers),用完即毁,环境 100% 干净可重现。

# pytest + testcontainers:每个测试套件起一个真实 Postgres import pytest from testcontainers.postgres import PostgresContainer @pytest.fixture(scope="session") def db(): with PostgresContainer("postgres:16") as pg: yield create_engine(pg.get_connection_url()) def test_order_persist(db): repo = OrderRepo(db) order_id = repo.create(Order(total=100)) assert repo.get(order_id).total == 100 # 真写真读

论为什么不用共享测试库

共享库会被别人/别的 CI 跑的数据污染,出现"我本地绿的、CI 红的"玄学。容器化让每次运行都从空库开始,确定性 = 可信任。

待测依赖怎么管:数据库 / HTTP 客户端

集成层要连的"外部"不止数据库,还有下游 HTTP 服务。两种策略:给下游也起容器(如 mock-server),或用一个 可许诺固定响应的 Stub 服务。原则是:集成测试验证"我和真实依赖的协议对不对",而不是"下游算得对不对"(那是下游自己的单测)。

依赖推荐做法原因
数据库Testcontainers 起真库验证 SQL/事务/类型映射
消息队列起内存或容器版验证发布/订阅语义
下游 HTTPWireMock 返回固定响应测我的容错,不依赖对方

E2E 测试:让浏览器替用户点一遍

E2E 从用户视角跑完整路径(登录→加购→下单→支付),最贴近真实,但也最慢最脆。用 Playwright 之类工具驱动真实浏览器。

# Playwright:一条"下单"端到端路径 def test_checkout_flow(page): page.goto("/login") page.fill("#email", "u@x.com") page.fill("#pwd", "secret") page.click("text=登录") page.click("text=加入购物车") page.click("text=去结算") assert page.locator(".order-no").is_visible()
E2E 最易"脆"的三个坑

① 用固定等待 sleep(3) 而非等元素出现,环境一慢就红。② 选择器绑死文案/样式,改个按钮字全红,用稳定的 data-testid。③ 依赖共享账号状态,并行跑互相污染。E2E 只覆盖最核心路径,别贪多。

契约测试:消费者驱动的接口守护

微服务里"我改个字段,你挂了"是常态。契约测试让消费者定义"我需要你返回什么",提供方在 CI 里校验"我满足这个契约吗"。双方各自跑,谁破坏约定谁红,不用等联调。

// Pact(JS 消费者侧):写下我对提供方的期望 const provider = new Pact({ consumer: "web", provider: "order-api" }); await provider.addInteraction({ state: "订单 100 存在", uponReceiving: "获取订单详情", withRequest: { method: "GET", path: "/orders/100" }, willRespondWith: { status: 200, body: { id: 100, total: 100 } }, }); const order = await OrderApi.get(100); expect(order.total).toEqual(100); await provider.verify(); // 生成契约文件,供提供方校验

论为什么微服务尤其要契约测试

服务多、团队多,单方面改接口容易让调用方线上故障。契约测试把"接口兼容性"前移到 CI:消费者说"我要这些字段"、提供方说"我给得了",任一破坏立刻失败。与 Spring Cloud Contract / Pact 对应,省去大量联调扯皮。

三类测试怎么排进一条流水线

不是所有测试每次都全跑。典型节奏:提交时跑单测(秒级)、合并前跑集成+契约(分钟级)、预发环境跑 E2E(分钟到十分钟)。这样反馈快、又不全靠慢测试兜底。

测试触发时机期望耗时
单测 + 静态每次 push< 2 分钟
集成 + 契约开 PR / 合并前2–10 分钟
E2E合并后 / 预发5–15 分钟

数据库迁移也要测:别让 schema 变更炸生产

改了模型不加迁移测试,常常"本地能跑、生产一迁移就锁表/报错"。把迁移跑在测试库上、再验证应用能正常读写,是集成层常被漏掉的一环。

# 在 CI 里:起空库 → 跑迁移 → 跑测试 pytest --migrate # 先 alembic upgrade head pytest # 再跑全套,验证迁移后模型对得上 # 还要单独测"向后兼容":旧版本读新 schema 不崩(滚动更新时新旧并存)
破坏性迁移是发布杀手

删列、改类型这类"破坏性迁移"会让滚动更新期间旧 Pod 连新库直接 500。做法:分两步走(先加新列兼容、再下个版本删旧列),或设维护窗口。迁移前先在预发库演练。

提供方如何验证契约:双向才完整

消费者写完契约只是半边。提供方要在 CI 里拉取这份契约、启动自己的服务、用 Pact 校验"我确实满足它"。只有双方都绿,契约才算守住。

# 提供方侧(Java 例):拿消费者契约,对我真实服务跑一遍 @PactVerification(fragment = "web") # 对应 web 消费者定义的契约 void verifyOrderContract() { // Pact 自动起 mock,按契约发请求到我的 /orders/100,比对响应 }

论契约测试的闭环

消费者说"我要这些字段"→ 提供方说"我给得了"→ 任一方改坏,对方 CI 立刻红。比"等联调环境两边都部署好才发现问题"早了整整一个周期。

4

性能与压测:基准、吞吐、延迟与瓶颈定位

Benchmark · Throughput · Latency · Profiling

功能对了,不代表扛得住流量。这一章讲"性能测试有哪些种类、吞吐和延迟怎么度量(P95/P99 是什么)、怎么写负载脚本、怎么定位瓶颈"。例子用 k6 与 JMH/pytest-benchmark。

性能测试的种类:别只说"压一下"

"压测"是个模糊词,不同目的用不同打法。先想清楚你要测什么。

种类目的特征
基准 Benchmark单请求有多快极小并发、求稳定
负载 Load预期流量下稳不稳渐进加压到目标
压力 Stress极限在哪、怎么挂加压到崩溃
浸泡 Soak长时间是否泄漏中负载跑几小时

吞吐量与延迟:P50 / P95 / P99 是什么

吞吐量(Throughput)是单位时间处理请求数(如 1000 RPS);延迟(Latency)是单个请求耗时。平均延迟会骗人——1% 的请求卡 5 秒,平均还是好看。所以看分位值:P95 表示"95% 的请求快于这个值",P99 才是长尾用户体验。

论看 P99,别只看平均

一个 API 平均 50ms、P99 却 3000ms,意味着每 100 个用户就有 1 个等到抓狂——这恰恰是客诉和流失的来源。SLA 通常按 P95/P99 写,而不是平均。

# PromQL:最近 5 分钟请求延迟的 P95 / P99(秒) histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))

用 k6 写负载脚本

k6 用 JS 写场景,能渐进加压、测出系统在多少并发下开始劣化。下面模拟"逐步从 10 加到 100 虚拟用户"。

// k6 负载脚本:ramp-up 渐进加压 import http from 'k6/http'; export const options = { stages: [ { duration: '1m', target: 10 }, // 热身 { duration: '3m', target: 100 }, // 加压 { duration: '1m', target: 0 }, // 回落 ], thresholds: { 'http_req_duration': ['p(95)<500'] }, // P95 必须 <500ms }; export default function() { http.get('https://api.example.com/orders'); }

基准测试:函数级快慢

想知道"这个算法到底多快、改完有没有变慢",用基准工具而非手掐表。JMH(Java)和 pytest-benchmark(Python)会排除 JVM 预热、GC 噪声,给出可信数字。

# pytest-benchmark:直接测一个函数,自动统计均值/方差 def test_parse(benchmark): result = benchmark(parse, "a=1&b=2&c=3") assert result["a"] == "1" # 跑:pytest --benchmark-only,会输出 min/max/mean/标准差
// JMH(Java):@Benchmark 标注,JMH 负责预热与多次采样 @Benchmark public int measureParse(Blackhole bh) { return parse("a=1&b=2").size(); }

瓶颈定位:先量再猜

系统慢,别急着"我觉得是数据库"。标准动作:看监控定位是 CPU / 内存 / IO / 锁哪类资源吃紧 → 抓火焰图看热点函数 → 看慢查询日志 → 定位到具体代码。每一步都要有证据。

论 profiling 的两大误区

① 凭直觉优化:花三天优化了个只占 0.1% 时间的函数。② 只看平均:平均 CPU 不高但 P99 卡,往往是 GC 停顿或锁竞争。先用火焰图/pprof 找到最宽的"柱子"再动手。

压测常见误区

压测是在自己骗自己

① 压测环境与生产差太多:单机压出"能扛 1 万",上线还是挂。② 只看吞吐不看错误率:请求全 500 了,吞吐再高也没用。③ 没热身:JIT 没编译、连接池没建好,前几秒数字失真。④ 客户端先成瓶颈:一台机器发不出那么大压力,要分布式压测。

容量规划与拐点:何时该扩容

压测的价值不止"扛不扛得住",更在于找到拐点(knee):吞吐量加到某点后延迟陡增、错误率上升——那就是系统上限。容量规划就是"按业务峰值预留余量到拐点之前"。

信号含义动作
延迟随并发线性涨还在舒适区观察即可
延迟陡增、错误率抬头逼近拐点扩容 / 优化
吞吐不再涨甚至掉已过拐点饱和立即扩容 + 限流

论留 30% 余量

别把容量压到"刚好够峰值"。突发流量、节点故障、邻居争抢都会吃掉余量。经验上按峰值的 1.3–2 倍规划,配合 HPA(见 tech-devops 页)自动兜底。

5

自动化与 CI:流水线、并行、失败重试、环境

CI Pipeline · Parallel · Flaky · Environments

测试写一次,要每次提交都自动跑才有意义。把测试塞进 CI(GitHub Actions / GitLab CI),并处理好并行加速、flaky(偶发失败)重试、测试环境一致性。这一章给可抄的配置。

把测试塞进 CI 流水线

最小可用:每次 push/PR 自动装依赖、跑测试、覆盖率不达标就红。下面是 GitHub Actions 示意。

# .github/workflows/test.yml on: [push, pull_request] jobs: test: runs-on: ubuntu-latest services: # CI 里直接起依赖容器 postgres: image: postgres:16 env: { POSTGRES_PASSWORD: test } ports: ["5432:5432"] steps: - uses: actions/checkout@v4 - run: pip install -r requirements.txt - run: pytest --cov=app --cov-fail-under=80

论CI 是"可执行的质量门禁"

把规则写进 CI,就等于让它替你盯着:谁提交让覆盖率跌破阈值、谁引入一个失败的测试,立刻红给所有人看。比"大家自觉跑测试"可靠得多。

并行与分片:让 30 分钟变 5 分钟

测试多了,串行跑会拖垮反馈速度。把用例按文件/按名字哈希分片(shard)到多个机器并行跑,再汇总。

# pytest 按分片跑:把全集切成 4 份,每份一台机器 pytest --numprocesses=4 # 进程级并行(pytest-xdist) # 或 CI 矩阵分片 pytest --splits=4 --group="${{ matrix.group }}"

失败重试与 flaky 处理

偶发失败(flaky)最伤信任:明明代码没病,测试却红,团队慢慢就"红也合并吧"。应对策略:先定位 flaky 根因(超时太紧、共享状态、时间戳依赖),实在短期的再有限重试,但重试次数要记下来单独追。

# pytest-rerunfailures:仅作临时缓冲,必须配套 flaky 清单 pytest --reruns 1 --reruns-delay 2 # 更推荐:把已知 flaky 标出来,单独跑、单独修 pytest -m "not flaky" # 主流水线跳过 flaky pytest -m "flaky" --reruns 3 # 单独跟踪
无脑加重试 = 埋雷

把 retry 当万能药,flaky 就永远修不好,还掩盖真实失败。重试只能是"止血",必须配合把 flaky 降到 0 的专项。健康的项目 flaky 率应趋近 0。

测试环境:用容器保证一致

“我本地是好的” 来自环境不一致。用 docker-compose 一键拉起应用+依赖,CI 和本地用同一份,差异归零。

# docker-compose.test.yml:测试专用环境 services: app: build: . environment: DATABASE_URL: postgres://test:test@db:5432/app depends_on: [db] db: image: postgres:16 environment: { POSTGRES_PASSWORD: test }

质量门禁:覆盖率与复杂度阈值

门禁不止覆盖率。常见几道:单测覆盖率下限、新增代码必须覆盖、圈复杂度上限、重复率、安全扫描。都过了才放行合并。

# 覆盖率门禁 + 禁止未覆盖的新代码 pytest --cov=app --cov-branch --cov-fail-under=80 # Sonar 类工具还能加: # 圈复杂度 > 15 的函数禁止合并 # 重复代码块 > 20 行告警

测试数据管理:别互相踩

并行跑最怕测试间共享数据。原则:每个测试自己造数据、自己清理(或用事务回滚),或用带随机后缀的隔离命名空间,避免"A 测试删了 B 测试刚建的订单"。

论可重复 = 可信任

测试乱序、单独跑、重复跑都应得到同样结果(确定性)。做不到确定性,CI 就不可信,团队就会绕过它。

门禁之外:依赖与许可证扫描

质量门禁不只"测试过没过"。两个常被漏的:依赖漏洞扫描(你引的库有没有已知 CVE)和许可证合规(别不小心引入 GPL 这类传染型协议,法务会找你)。

# 依赖审计 + 许可证检查,并入 CI 门禁 pip-audit # 查依赖已知漏洞 # 或:npm audit / safety check / trivy fs . license-checker --summary # 列出所有依赖的许可证,禁止黑名单项

论安全左移的一部分

漏洞和合规问题越晚发现越贵——上线前夜才发现用了违规协议,得连夜换库。把它们和单测一起卡在 CI 里,发现即拦截,成本最低。

6

测试策略与质量门禁:风险驱动、回归、度量

Strategy · Risk-Driven · Quality Gates

测试不是写完就完。这一章讲怎么定全局策略(风险驱动)、回归集怎么长、质量怎么度量、金字塔失衡有什么信号、测试左移/右移、以及用突变测试验证"测试本身有没有用"。

风险驱动测试策略

不是所有代码平等。把"出错概率 × 出错代价"画成矩阵,右上角(高频出错且代价高)重点测,左下角(工具类、几乎不变)少测甚至不测。

象限例子策略
高频错·高代价支付、权限、计费密集单测 + 集成 + 契约
高频错·低代价表单校验适度单测
低频错·高代价灾备切换E2E + 演练
低频错·低代价内部工具函数少量或不测

论把测试预算花在刀刃上

测试也是成本。资源有限时,先补"最怕它出事"的地方。与其 100% 覆盖一个静态配置解析器,不如把精力放在"钱算对没"。

回归测试集怎么维护

每修一个 bug,就加一条测试把它钉死。回归集随项目增长——这是好事,但也要定期清理过时/重复用例,并对慢用例做分层(快集常跑、慢集定时跑)。关键是:每条回归测试都对应"一个曾真实发生过的失败"。

质量度量:覆盖率之外的指标

只盯覆盖率会跑偏。更健康的度量组合:

指标说明健康信号
覆盖率代码被执行比例是下限,不是目标
Flaky 率偶发失败占比趋近 0
测试耗时CI 跑完多久分钟级、可降
线上漏测率线上 bug 中"本可被测出"的比例持续下降
别为覆盖率而写测试

为了凑数字写 assert True 式假测试,既拖慢 CI 又制造安全感幻觉。每条测试都应能"在 bug 出现时变红"——测不出错的测试,等于没测。

金字塔失衡的信号

团队常说"我们测试很多",但其实是失衡的。几个红旗:CI 跑一次 40 分钟、E2E 经常因环境问题红、改一行要等半天、flaky 率高。说明 E2E 太多、单测太少,该把保障重心下移。

论失衡的根因常是"不好测"

单测少,往往不是人不写,而是代码耦合重、依赖直接 new 在内部、没法注入。所以"补单测"常常是"重构到可测"的契机——又回到 TDD 那句:可测即低耦合。

测试左移与右移

左移:在开发阶段就写测试、做静态分析、本地跑(越早发现问题越便宜)。右移:上线后用线上监控、影子流量、金丝雀发布继续验证(呼应 tech-devops 的可观测与发布策略)。测试不是"交付前一道关",而是贯穿全生命周期。

突变测试:测"测试本身"有没有用

覆盖率再高,也可能"跑了但没断言"。突变测试故意把代码改坏(如把 > 改成 <、把 + 改成 -),看你的测试会不会变红。不变红 = 这条测试发现不了这个 bug。

# mutmut(Python)或 pitest(Java):跑突变测试 mutmut run # 自动改坏代码,看哪些测试仍能绿(= 没用) # 报告会列出"幸存突变":这些代码改动没被任何测试捕获 # 健康项目:突变分数(killed / total)应 > 80%

论突变测试是"测试质量的试金石"

它回答了一个覆盖率答不了的问题:"我的测试真能抓住 bug 吗?"引入成本高、慢,适合对核心模块定期跑,而不是每次 CI 都跑。

把策略写进团队约定

再好的策略不落地也是空谈。把它写进 CONTRIBUTING / 架构决策记录(ADR):单测覆盖率门槛多少、E2E 覆盖哪些路径、flaky 怎么处理、新服务必须接哪些监控。新人按文档就能对齐,评审也有据可依。

论约定优于口头默契

"大家都应该写测试"这种话没人真执行。写成可检查的规则(CI 门禁 + 文档),质量才不依赖个人自觉。测试文化不是靠倡导师傅,是靠把门槛焊进流水线。

结
本页要点

测试用金字塔配比:大量单测(AAA 结构、TDD 红绿重构、参数化、边界与异常路径)+ 适量集成(Testcontainers 起真库)+ 少量 E2E(Playwright,只覆盖核心路径);用 Stub/Mock/Spy/Fake 隔离外部边界但别 Mock 内部实现;微服务用消费者驱动契约测试(Pact)守护接口;性能测试分清基准/负载/压力/浸泡,看 P95/P99 而非平均,用 k6/JMH 打、用火焰图定位瓶颈;把测试接入 CI,靠并行分片加速、靠重试+flaky 清单止血、靠容器统一环境、靠门禁卡质量;覆盖率只是下限,突变测试才验证测试本身有效,策略上风险驱动、左移右移结合。

自测 · 看你是否真懂

1.为什么"测试金字塔"反对大量 E2E 测试?

查看答案

E2E 启动慢、依赖多、易因环境问题随机失败(脆),维护成本高。金字塔主张把保障重心放在快而廉价的单测上,E2E 只覆盖最核心的用户路径,反馈才快、才可信。

2.Mock / Stub / Spy / Fake 各有什么区别?该 Mock 什么?

查看答案

Stub 给预制答案、Mock 验证调用交互、Spy 真做事并记行为、Fake 是轻量真实实现。只 Mock 真正的外部边界(网络、DB、时间、随机),别 Mock 内部实现——否则测试只是在复述自己代码长啥样,重构就全红。

3.为什么看延迟要看 P95/P99 而不是平均值?

查看答案

平均值会掩盖长尾:1% 请求卡 5 秒,平均仍好看,但每 100 个用户就有 1 个体验极差,正是客诉来源。P99 反映最差的那批用户体验,SLA 通常按分位值写。

4.flaky(偶发失败)测试该怎么处理?无脑加重试行不行?

查看答案

不行。重试只能临时止血,会掩盖真实失败、让团队不信任 CI。正确做法:先定位根因(超时太紧、共享状态、时间依赖),把 flaky 降到 0;确实短期修不了的,单独标记、单独跑、单独追,不让它污染主流水线。

5.为什么微服务特别强调契约测试?它和集成测试有何不同?

查看答案

服务/团队多,单方改接口易让调用方线上故障。契约测试让消费者定义所需字段、提供方在 CI 校验是否满足,双方各自跑、谁破坏谁红。集成测试验证"我和真实依赖协议对不对",契约测试验证"服务间接口约定变没变",互补而非替代。

下一步往哪走

路学完测试之后

① 配 CI:去 FDE 模块 02 把测试塞进流水线,设覆盖率与 flaky 门禁。

② 串可观测:测试防"引入 bug",监控防"漏掉 bug",二者互补(FDE 模块 08 / tech-devops 页)。

③ 进阶:性能测试、混沌工程(故意制造故障)、Mutation Testing(测测试本身有效性)——本页都已点到,可深入。