跳转至

敏捷团队中的测试节奏与准入准出

一、提测当天冒烟挂了

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 标记与冒烟集

# pytest.ini
[pytest]
markers =
    smoke: 冒烟,提测门禁
    regression: 回归
    p0: 核心链路
    slow: 超过 60 秒
# 冒烟:失败即停,输出 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
本次回归表达式: regression and (order) and (refund)

风险加权决定「必测 / 抽测」:

维度 权重 判定
变更直接命中 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

读法(三条硬判定):

  1. D-3 起新增必须单调下降。 表中 D8 起 9→5→2,符合。若 D9 新增反弹到 12,说明提测包质量差或范围失控,发布应推迟。
  2. 关闭数必须连续 3 天大于新增数。 D7~D9 满足;只有一天交叉不算收敛,可能只是批量关闭。
  3. 发布前剩余缺陷 ≤ 团队阈值且 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 秒、三句话:

  1. 昨天完成了什么(带数字,不说「测了一些」)
  2. 今天做什么(带时间点和范围)
  3. 有什么阻塞(具体到人、事、时间)

正面例子:

昨天:退款模块执行 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 秒,阻塞会后单聊

相关阅读