敏捷团队中的测试节奏与准入准出¶
一、提测当天冒烟挂了¶
2026-09 迭代 D8 提测,Jenkins 构建日志原文:
[Pipeline] stage
[Pipeline] { (Smoke)
+ pytest -m smoke --maxfail=1 -q --junitxml=reports/smoke.xml
FAILED tests/smoke/test_order_submit.py::test_submit_with_coupon
E AssertionError: assert 500 == 200
E + where 500 = <Response [500]>.status_code
!!!!!! stopping after 1 failures !!!!!!
1 failed, 39 deselected in 4.21s
[Pipeline] }
ERROR: script returned exit code 1
Finished: FAILURE
服务端日志:
2026-09-14T10:12:03.771+08:00 ERROR 1 --- [http-nio-8080-exec-3] c.q.o.OrderService :
java.sql.SQLSyntaxErrorException: Unknown column 'coupon_id' in 'field list'
at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:1013)
根因不是代码:本次有 DB 脚本 V2.3.0__add_coupon_id.sql,开发只在自己库执行了,测试环境没跑。结果是提测即阻塞,整条流水线停摆,发布推迟 2 天。
流程上的漏洞有三个:提测单没写 DB 变更、环境没做版本校验、冒烟失败没有第一时间定位到环境问题。下面把这套节奏和门禁补齐。
二、双周迭代测试排期(按天)¶
以 2026-10-08 至 10-21 的迭代为例,D1 为迭代首日,D10 为发布日:
| 天 | 输入 | 测试活动 | 产出 | 门禁 |
|---|---|---|---|---|
| D1 | 需求文档 v1.2 | 需求评审、AC 确认、风险矩阵 | 问题清单、风险等级、测试范围 | 阻塞问题 >0 则需求不进开发 |
| D2 | 接口草稿、原型 | 用例设计(等价类/判定表/状态机)、测试数据准备 | 用例初稿、数据构造脚本 | 用例评审覆盖率 ≥90% |
| D3 | 用例初稿 | 用例评审、接口契约评审 | 用例基线、OpenAPI diff | breaking change 未声明则接口不许开发完 |
| D4 | 开发分支 | 接口测试脚本、自动化用例补齐 | 自动化脚本、Mock 数据 | 脚本本地跑通 |
| D5 | 联调环境 | 联调支持、接口冒烟、缺陷跟踪 | 接口测试报告、缺陷列表 | 冒烟通过率 100% |
| D6 | 提测包(可选) | 提前介入核心链路,发现阻塞项 | 预提测问题清单 | P0 级问题当日反馈 |
| D7 | 提测包 | 冒烟、准入检查 | 冒烟报告、准入结论 | 准入不通过直接打回 |
| D8 | 提测包 | 功能测试、专项(兼容/权限/异常) | 执行记录、缺陷 | P0/P1 当日清零计划 |
| D9 | 修复包 | 回归(变更点 + 关联模块)、性能抽检 | 回归报告 | 回归通过率 100% |
| D10 上午 | 候选包 | 发布评审、发布检查单、回滚演练 | 测试报告、Go/No-Go 结论 | 准出标准全部满足 |
| D10 下午 | 生产 | 发布、灰度观察、线上验证 | 线上验证记录 | 30 分钟黄金观察期无异常 |
| D11 | 线上数据 | 24 小时观察、逃逸缺陷登记 | 观察记录 | 指标越界触发回滚 |
排期不是拍脑袋,测试窗口要反算:
可用测试人天 = 测试人数 × (D8 到 D10 的天数) × 0.8(会议/沟通损耗)
用例执行人天 = 用例总数 × 单条执行中位数(从历史执行记录取,不要用估算)
若 用例执行人天 > 可用测试人天 → 砍范围或提前到 D6 介入,不能压缩回归
一个真实的反算例子:本迭代 186 条用例,单条执行中位数 4.5 分钟,含缺陷复验与数据准备的实际系数 1.6,总工作量 186 × 4.5 × 1.6 / 60 ≈ 22.3 人时;2 名测试 × 3 天 × 8 小时 × 0.8 = 38.4 人时,余量 16 人时用于专项与回归。余量低于 20% 就要预警,而不是等到 D9 加班。
D9 必须留 0.5 天 buffer。所有迭代都把回归排在发布前最后半天,等于把风险堆在最后一刻。
三、准入标准:每条都要能自动判定¶
| # | 条目 | 阈值 | 检查方式 | 不满足处理 |
|---|---|---|---|---|
| 1 | 构建可部署且有版本号 | 镜像 tag 与提测单一致 | kubectl get deploy -o jsonpath |
打回 |
| 2 | 冒烟用例通过 | 100%,0 failed | JUnit XML failures="0" |
打回 |
| 3 | 未关闭 P0/P1 | 0 | JQL 查询 total=0 | 打回 |
| 4 | 新增代码单测覆盖率 | ≥60% | JaCoCo 增量报告 | 打回或限期补 |
| 5 | 接口契约 | 无未声明的 breaking change | oasdiff breaking |
打回 |
| 6 | DB 变更脚本 | 已在测试环境执行且幂等 | 版本表比对 | 打回 |
| 7 | 配置变更 | 已登记键名/默认值/生效范围 | 配置清单 | 打回 |
| 8 | 自测说明 | 开发自测场景 ≥5 条并附结果 | 提测单字段 | 打回 |
| 9 | 已知问题 | 列明且经产品确认 | 提测单字段 | 可提测但需标注 |
| 10 | 测试数据与账号 | 可用,含边界数据 | 数据准备脚本执行成功 | 打回 |
一条命令跑完前 3 项:
#!/usr/bin/env bash
# ci/admission.sh <version>,退出码非 0 即不准提测
set -euo pipefail
VERSION="${1:?usage: admission.sh v2.3.0}"
IMG=$(kubectl -n test get deploy order-service -o jsonpath='{.spec.template.spec.containers[0].image}')
[[ "$IMG" == *"$VERSION"* ]] || { echo "FAIL 镜像版本不一致: $IMG"; exit 1; }
[[ -f reports/smoke.xml ]] || { echo "FAIL 缺少冒烟报告"; exit 1; }
grep -q 'failures="0"' reports/smoke.xml || { echo "FAIL 冒烟存在失败用例"; exit 1; }
OPEN=$(curl -s -u "$JIRA_USER:$JIRA_TOKEN" \
"https://jira.qalab.cn/rest/api/2/search?jql=project=QALAB%20AND%20fixVersion=$VERSION%20AND%20priority%20in%20(P0,P1)%20AND%20status!=Closed" \
| jq '.total')
[[ "$OPEN" -eq 0 ]] || { echo "FAIL 未关闭 P0/P1: $OPEN 条"; exit 1; }
echo "PASS 准入检查通过: $VERSION"
脚本的意义是把「打回」变成系统行为,而不是测试的个人态度。一次真实的打回比十次口头提醒有效。
四、准出标准:发布前逐项签字¶
| # | 条目 | 阈值 | 证据 | 责任人 |
|---|---|---|---|---|
| 1 | P0/P1 | 0 | JQL 截图/导出 | 测试 |
| 2 | P2 | 有处理计划(版本+owner) | DDR 清单 | 产品 |
| 3 | 核心用例执行率 | 100% | 测试管理工具报告 | 测试 |
| 4 | 用例通过率 | ≥95%(末次执行口径) | 执行报告 | 测试 |
| 5 | 回归范围 | 覆盖全部变更点及关联模块 | 影响分析表 | 测试 |
| 6 | 性能 | P95 ≤ 基线 × 1.1,错误率 <0.1% | 压测报告 | 性能 |
| 7 | 安全 | 无高危,中危有豁免单 | 扫描报告 | 安全 |
| 8 | 回滚 | 演练通过,耗时 ≤10 分钟 | 演练记录 | 运维 |
| 9 | 灰度 | 方案、比例、观察指标明确 | 发布方案 | 开发/运维 |
| 10 | 监控告警 | 核心接口与业务指标均有告警 | 监控截图/配置 | 运维 |
「通过率 ≥95%」必须写清口径,否则同一个版本能算出 3 个数:
- 分母用「计划用例数」:被跳过的用例拉低通过率,适合对外汇报进度。
- 分母用「已执行用例数」:适合判断质量,但会掩盖「该测的没测」。
- 「末次执行」与「任一次通过」:重跑通过算不算通过?必须写「末次执行结果」。
统一成一句:通过率 = 末次执行通过数 / 已执行数,分母不含未执行,未执行单独报执行率。
五、提测单模板¶
# 提测单:order-service v2.3.0
## 基本信息
- 版本/镜像:v2.3.0 / harbor.qalab.cn/qalab/order-service:20261014-a1b2c3d
- commit:a1b2c3d(分支 release/2.3)
- 提测人 / 日期:@dev-li / 2026-10-14 10:00
- 测试环境:test-cluster / namespace qalab-test
## 变更清单
| 类型 | 内容 | 影响模块 |
| --- | --- | --- |
| 功能 | 订单支持部分退款 | order / refund |
| 接口 | POST /orders/{no}/refund 新增 items 字段 | order-api |
| DB | V2.3.0__add_refund_item.sql(新增表 refund_item) | order-db |
| 配置 | refund.partial.enabled=true | refund-service |
## DB 变更
- 脚本路径:db/migration/V2.3.0__add_refund_item.sql
- 是否已在测试环境执行:是(2026-10-14 09:40,执行人 @dev-li)
- 是否幂等:是(CREATE TABLE IF NOT EXISTS)
- 回滚脚本:db/rollback/V2.3.0__drop_refund_item.sql
## 配置变更
| key | 默认值 | 生效范围 | 是否需重启 |
| --- | --- | --- | --- |
| refund.partial.enabled | false | 全部实例 | 否(配置中心热更新) |
## 开发自测
| # | 场景 | 结果 |
| --- | --- | --- |
| 1 | 全额退款成功 | 通过 |
| 2 | 部分退款(单品) | 通过 |
| 3 | 重复回调 | 通过(幂等) |
| 4 | 金额超审批阈值 | 通过 |
| 5 | 券不退回 | 通过 |
## 已知问题
| # | 问题 | 影响 | 产品确认 |
| --- | --- | --- | --- |
| 1 | 退款通知模板沿用旧文案 | 展示层 | @pm-zhang 已确认可后置 |
## 回滚方案
- 开关:refund.partial.enabled=false(5 分钟内生效)
- 镜像回滚:kubectl rollout undo deploy/order-service -n test
- 数据:refund_item 表保留,不回滚(只增不删)
提测单缺 DB 变更、缺回滚方案,直接退回。提测单是开发对「我测过什么」的承诺,不是形式。
六、冒烟门禁与自动化卡点¶
pytest 标记与冒烟集¶
# 冒烟:失败即停,输出 JUnit 供 CI 判定
pytest -m smoke --maxfail=1 -q --junitxml=reports/smoke.xml
# 回归:按变更模块筛
pytest -m "regression and (order or refund)" -q --junitxml=reports/regression.xml
Jenkinsfile 卡点¶
pipeline {
agent any
options { timeout(time: 40, unit: 'MINUTES') }
stages {
stage('Build') {
steps { sh 'mvn -B -DskipTests package' }
}
stage('UnitTest') {
steps { sh 'mvn -B test jacoco:report' }
post {
always { junit '**/target/surefire-reports/*.xml' }
}
}
stage('CoverageGate') {
steps {
sh 'mvn -B jacoco:check -Djacoco.haltOnFailure=true'
}
}
stage('Smoke') {
steps { sh 'pytest -m smoke --maxfail=1 -q --junitxml=reports/smoke.xml' }
post {
failure { error '冒烟失败,构建不可提测' }
}
}
stage('Contract') {
steps { sh 'oasdiff breaking api/old.yaml api/new.yaml' }
}
stage('Sonar') {
steps {
withSonarQubeEnv('qalab-sonar') {
sh 'mvn -B sonar:sonar -Dsonar.qualitygate.wait=true'
}
}
}
}
post {
failure {
mattermostSend color: 'danger',
message: "构建失败: ${env.JOB_NAME} #${env.BUILD_NUMBER} ${env.BUILD_URL}"
}
}
}
sonar.qualitygate.wait=true 会让流水线一直等到质量门禁结果,超时默认 5 分钟;门禁不通过时构建失败,不需要再写额外判断。
卡点分级¶
| 卡点 | 阻断级别 | 例外处理 |
|---|---|---|
| 编译失败 | 必须阻断 | 无 |
| 单测失败 | 必须阻断 | 无 |
| 冒烟失败 | 必须阻断 | 无 |
| 增量覆盖率 <60% | 阻断合并,不阻断提测 | 技术负责人审批后可豁免 |
| breaking change | 阻断 | 需版本号升级并通知客户端 |
| Sonar 新增 blocker | 阻断 | 无 |
| Sonar 新增 critical | 告警 | 记录技术债,下迭代清 |
七、回归范围怎么选¶
三层回归,不是每次都跑全量:
| 层 | 范围 | 触发时机 | 耗时目标 |
|---|---|---|---|
| L1 冒烟 | 核心链路 40 条 | 每次构建 | ≤10 分钟 |
| L2 变更回归 | 变更模块 + 关联模块 | 每个提测包 | ≤2 小时 |
| L3 全量回归 | 全部 P0/P1 + 跨模块 | 发布前 | ≤1 个工作日 |
L2 的范围由变更影响分析得出。先建立「目录 → 用例标记」映射:
# ci/module_markers.yaml
src/refund: refund
src/order: order
src/coupon: coupon
src/payment: payment
再用脚本从 git diff 自动推导:
# ci/select_regression.py,Python 3.11 + PyYAML 6.0
import subprocess, sys, yaml
BASE = sys.argv[1] if len(sys.argv) > 1 else "origin/release/2.3...HEAD"
mapping = yaml.safe_load(open("ci/module_markers.yaml", encoding="utf-8"))
files = subprocess.check_output(
["git", "diff", "--name-only", BASE], text=True
).splitlines()
hit = sorted({marker for f in files for prefix, marker in mapping.items() if f.startswith(prefix)})
if not hit:
print("regression and p0")
else:
expr = " and ".join(["regression"] + [f"({m})" for m in hit])
print(expr)
MARKER=$(python ci/select_regression.py)
echo "本次回归表达式: $MARKER"
pytest -m "$MARKER" -q --junitxml=reports/regression.xml
风险加权决定「必测 / 抽测」:
| 维度 | 权重 | 判定 |
|---|---|---|
| 变更直接命中 | 3 | git diff 包含该模块 |
| 近 3 个版本有 P0/P1 | 2 | JQL 统计 |
| 核心链路(下单/支付/退款) | 3 | 业务标注 |
| 强依赖(被 3 个以上服务调用) | 2 | 调用链拓扑 |
| 本次有配置/DB 变更 | 2 | 提测单 |
得分 ≥6 必测全量用例;3~5 抽测 P0 + 关键分支;≤2 只跑冒烟。
八、缺陷收敛曲线怎么读¶
一个迭代的真实日数据:
| 日期 | 新增 | 关闭 | 累计未关闭 | 其中 P0/P1 |
|---|---|---|---|---|
| D5 | 18 | 6 | 12 | 3 |
| D6 | 15 | 9 | 18 | 2 |
| D7 | 12 | 14 | 16 | 2 |
| D8 | 9 | 15 | 10 | 1 |
| D9 | 5 | 11 | 4 | 0 |
| D10 上午 | 2 | 3 | 3 | 0 |
读法(三条硬判定):
- D-3 起新增必须单调下降。 表中 D8 起 9→5→2,符合。若 D9 新增反弹到 12,说明提测包质量差或范围失控,发布应推迟。
- 关闭数必须连续 3 天大于新增数。 D7~D9 满足;只有一天交叉不算收敛,可能只是批量关闭。
- 发布前剩余缺陷 ≤ 团队阈值且 P0/P1=0。 阈值按历史逃逸率定,例如「剩余 ≤8 且 P0/P1=0」。
三种异常形态:
| 形态 | 数据特征 | 真实原因 | 处理 |
|---|---|---|---|
| 尾翘 | 最后 2 天新增突然上升 | 提测包反复变更、回归发现新问题 | 推迟发布,先冻结变更 |
| 断崖式关闭 | 某天关闭数 = 累计未关闭(一次清零) | 批量改状态、把 P2 降级为 P3 | 抽查 20 条结案记录,核对级别与验证记录 |
| 锯齿 | 关闭后重开率 >15% | 修复不彻底、验证不按相邻分支回归 | 强制回归断言,重开计入开发质量 |
每日数据用 JQL 按创建/解决时间统计:
project = QALAB AND fixVersion = "2.3.0" AND created >= "2026-10-08" AND created < "2026-10-15" ORDER BY created ASC
project = QALAB AND fixVersion = "2.3.0" AND resolved >= "2026-10-08" AND resolved < "2026-10-15"
导出 CSV 后用数据透视表按日期计数,不要手工数。
九、发布检查清单¶
- 准出 10 项全部有证据,测试报告已发
- 发布窗口与变更冻结时间确认(发布前 2 小时禁止合并)
- 灰度比例、观察指标、放量节奏明确
- 回滚开关可用,演练耗时 ≤10 分钟
- 数据库变更可向前兼容(新代码读旧数据不报错)
- 监控面板已建,告警阈值已配(错误率、P95、核心业务量)
- 值班人、升级路径、通讯群已确认
- 发布后 30 分钟黄金观察:错误率、P95、订单量、退款量
- 24 小时观察记录归档,逃逸缺陷已登记
十、hotfix 流程¶
触发条件只有两类:线上 P0/P1,或监管/合规要求。其他一律走正常迭代。
git fetch origin
git checkout -b hotfix/2.3.1-coupon-threshold origin/release/2.3
# 修改 + 补一条能复现的最小单测
git add src/main/java/com/qalab/coupon/CouponCalculator.java \
src/test/java/com/qalab/coupon/CouponCalculatorTest.java
git commit -m "fix(coupon): 门槛比较由 gt 改为 gte,修复 100.00 不抵扣"
git push -u origin hotfix/2.3.1-coupon-threshold
# CI 通过 + 双人 review 后打 tag
git tag -a v2.3.1 -m "hotfix: coupon threshold"
git push origin v2.3.1
发布后必须回合,否则下个版本会把 bug 带回来:
git checkout release/2.3
git merge --no-ff hotfix/2.3.1-coupon-threshold
git push origin release/2.3
git checkout main
git cherry-pick -x v2.3.1
git push origin main
hotfix 的准出比正常发布多三条:
| 额外要求 | 判定 |
|---|---|
| 最小改动 | diff 只含缺陷相关文件,无顺手重构 |
| 复现用例入库 | 新增单测/接口用例能在旧版本失败、新版本通过 |
| 回归最小集 | 冒烟 40 条 + 缺陷相邻分支 5 条,全部通过 |
「能在旧版本失败」这一步常被跳过,结果是修了但没修对,或者修了另一个问题。验证方式:git stash 改动后跑新用例,必须红。
十一、测试环境冲突的真实处理¶
现象:两个 feature 同时部署到 test,A 的用例在 B 部署后全部 500;或者数据被另一个人的脚本 TRUNCATE 掉。
[10:32] qa-wang: 订单详情又 500 了,谁在发版?
[10:33] dev-li : 我发了个 refund 分支到 test,怎么了?
[10:34] qa-wang: 我这条用例跑的是 order 的旧包,现在被覆盖了
四种处理方式,按成本从低到高:
| 方案 | 做法 | 适用 | 代价 |
|---|---|---|---|
| 部署锁 | CI 里对 test 环境加互斥锁 | 环境少、发布不频繁 | 排队等待 |
| 环境分层 | dev(每人)/ test(共享)/ staging(准生产) | 大多数团队 | 维护成本 |
| 动态环境 | 每个 feature 一个 namespace | 微服务、K8s | 资源成本 |
| 数据隔离 | 每人独立 schema 或数据前缀 | 单体、共享 DB | 脚本改造成本 |
部署锁用 Jenkins Lockable Resources:
lock(resource: 'test-env') {
sh 'kubectl -n qalab-test set image deploy/order-service order-service=$IMAGE'
sh 'kubectl -n qalab-test rollout status deploy/order-service --timeout=180s'
}
动态环境:
kubectl create namespace qalab-feat-2231
kubectl -n qalab-feat-2231 apply -f deploy/
kubectl -n qalab-feat-2231 get pod -w
# 用完即删,避免资源泄漏
kubectl delete namespace qalab-feat-2231
本地 compose 用项目名隔离,但端口仍要区分:
docker compose -p feat2231 -f compose.test.yml up -d
docker compose -p feat2231 ps
docker compose -p feat2231 down -v
数据隔离最容易被忽略:共享库里用 tenant 或前缀区分,测试脚本清理数据时必须带范围条件,禁止裸 DELETE FROM orders。约定写进用例基类:
# conftest.py:所有测试数据带本次运行的 run_id 前缀
import uuid, pytest
@pytest.fixture(scope="session")
def run_id():
return f"qa{uuid.uuid4().hex[:8]}"
@pytest.fixture
def order_factory(run_id):
created = []
def _make(**kwargs):
order = create_order(remark=run_id, **kwargs)
created.append(order["orderNo"])
return order
yield _make
for no in created:
cancel_order(no) # 只清理自己创建的
十二、站会上测试该说什么¶
测试在站会的发言控制在 60 秒、三句话:
- 昨天完成了什么(带数字,不说「测了一些」)
- 今天做什么(带时间点和范围)
- 有什么阻塞(具体到人、事、时间)
正面例子:
昨天:退款模块执行 42 条,通过 38 条,提了 4 个缺陷,2 个 P1 已复验通过。
今天:上午跑变更回归(order + refund),下午做审批阈值边界和重复回调。
阻塞:测试环境 refund-service 还是昨天的包,需要 @dev-li 今天 10 点前重新部署,否则回归延后半天。
反面例子与问题:
| 说法 | 问题 | 改法 |
|---|---|---|
| 「昨天测了一下退款」 | 无数字,无法判断进度 | 报执行数/通过数/缺陷数 |
| 「今天继续测」 | 无范围无时间点 | 报模块 + 计划完成时间 |
| 「有些问题」 | 无法行动 | 具体到接口、包版本、需要谁做什么 |
| 「环境不稳定」 | 泛泛而谈 | 哪个服务、什么报错、影响哪条用例 |
十三、坑位表¶
| 坑 | 表现 | 根因 | 处理 |
|---|---|---|---|
| 提测标准只写在文档 | 每次都说「下次一定」 | 没有自动判定 | 用 admission.sh 卡流水线 |
| 冒烟集越来越大 | 提测要跑 40 分钟 | 把回归用例塞进 smoke | smoke ≤40 条、≤10 分钟,超出移到 regression |
| 回归永远跑全量 | 每次 1 天,还是漏 | 没有影响分析 | git diff + 模块标记,三层回归 |
| 缺陷「批量关闭」 | 发布前突然清零 | 赶进度改状态 | 抽查结案记录,重开率纳入复盘 |
| 环境互相覆盖 | 用例随机 500 | 共享 test 无锁 | 部署锁或动态 namespace |
| hotfix 不回合 | 下版本 bug 复现 | 只改 release 分支 | tag + merge + cherry-pick 三件套 |
| 站会变成汇报会 | 15 分钟还没到测试 | 无时间盒 | 每人 60 秒,阻塞会后单聊 |