Files
FinDataHub/Scripts/bin/run_all_tests.py
T
FinDataHub cd076018ba test: 统一测试入口 + 真实 MySQL 集成测试
## 统一入口 run_all_tests.py

跑一次全量测试要记住四件事,任何一条忘了结论就是错的:
1. 每套件用哪个 venv(findatahub_core 只装在部分 venv 里)
2. 必须注入 PYTHONPATH,否则验的是已安装的旧版本
3. Scripts/bin/tests 需要 httpx,选错 venv 在收集阶段直接报错
4. **不能并行**——两个 pytest 会互踩系统临时目录,报 pytest-of-root
   EEXIST 假失败(表现为几十个 errors,看着像大回归)

摩擦本身就是回归风险的来源,固化成脚本。四个设计点:
- 解释器按能力选:每套件声明 requires,扫描 Services/*/.venv 挑第一个
  满足的;挑不到 exit 2 并列出缺什么,绝不用错的 venv 硬跑出假结果
- 强制注入 PYTHONPATH,源码目录排最前
- **每套件独立 --basetemp**(.pytest-cache/<suite>/):从根上消除 tmpdir
  争用,不依赖"记得串行",也不去删系统临时目录
- 摘要提取要求「数字+状态词」相邻且 stdout 优先,避免把
  (Background on this error at: sqlalche.me/...) 当成统计行

实测 6 套件 14.9s;加 --include-swift --include-frontend 共 8 套件 37s。
**重要发现**:scripts 从 205 → 252——多的 47 条是 test_rss_fetcher.py,
此前因 akshare venv 缺 httpx 一直被 --ignore 跳过。这批用例一直存在且可用,
只是从没在本机跑过。

## 真实 MySQL 集成测试

findatahub-core/tests/integration/,8 条用例。

为什么 fake 不够:test_db_replace_guard.py 用 Fake 游标验了代码路径,但
Fake 验不了三件事——事务是否真的回滚(Fake 的 rollback 只是置标志位)、
跨 chunk 失败是否留下半成品(Fake 不会真提交,这个场景永远复现不出来)、
熔断时表是否真的没动(需真实读行数)。

隔离:每次 fixture 新建 fdh_it_replace_<随机8位> 空库,用完 DROP;
库名带前缀且 DROP 前再断言。不读现有配置、不碰现有库。
无 MYSQL_PASSWORD 时整组 skip,CI 无 MySQL 不会变红。

**证伪验证**:把 replace() 临时改回「每 chunk 提交」旧行为,该套件报
「回滚不完整:表内行数既不是旧值 1000,说明留下了半成品数据」→ 测试有牙。
恢复后复跑 8 passed,生产库行数前后一致(news_flash 368346 /
stock_daily 8158172),无 fdh_it_% 残留。

--with-integration 开关默认关闭(要真实库且会建临时库)。
2026-10-26 20:55:52 +08:00

473 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# coding=utf-8
"""统一测试入口 —— 一条命令跑全部套件.
## 为什么需要它
本机开发有 5 个独立 venv(各服务的 bundle 部署需要),跑一次全量测试要:
1. 记住每个套件该用哪个解释器(`findatahub-core` 只装在部分 venv 里);
2. 记住 `findatahub_core` 以**非 editable** 方式装进各 venv,
不注入 `PYTHONPATH` 就会验到**已安装的旧版本**,源码改动不生效;
3. 记住 `Scripts/bin/tests` 里 `test_rss_fetcher.py` 需要 `httpx`,
选错 venv 会在收集阶段直接报错;
4. 记住 **不能并行跑两个 pytest 进程**——它们会互踩系统临时目录,
报 `FileExistsError: pytest-of-root`,表现为几十个 errors 的假回归。
这四条中任何一条被忘记,得到的结论就是错的。摩擦本身就是回归风险的来源,
所以把它固化成脚本。
## 关键设计
- **每个套件独立 `--basetemp`**:放进项目内 `.pytest-cache/<suite>/`,
从根上消除 tmpdir 争用,不依赖"记得串行"。也不去删系统临时目录。
- **解释器按能力选,不写死**:每个套件声明它需要能 import 哪些模块,
脚本在候选 venv 里挑第一个满足的;挑不到就明确报错,而不是用错的 venv 硬跑。
- **强制注入 `PYTHONPATH`**:把源码目录排在已安装包之前,避免验旧版本。
- **纯 Python、跨平台**:不依赖 bash,Windows 上同样可用
(与 `paths.py` / `process_utils.py` 的迁移方向一致)。
## 用法
python3 Scripts/bin/run_all_tests.py # 全部 Python 套件
python3 Scripts/bin/run_all_tests.py --list # 只列出套件与选中的解释器
python3 Scripts/bin/run_all_tests.py -k newsflash # 透传给 pytest 的 -k
python3 Scripts/bin/run_all_tests.py --only core,backend
python3 Scripts/bin/run_all_tests.py --include-swift --include-frontend
退出码:全部通过 → 0;任一失败 → 1;环境不满足(找不到解释器)→ 2。
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
# 所有套件共用同一个 basetemp 根,但各自一个子目录(互不干扰)
BASETEMP_ROOT = ROOT / ".pytest-cache"
# ══════════════════════════════════════════════════════════
# 套件声明
# ══════════════════════════════════════════════════════════
@dataclass
class Suite:
"""一个可独立执行的 pytest 套件。"""
key: str
name: str
cwd: Path # 工作目录(相对 ROOT)
target: str # pytest 目标(相对 cwd)
# 需要排到 PYTHONPATH 最前面的源码目录(相对 ROOT)
source_paths: list[Path] = field(default_factory=list)
# 必须能 import 的模块;脚本据此挑选解释器
requires: list[str] = field(default_factory=lambda: ["pytest"])
note: str = ""
# 顺序即执行顺序。高频、纯离线的放前面,出错能更早暴露。
SUITES: list[Suite] = [
Suite(
key="core",
name="findatahub-core(数据层基类)",
cwd=ROOT / "Services/findatahub-core",
target="tests",
# 必须排在最前:该包以非 editable 装进各 venv,
# 否则测到的是已安装的旧版本(历史踩坑)
source_paths=[ROOT / "Services/findatahub-core/src"],
note="含 replace 收缩熔断回归",
),
Suite(
key="tushare",
name="tushare_fetcher(采集器)",
cwd=ROOT / "Services/tushare_fetcher",
target="tests/unit",
source_paths=[ROOT / "Services/findatahub-core/src"],
requires=["pytest", "findatahub_core"],
note="仅 unit:integration 需要真实 Tushare 额度",
),
Suite(
key="akshare",
name="akshare_fetcher(采集器)",
cwd=ROOT / "Services/akshare_fetcher",
target="tests",
source_paths=[ROOT / "Services/findatahub-core/src"],
requires=["pytest", "findatahub_core"],
),
Suite(
key="client",
name="findatahub-client(SDK)",
cwd=ROOT / "Services/findatahub-client",
target="tests",
# 测试 import 的是 `findatahub.client`(命名空间包),
# 必须把 client 自己的 src 排进 PYTHONPATH:
# 只写 findatahub_core 的话,akshare 的 venv 也能"满足依赖"却跑不起来
source_paths=[
ROOT / "Services/findatahub-core/src",
ROOT / "Services/findatahub-client/src",
],
requires=["pytest", "findatahub.client"],
),
Suite(
key="scripts",
name="Scripts/bin(脚本层)",
cwd=ROOT / "Scripts/bin",
target="tests",
# 该套件里 rss_fetcher 需要 httpx;缺了会在收集阶段直接报错
requires=["pytest", "httpx", "pymysql"],
note="含跨平台抽象层回归",
),
Suite(
key="backend",
name="kuaixun-service(后端)",
cwd=ROOT / "Services/kuaixun-service",
target="tests",
requires=["pytest", "fastapi", "httpx"],
note="含 CORS / 时间基准回归",
),
]
# ══════════════════════════════════════════════════════════
# 解释器探测
# ══════════════════════════════════════════════════════════
def _venv_python(venv_dir: Path) -> Path | None:
"""返回 venv 内的解释器路径(兼容 POSIX 与 Windows 布局)。"""
for rel in ("bin/python", "bin/python3", "Scripts/python.exe"):
p = venv_dir / rel
if p.exists():
return p
return None
def candidate_interpreters() -> list[tuple[str, Path]]:
"""候选解释器,按优先级排列:各服务自带 venv 在前,当前解释器兜底。"""
cands: list[tuple[str, Path]] = []
for venv in sorted((ROOT / "Services").glob("*/.venv")):
py = _venv_python(venv)
if py:
cands.append((f"{venv.parent.name}/.venv", py))
cands.append(("当前解释器", Path(sys.executable)))
return cands
def _can_import(python: Path, module: str, pythonpath: str) -> bool:
"""探测某解释器能否 import 指定模块(含注入的 PYTHONPATH)。"""
env = dict(os.environ)
if pythonpath:
env["PYTHONPATH"] = pythonpath
try:
res = subprocess.run(
[str(python), "-c", f"import {module}"],
capture_output=True, timeout=30, env=env, cwd=str(ROOT),
)
return res.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def pick_interpreter(suite: Suite, cands: list[tuple[str, Path]]) -> tuple[str, Path] | None:
"""选第一个能满足 suite.requires 的解释器。"""
pythonpath = os.pathsep.join(str(p) for p in suite.source_paths)
for label, py in cands:
if all(_can_import(py, m, pythonpath) for m in suite.requires):
return label, py
return None
# ══════════════════════════════════════════════════════════
# 执行
# ══════════════════════════════════════════════════════════
@dataclass
class Result:
suite: Suite
interpreter: str
returncode: int
seconds: float
summary: str
output: str
def _ensure_dir(path: Path) -> None:
"""建目录,容忍已存在。
不直接用 ``mkdir(exist_ok=True)``:本机沙箱对 mkdir 做了拦截,
即使目标已存在也可能抛 ``FileExistsError``(本项目已多次踩到)。
先判 is_dir 再兜住异常,两条路径都安全。
"""
if path.is_dir():
return
try:
path.mkdir(parents=True, exist_ok=True)
except FileExistsError:
pass
def run_suite(suite: Suite, label: str, python: Path, extra_args: list[str]) -> Result:
"""执行单个套件。"""
basetemp = BASETEMP_ROOT / suite.key
# pytest 会先清空 --basetemp 目标目录;它位于项目内,删的是自己的缓存
_ensure_dir(basetemp)
env = dict(os.environ)
if suite.source_paths:
env["PYTHONPATH"] = os.pathsep.join(str(p) for p in suite.source_paths)
# 统一 UTF-8:Windows 控制台默认 cp936,中文用例名会乱码甚至报编码错
env["PYTHONUTF8"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
cmd = [
str(python), "-m", "pytest", suite.target,
"-q", "--basetemp", str(basetemp),
*extra_args,
]
started = time.time()
try:
proc = subprocess.run(
cmd, cwd=str(suite.cwd), env=env,
capture_output=True, text=True, timeout=1800,
)
elapsed = time.time() - started
stdout = proc.stdout or ""
stderr = proc.stderr or ""
output = stdout + stderr
return Result(suite, label, proc.returncode, elapsed,
_extract_summary(stdout, stderr), output)
except subprocess.TimeoutExpired:
return Result(suite, label, 124, time.time() - started,
"超时(1800s)", "")
except OSError as e:
return Result(suite, label, 125, time.time() - started,
f"启动失败:{e}", "")
# pytest 统计行的形态:"132 passed, 2 skipped in 3.55s" / "1 error in 0.25s"
# 需覆盖的边界:
# - "-k 过滤到零用例" → "134 deselected in 0.32s"(exit 5,无 passed/failed)
# - "no tests ran":同样 exit 5
# - xfailed / xpassed:backend 套件有 xfail
_SUMMARY_RE = re.compile(
r"\b\d+ (?:passed|failed|error|errors|skipped|deselected|xfailed|xpassed"
r"|warning|warnings|rerun|reruns)\b|no tests ran"
)
def _extract_summary(stdout: str, stderr: str = "") -> str:
"""从 pytest 输出里取统计行。
必须**优先看 stdout、且要求「数字 + 状态词」相邻**:
应用自身的 stderr 日志里常出现形如
``(Background on this error at: https://sqlalche.me/...)`` 的行,
只按 "error" 关键词匹配会把这种日志误当成统计结果
(首版脚本就把 backend 套件的摘要取成了 SQLAlchemy 的报错提示)。
"""
for text in (stdout, stderr):
for line in reversed(text.strip().splitlines()):
s = line.strip().strip("=· ")
if s and _SUMMARY_RE.search(s):
return s
return "(无统计输出)"
# ══════════════════════════════════════════════════════════
# Swift / 前端(可选)
# ══════════════════════════════════════════════════════════
def run_swift(extra_args: list[str]) -> Result:
suite = Suite(key="swift", name="Swift 单元测试", cwd=ROOT, target="Tests")
started = time.time()
if not shutil.which("swift"):
return Result(suite, "-", 127, 0, "未安装 swift", "")
# SwiftPM 自身要调 sandbox-exec,本机沙箱下必须 --disable-sandbox
cmd = ["swift", "test", "--disable-sandbox", *extra_args]
try:
proc = subprocess.run(cmd, cwd=str(ROOT), capture_output=True, text=True, timeout=1800)
out = (proc.stdout or "") + (proc.stderr or "")
executed = [l for l in out.splitlines() if "Executed" in l and "failure" in l]
summary = executed[-1].strip() if executed else ("通过" if proc.returncode == 0 else "失败")
return Result(suite, "swift", proc.returncode, time.time() - started, summary, out)
except (OSError, subprocess.SubprocessError) as e:
return Result(suite, "swift", 125, time.time() - started, f"启动失败:{e}", "")
# ANSI 转义:vitest / pytest 在管道输出里也可能带颜色码,
# 不去掉会破坏行首匹配(首版前端摘要因此退化成"通过",看不到真实数量)
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
def run_frontend(extra_args: list[str]) -> Result:
cwd = ROOT / "Services/kuaixun-frontend"
suite = Suite(key="frontend", name="前端(vitest)", cwd=cwd, target="tests")
started = time.time()
npx = shutil.which("npx")
if not npx or not (cwd / "node_modules").exists():
return Result(suite, "-", 127, 0, "未安装 npx 或 node_modules", "")
cmd = [npx, "vitest", "run", "--reporter=dot", *extra_args]
try:
proc = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True, timeout=1800)
out = _ANSI_RE.sub("", (proc.stdout or "") + (proc.stderr or ""))
# vitest 汇总行形如 " Tests 44 passed (44)"
summary = ""
for line in out.splitlines():
if re.match(r"^\s*Tests\s+\d+", line):
summary = line.strip()
if not summary:
summary = "通过" if proc.returncode == 0 else "失败"
return Result(suite, "npx", proc.returncode, time.time() - started, summary, out)
except (OSError, subprocess.SubprocessError) as e:
return Result(suite, "npx", 125, time.time() - started, f"启动失败:{e}", "")
# ══════════════════════════════════════════════════════════
# 入口
# ══════════════════════════════════════════════════════════
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="统一测试入口:一条命令跑全部套件",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="示例:\n %(prog)s\n %(prog)s --list\n %(prog)s -k newsflash\n"
" %(prog)s --only core,backend --include-swift",
)
parser.add_argument("--list", action="store_true", help="只列出套件与选中的解释器")
parser.add_argument("--only", default="", help="只跑指定套件,逗号分隔(key)")
parser.add_argument("--skip", default="", help="跳过指定套件,逗号分隔(key)")
parser.add_argument("--include-swift", action="store_true", help="同时跑 Swift 测试")
parser.add_argument("--include-frontend", action="store_true", help="同时跑前端测试")
parser.add_argument(
"--with-integration", action="store_true",
help="同时跑需要真实 MySQL 的集成测试(会自建临时库,需 MYSQL_PASSWORD)",
)
parser.add_argument("--verbose", action="store_true",
help="失败时打印完整输出(刻意不设 -v:留给 pytest 的 -v)")
# 未知参数一律透传给 pytest。
# 不用 nargs=REMAINDER:argparse 在 "先有 --only、后有 -k" 的组合下
# 会把 -k 当成本脚本的未知选项而直接退出 2(已实测)。
args, extra = parser.parse_known_args(argv)
extra = [a for a in extra if a != "--"]
only = {s.strip() for s in args.only.split(",") if s.strip()}
skip = {s.strip() for s in args.skip.split(",") if s.strip()}
selected = [s for s in SUITES
if (not only or s.key in only) and s.key not in skip]
unknown = only - {s.key for s in SUITES}
if unknown:
print(f"未知套件:{', '.join(sorted(unknown))};可用:"
f"{', '.join(s.key for s in SUITES)}", file=sys.stderr)
return 2
cands = candidate_interpreters()
# ── 选解释器 ──
planned: list[tuple[Suite, str, Path]] = []
unresolved: list[Suite] = []
for suite in selected:
picked = pick_interpreter(suite, cands)
if picked is None:
unresolved.append(suite)
else:
planned.append((suite, picked[0], picked[1]))
if args.list:
print(f"项目根:{ROOT}\n")
print(f"{'套件':<10} {'解释器':<28} {'需要模块'}")
print("-" * 78)
for suite in selected:
picked = pick_interpreter(suite, cands)
label = picked[0] if picked else "❌ 无可用解释器"
print(f"{suite.key:<10} {label:<28} {', '.join(suite.requires)}")
if suite.note:
print(f"{'':<10} └─ {suite.note}")
return 0 if not unresolved else 2
if unresolved:
print("以下套件找不到满足依赖的解释器:", file=sys.stderr)
for suite in unresolved:
print(f" - {suite.key}({suite.name}):需要 "
f"{', '.join(suite.requires)}", file=sys.stderr)
print("\n可尝试:在对应服务目录执行 `uv sync` 补齐依赖。", file=sys.stderr)
return 2
# ── 串行执行 ──
_ensure_dir(BASETEMP_ROOT)
if args.with_integration and not os.environ.get("MYSQL_PASSWORD"):
print(
"⚠️ 已启用 --with-integration,但未设置 MYSQL_PASSWORD:\n"
" 集成测试会整体 skip(不算失败)。如需真跑:\n"
" set -a; . ~/.config/findatahub/env; set +a\n",
file=sys.stderr,
)
results: list[Result] = []
total = len(planned) + int(args.include_swift) + int(args.include_frontend)
idx = 0
print(f"项目根:{ROOT}")
print(f"套件数:{total}(每个套件独立 --basetemp,天然互不干扰)\n")
for suite, label, python in planned:
idx += 1
print(f"[{idx}/{total}] {suite.name} … ", end="", flush=True)
# 集成测试默认排除:它要真实 MySQL,且会建临时库。
# 只对 core 套件生效(目前唯一含 integration 子目录的套件)。
suite_extra = list(extra)
if suite.key == "core" and not args.with_integration:
suite_extra.append("--ignore=tests/integration")
r = run_suite(suite, label, python, suite_extra)
results.append(r)
status = "✓" if r.returncode == 0 else "✗"
print(f"{status} {r.summary} ({r.seconds:.1f}s, {r.interpreter})")
if args.include_swift:
idx += 1
print(f"[{idx}/{total}] Swift 单元测试 … ", end="", flush=True)
r = run_swift(extra)
results.append(r)
print(f"{'✓' if r.returncode == 0 else '✗'} {r.summary} ({r.seconds:.1f}s)")
if args.include_frontend:
idx += 1
print(f"[{idx}/{total}] 前端(vitest)… ", end="", flush=True)
r = run_frontend(extra)
results.append(r)
print(f"{'✓' if r.returncode == 0 else '✗'} {r.summary} ({r.seconds:.1f}s)")
# ── 汇总 ──
failed = [r for r in results if r.returncode != 0]
print("\n" + "=" * 78)
print(f"{'套件':<12} {'状态':<6} {'耗时':>8} 统计")
print("-" * 78)
for r in results:
status = "PASS" if r.returncode == 0 else "FAIL"
print(f"{r.suite.key:<12} {status:<6} {r.seconds:>7.1f}s {r.summary}")
print("=" * 78)
if failed:
print(f"\n❌ {len(failed)} 个套件失败:{', '.join(r.suite.key for r in failed)}")
for r in failed:
if args.verbose and r.output:
print(f"\n───── {r.suite.key} 输出 ─────")
print(r.output[-6000:])
return 1
total_seconds = sum(r.seconds for r in results)
print(f"\n✅ 全部 {len(results)} 个套件通过(合计 {total_seconds:.1f}s)")
return 0
if __name__ == "__main__":
sys.exit(main())