test_report_metrics.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. # -*- coding: utf-8 -*-
  2. """CFC 测试度量工具(纯只读)—— 聚合 surefire + 逐例归因 + 场景/用例覆盖。
  3. 用途:让每轮测试报告的数字可复现(B-15 度量脚本丢失、B-32 口径漂移)。
  4. 用法:
  5. # 1) 聚合 + 归因(后端 surefire 报告目录)
  6. python test_report_metrics.py --reports <cfc-backend>/target/surefire-reports --out-json agg.json
  7. # 2) 场景/用例覆盖(仓库根)
  8. python test_report_metrics.py --repo-root <repo> --scene
  9. 口径(写死在代码里,跨轮必须一致):
  10. - 场景 = user-scenarios/*.md 中 `##`/`### 场景 <ID>:` 标题
  11. - TC = test-cases/*.md 中 `| TC-xxx |` 表格行
  12. - 异常流 TC = 编号含 `-N<数字>` 或 类型列含 异常/边界/负向/权限
  13. - 场景绑定 = 场景 ID 或其映射 TC 编号出现在测试资产中
  14. - 环境红 = 堆栈/消息命中 ApplicationContext/JDBC 连接类关键字
  15. - 恒等式 = @Test − 类级@Disabled 内用例 + surefire 占位 + Σ(参数化展开−1) = 执行数
  16. """
  17. import argparse
  18. import glob
  19. import io
  20. import json
  21. import os
  22. import re
  23. import xml.etree.ElementTree as ET
  24. ENV_PAT = re.compile(
  25. r"Failed to load ApplicationContext|Communications link failure|Connection refused|"
  26. r"Could not create connection|Access denied for user|Unknown database|"
  27. r"CannotGetJdbcConnection|Unable to acquire JDBC Connection|Failed to instantiate.*ApplicationContext",
  28. re.S,
  29. )
  30. MOCK_TYPES = {"MockitoException", "InvalidUseOfMatchersException", "WantedButNotInvoked",
  31. "ArgumentsAreDifferent", "UnnecessaryStubbingException"}
  32. def read(p):
  33. with io.open(p, encoding="utf-8", errors="ignore") as f:
  34. return f.read()
  35. def xml_flag(txt, key):
  36. m = re.search(key + r'="(\d+)"', txt)
  37. return int(m.group(1)) if m else 0
  38. def simple(name):
  39. return (name or "").split(".")[-1]
  40. def collect_surefire(reports_dir):
  41. """XML 为主、TXT 兜底;返回 {类简单名: stats}。"""
  42. stats = {}
  43. for p in glob.glob(os.path.join(reports_dir, "TEST-*.xml")):
  44. txt = read(p)
  45. try:
  46. root = ET.parse(p).getroot()
  47. except Exception:
  48. continue
  49. cls = simple(root.get("name") or os.path.basename(p)[5:-4])
  50. rec = {"tests": xml_flag(txt, "tests"), "failures": xml_flag(txt, "failures"),
  51. "errors": xml_flag(txt, "errors"), "skipped": xml_flag(txt, "skipped"),
  52. "env": bool(ENV_PAT.search(txt)), "time": 0.0}
  53. m = re.search(r'time="([0-9.]+)"', txt)
  54. if m:
  55. rec["time"] = float(m.group(1))
  56. prev = stats.get(cls)
  57. if prev is None or rec["tests"] > prev["tests"]:
  58. stats[cls] = rec
  59. else:
  60. prev["env"] = prev["env"] or rec["env"]
  61. for p in glob.glob(os.path.join(reports_dir, "*.txt")):
  62. cls = simple(os.path.basename(p)[:-4])
  63. if cls in stats:
  64. continue
  65. txt = read(p)
  66. m = re.search(r"Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)", txt)
  67. if not m:
  68. continue
  69. stats[cls] = {"tests": int(m.group(1)), "failures": int(m.group(2)),
  70. "errors": int(m.group(3)), "skipped": int(m.group(4)),
  71. "env": bool(ENV_PAT.search(txt)), "time": 0.0}
  72. return stats
  73. def source_classes(backend_root):
  74. out = set()
  75. for p in glob.glob(os.path.join(backend_root, "src", "test", "java", "**", "*.java"), recursive=True):
  76. fn = os.path.basename(p)
  77. if fn.endswith(("Test.java", "Tests.java", "IT.java")):
  78. out.add(fn[:-5])
  79. return out
  80. def identity(backend_root, executed):
  81. """恒等式校验:base = @Test − 类级@Disabled 内用例 + 占位;delta 应为参数化净增。
  82. 注意:@Disabled 必须紧贴类声明(注解块内)才算「类级」——方法级 @Disabled
  83. (如 EnergySystemFlowTest:69)会自带 1 条 skipped,不参与 base 扣减。
  84. """
  85. test_cnt = 0
  86. param_cnt = 0
  87. disabled_inner = 0
  88. placeholders = 0
  89. disabled_classes = []
  90. for p in glob.glob(os.path.join(backend_root, "src", "test", "java", "**", "*.java"), recursive=True):
  91. txt = read(p)
  92. n_test = len(re.findall(r"^\s*@Test\b", txt, re.M))
  93. test_cnt += n_test
  94. param_cnt += len(re.findall(r"^\s*@ParameterizedTest\b", txt, re.M))
  95. m = re.search(r"^\s*(?:public\s+|final\s+|abstract\s+)*class\s+(\w+)", txt, re.M)
  96. if m:
  97. head = txt[:m.start()]
  98. # 类声明前最后一个非注解语句之后的注解块
  99. block = "\n".join(head.split("\n")[-40:])
  100. if re.search(r"^\s*@Disabled\b", block, re.M):
  101. disabled_inner += n_test
  102. placeholders += 1
  103. disabled_classes.append(m.group(1))
  104. base = test_cnt - disabled_inner + placeholders
  105. return {"at_test": test_cnt, "parameterized_methods": param_cnt,
  106. "disabled_classes": disabled_classes,
  107. "disabled_inner": disabled_inner, "placeholders": placeholders,
  108. "base_executed": base, "actual_executed": executed,
  109. "param_expansion_net": executed - base}
  110. def attribute(reports_dir):
  111. rows = []
  112. T = re.compile(r"at (com\.etotem\.cfc\.[\w.]+)\.(\w+)\(")
  113. for p in glob.glob(os.path.join(reports_dir, "TEST-*.xml")):
  114. try:
  115. root = ET.parse(p).getroot()
  116. except Exception:
  117. continue
  118. cls = simple(root.get("name"))
  119. for tc in root.iter("testcase"):
  120. nodes = list(tc.findall("failure")) + list(tc.findall("error"))
  121. if not nodes:
  122. continue
  123. n = nodes[0]
  124. body = n.text or ""
  125. frames = [l.strip() for l in body.split("\n") if l.strip().startswith("at ")]
  126. prod = []
  127. for f in frames:
  128. m = T.search(f)
  129. if m and not m.group(1).split(".")[-1].endswith(("Test", "Tests", "IT")):
  130. prod.append(f)
  131. typ = (n.get("type") or "").split(".")[-1]
  132. rows.append({"class": cls, "case": tc.get("name"),
  133. "kind": "F" if n.tag == "failure" else "E", "type": typ,
  134. "msg": ((n.get("message") or "").strip() or (body.split("\n")[0] if body else ""))[:200],
  135. "prod_frames": len(prod)})
  136. return rows
  137. def scene_metrics(repo_root):
  138. scenes = []
  139. scene_re = re.compile(r"^#{2,3}\s*场景\s*([A-Z]{1,3}-\d+)", re.M)
  140. per = {}
  141. for p in sorted(glob.glob(os.path.join(repo_root, "tests", "user-scenarios", "*.md"))):
  142. ids = scene_re.findall(read(p))
  143. per[os.path.basename(p)] = len(ids)
  144. scenes += ids
  145. tcs = []
  146. for p in sorted(glob.glob(os.path.join(repo_root, "tests", "test-cases", "*.md"))):
  147. for line in read(p).splitlines():
  148. m = re.match(r"^\|\s*(TC-[A-Z]{1,4}-[0-9A-Za-z\-]+?)\s*\|", line)
  149. if m:
  150. cols = [c.strip() for c in line.strip().strip("|").split("|")]
  151. tcs.append({"id": m.group(1), "scene": cols[1] if len(cols) > 1 else "",
  152. "type": cols[5] if len(cols) > 5 else ""})
  153. def is_exc(t):
  154. return bool(re.search(r"-N\d+", t["id"])) or any(k in t["type"] for k in ("异常", "边界", "负向", "权限"))
  155. mapped = set()
  156. for t in tcs:
  157. for sid in re.findall(r"[A-Z]{1,3}-\d+", t["scene"]):
  158. mapped.add(sid)
  159. # 测试资产编号索引
  160. toks = set()
  161. globs = [repo_root + r"\cfc-backend\src\test\java\**\*.java",
  162. repo_root + r"\cfc-frontend\__tests__\**\*.js",
  163. repo_root + r"\cfc-web\src\**\*.spec.js", repo_root + r"\cfc-web\src\**\*.test.js",
  164. repo_root + r"\tests\**\*.spec.js", repo_root + r"\cfc-langgraph\tests\**\*.py"]
  165. assets = 0
  166. for g in globs:
  167. for p in glob.glob(g, recursive=True):
  168. if "node_modules" in p:
  169. continue
  170. assets += 1
  171. for m in re.finditer(r"(?<![A-Za-z0-9])(TC-[A-Z0-9]{1,6}(?:-[A-Za-z0-9]{1,8}){0,4})(?![0-9A-Za-z])", read(p)):
  172. toks.add(m.group(1))
  173. spec_txt = "\n".join(read(p) for p in glob.glob(repo_root + r"\tests\**\*.spec.js", recursive=True))
  174. lit = [s for s in scenes
  175. if re.search(r"(?<![A-Za-z0-9])" + re.escape(s) + r"(?![0-9A-Za-z])", spec_txt)]
  176. n = len(scenes)
  177. exc = sum(1 for t in tcs if is_exc(t))
  178. return {
  179. "scenes": n, "per_scenario_file": per,
  180. "scenes_mapped_to_tc": len([s for s in scenes if s in mapped]),
  181. "scene_mapped_ratio": round(len([s for s in scenes if s in mapped]) * 100.0 / n, 1) if n else 0,
  182. "scenes_literal_in_spec": len(lit),
  183. "scene_spec_ratio": round(len(lit) * 100.0 / n, 1) if n else 0,
  184. "tc_total": len(tcs), "tc_exception": exc,
  185. "tc_exception_ratio": round(exc * 100.0 / len(tcs), 1) if tcs else 0,
  186. "tc_with_asset_anchor": len([t for t in tcs if t["id"] in toks]),
  187. "assets_scanned": assets,
  188. }
  189. def main():
  190. ap = argparse.ArgumentParser()
  191. ap.add_argument("--reports", help="surefire-reports 目录")
  192. ap.add_argument("--repo-root", help="仓库根(用于恒等式与场景度量)")
  193. ap.add_argument("--scene", action="store_true", help="输出场景/用例覆盖")
  194. ap.add_argument("--out-json")
  195. a = ap.parse_args()
  196. out = {}
  197. if a.reports:
  198. st = collect_surefire(a.reports)
  199. tot = {"classes": len(st), "tests": 0, "failures": 0, "errors": 0, "skipped": 0, "time": 0.0}
  200. reds = []
  201. for cls, s in st.items():
  202. for k in ("tests", "failures", "errors", "skipped", "time"):
  203. tot[k] += s[k]
  204. if s["failures"] + s["errors"]:
  205. reds.append({"class": cls, "F": s["failures"], "E": s["errors"], "env": s["env"], "tests": s["tests"]})
  206. env_red = sum(r["F"] + r["E"] for r in reds if r["env"])
  207. passed = tot["tests"] - tot["failures"] - tot["errors"] - tot["skipped"]
  208. out["backend"] = dict(tot, passed=passed,
  209. pass_rate=round(passed * 100.0 / tot["tests"], 2) if tot["tests"] else 0,
  210. pass_rate_ex_env=round((passed + env_red) * 100.0 / tot["tests"], 2) if tot["tests"] else 0,
  211. red_classes=len(reds), red_cases=tot["failures"] + tot["errors"],
  212. env_red_cases=env_red, nonenv_red_cases=tot["failures"] + tot["errors"] - env_red,
  213. time_sum_s=round(tot["time"], 1),
  214. reds=sorted(reds, key=lambda x: -(x["F"] + x["E"])))
  215. out["attr"] = attribute(a.reports)
  216. if a.repo_root:
  217. b = os.path.join(a.repo_root, "cfc-backend")
  218. out["identity"] = identity(b, tot["tests"])
  219. out["silent_missing"] = sorted(source_classes(b) - set(st))
  220. if a.scene and a.repo_root:
  221. out["scene"] = scene_metrics(a.repo_root)
  222. txt = json.dumps(out, ensure_ascii=False, indent=1)
  223. if a.out_json:
  224. with io.open(a.out_json, "w", encoding="utf-8") as f:
  225. f.write(txt)
  226. b = out.get("backend")
  227. if b:
  228. print("classes=%d tests=%d pass=%d F=%d E=%d S=%d rate=%.2f%% (ex-env %.2f%%) red_classes=%d red_cases=%d"
  229. % (b["classes"], b["tests"], b["passed"], b["failures"], b["errors"], b["skipped"],
  230. b["pass_rate"], b["pass_rate_ex_env"], b["red_classes"], b["red_cases"]))
  231. if "identity" in out:
  232. print("identity:", out["identity"])
  233. if "silent_missing" in out:
  234. print("silent_missing=%d %s" % (len(out["silent_missing"]), out["silent_missing"][:5]))
  235. if "scene" in out:
  236. print("scene:", {k: v for k, v in out["scene"].items() if k != "per_scenario_file"})
  237. if __name__ == "__main__":
  238. main()