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 开关默认关闭(要真实库且会建临时库)。
This commit is contained in:
1 parent
73b45d7c0c
commit
cd076018ba
4 files changed
+766
No files matched your search
@@ -97,3 +97,6 @@ test-results/
|
||||
|
||||
# Agent 团队运行态(会话产物,非产品资产)
|
||||
.agent-teams/
|
||||
|
||||
# 统一测试入口(Scripts/bin/run_all_tests.py)的 per-suite basetemp
|
||||
.pytest-cache/
|
||||
@@ -0,0 +1,472 @@
|
||||
#!/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())
|
||||
Whitespace-only changes.
@@ -0,0 +1,291 @@
|
||||
# coding=utf-8
|
||||
"""`replace()` 的真实 MySQL 集成测试.
|
||||
|
||||
## 为什么必须有这一套
|
||||
|
||||
`test_db_replace_guard.py` 用 Fake 游标验证了**代码路径**,但 Fake 无法验证三件
|
||||
只有真库才成立的事:
|
||||
|
||||
1. **事务是否真的回滚**——Fake 的 `rollback()` 只是置一个标志位,真实行为取决于
|
||||
InnoDB 的 undo 与隔离级别;
|
||||
2. **唯一的"半成品"风险**——旧实现每个 chunk 独立 `commit()`,中途失败会留下
|
||||
「旧数据已删、新数据写了一半」。这个场景在 Fake 上永远复现不出来,
|
||||
因为 Fake 不会真的提交。
|
||||
3. **熔断时表是否真的没动**——需要真实读取行数来确认。
|
||||
|
||||
这三件事恰好是 `replace()` 最危险的三个点,因此本套件用真实 MySQL 验证。
|
||||
|
||||
## 隔离约束(硬性)
|
||||
|
||||
- **绝不连生产库**:每次在 fixture 内新建 `fdh_it_replace_<随机8位>` 空库,
|
||||
用完 `DROP`。库名带前缀且 DROP 前再断言一次前缀。
|
||||
- 不依赖任何现有库、现有表、现有凭据文件;只读环境变量。
|
||||
- 没有可用凭据时整组 `skip`,不报失败——CI 无 MySQL 时不应因此变红。
|
||||
|
||||
## 运行方式
|
||||
|
||||
export MYSQL_PASSWORD='...' # 或从 ~/.config/findatahub/env 取
|
||||
PYTHONPATH=src python -m pytest tests/integration -q
|
||||
|
||||
可选环境变量:`MYSQL_HOST`(默认 127.0.0.1)、`MYSQL_USER`(默认 root)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from findatahub_core.db import BaseDatabaseManager, ReplaceShrinkError
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 凭据与跳过
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
def _credentials() -> dict:
|
||||
password = os.environ.get("MYSQL_PASSWORD", "")
|
||||
if not password:
|
||||
pytest.skip(
|
||||
"未提供 MYSQL_PASSWORD,跳过真实库集成测试"
|
||||
"(本套件会自建临时库,不会触碰生产数据)"
|
||||
)
|
||||
return {
|
||||
"host": os.environ.get("MYSQL_HOST", "127.0.0.1"),
|
||||
"port": int(os.environ.get("MYSQL_PORT", "3306")),
|
||||
"user": os.environ.get("MYSQL_USER", "root"),
|
||||
"password": password,
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 测试用 DatabaseManager
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
class _ITManager(BaseDatabaseManager):
|
||||
"""最小子类:只声明测试表,不读任何配置文件。
|
||||
|
||||
`replace()` 不走唯一索引配置(那是 `upsert()` 才需要的),
|
||||
因此 `_get_index_config_path` 返回空串即可。
|
||||
"""
|
||||
|
||||
def _get_index_config_path(self) -> str:
|
||||
return ""
|
||||
|
||||
def _get_safe_tables(self) -> set[str]:
|
||||
return {"it_rows"}
|
||||
|
||||
|
||||
CREATE_TABLE_SQL = """
|
||||
CREATE TABLE `it_rows` (
|
||||
`ts_code` VARCHAR(16) NOT NULL,
|
||||
`trade_date` CHAR(8) NOT NULL,
|
||||
`close` DOUBLE DEFAULT NULL,
|
||||
UNIQUE KEY `uk_code_date` (`ts_code`, `trade_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
"""
|
||||
|
||||
|
||||
def _make_df(rows: int, *, duplicate_row: int | None = None) -> pd.DataFrame:
|
||||
"""构造测试数据。
|
||||
|
||||
Args:
|
||||
rows: 行数。
|
||||
duplicate_row: 若指定,把该行的 (ts_code, trade_date) 改成与第 0 行相同,
|
||||
用于制造唯一索引冲突——这是验证「跨 chunk 回滚」的关键手段。
|
||||
"""
|
||||
codes = [f"{i:06d}.SZ" for i in range(rows)]
|
||||
dates = ["20260101"] * rows
|
||||
if duplicate_row is not None and 0 < duplicate_row < rows:
|
||||
codes[duplicate_row] = codes[0]
|
||||
dates[duplicate_row] = dates[0]
|
||||
return pd.DataFrame({
|
||||
"ts_code": codes,
|
||||
"trade_date": dates,
|
||||
"close": [10.0 + i * 0.01 for i in range(rows)],
|
||||
})
|
||||
|
||||
|
||||
def _count_rows(manager: _ITManager) -> int:
|
||||
"""直连统计表内实际行数(不复用被测代码,避免自证)。"""
|
||||
raw = manager.get_engine().raw_connection()
|
||||
try:
|
||||
cur = raw.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM `it_rows`")
|
||||
return int(cur.fetchone()[0])
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Fixture:独立临时库
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def manager():
|
||||
"""建一个独立临时库 + 测试表,模块结束后整库删除。"""
|
||||
import pymysql
|
||||
|
||||
creds = _credentials()
|
||||
dbname = f"fdh_it_replace_{uuid.uuid4().hex[:8]}"
|
||||
assert dbname.startswith("fdh_it_replace_"), "库名前缀必须受控"
|
||||
|
||||
admin = pymysql.connect(
|
||||
host=creds["host"], port=creds["port"],
|
||||
user=creds["user"], password=creds["password"],
|
||||
charset="utf8mb4", autocommit=True,
|
||||
)
|
||||
try:
|
||||
with admin.cursor() as cur:
|
||||
cur.execute(
|
||||
f"CREATE DATABASE `{dbname}` "
|
||||
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
admin.close()
|
||||
pytest.skip(f"无法创建测试库(MySQL 不可用?):{e}")
|
||||
|
||||
mgr = _ITManager({
|
||||
"host": creds["host"], "port": creds["port"],
|
||||
"user": creds["user"], "password": creds["password"],
|
||||
"database": dbname, "charset": "utf8mb4",
|
||||
})
|
||||
|
||||
# 建表
|
||||
raw = mgr.get_engine().raw_connection()
|
||||
try:
|
||||
cur = raw.cursor()
|
||||
cur.execute(CREATE_TABLE_SQL)
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
|
||||
try:
|
||||
yield mgr
|
||||
finally:
|
||||
# 全库删除:只删自己建的库,且再校验一次前缀
|
||||
if dbname.startswith("fdh_it_replace_"):
|
||||
try:
|
||||
with admin.cursor() as cur:
|
||||
cur.execute(f"DROP DATABASE IF EXISTS `{dbname}`")
|
||||
except Exception: # noqa: BLE001 — 清理失败不应掩盖测试结论
|
||||
pass
|
||||
try:
|
||||
mgr.get_engine().dispose()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
admin.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_table(manager):
|
||||
"""每个用例前清空表,保证用例间独立。"""
|
||||
raw = manager.get_engine().raw_connection()
|
||||
try:
|
||||
cur = raw.cursor()
|
||||
cur.execute("DELETE FROM `it_rows`")
|
||||
raw.commit()
|
||||
finally:
|
||||
raw.close()
|
||||
yield
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 用例
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
class TestRealReplace:
|
||||
def test_normal_write_on_empty_table(self, manager):
|
||||
"""空表写入不受熔断影响(原表 0 行 < 阈值)。"""
|
||||
assert _count_rows(manager) == 0
|
||||
n = manager.replace(_make_df(100), "it_rows")
|
||||
assert n == 100
|
||||
assert _count_rows(manager) == 100
|
||||
|
||||
def test_large_table_replace_succeeds_when_size_similar(self, manager):
|
||||
"""正常全量刷新(行数相当)必须放行——熔断不能误伤日常路径。"""
|
||||
manager.replace(_make_df(1000), "it_rows")
|
||||
assert _count_rows(manager) == 1000
|
||||
|
||||
n = manager.replace(_make_df(900), "it_rows")
|
||||
assert n == 900
|
||||
assert _count_rows(manager) == 900
|
||||
|
||||
|
||||
class TestShrinkGuardOnRealDB:
|
||||
def test_rejects_shrink_and_leaves_table_untouched(self, manager):
|
||||
"""核心断言:熔断时表**必须原样**,旧数据一行不少。
|
||||
|
||||
这是 replace 最危险的失败模式——用不完整批次替换完整历史。
|
||||
"""
|
||||
manager.replace(_make_df(1000), "it_rows")
|
||||
assert _count_rows(manager) == 1000
|
||||
|
||||
with pytest.raises(ReplaceShrinkError) as ei:
|
||||
manager.replace(_make_df(10), "it_rows")
|
||||
|
||||
assert ei.value.existing == 1000
|
||||
assert ei.value.incoming == 10
|
||||
# 真库复核:数据完好,删除动作从未发生
|
||||
assert _count_rows(manager) == 1000, "熔断后表被改动,护栏失效"
|
||||
|
||||
def test_allow_shrink_replaces_for_real(self, manager):
|
||||
"""显式承认收缩时,表内容必须真的被替换。"""
|
||||
manager.replace(_make_df(1000), "it_rows")
|
||||
n = manager.replace(_make_df(5), "it_rows", allow_shrink=True)
|
||||
assert n == 5
|
||||
assert _count_rows(manager) == 5
|
||||
|
||||
def test_small_table_not_guarded(self, manager):
|
||||
"""低于 REPLACE_GUARD_MIN_ROWS 的表不做比例校验。"""
|
||||
manager.replace(_make_df(50), "it_rows")
|
||||
n = manager.replace(_make_df(1), "it_rows")
|
||||
assert n == 1
|
||||
assert _count_rows(manager) == 1
|
||||
|
||||
|
||||
class TestAtomicityOnRealDB:
|
||||
def test_cross_chunk_failure_rolls_back_entirely(self, manager):
|
||||
"""跨 chunk 失败必须整体回滚——旧实现在这里会留下半张表。
|
||||
|
||||
构造方式:写入 15000 行(2 个 chunk:10000 + 5000),
|
||||
令第 10001 行与第 1 行的唯一键相同。
|
||||
于是:chunk1 成功、chunk2 冲突失败。
|
||||
|
||||
旧实现(每 chunk 独立 commit)的后果:
|
||||
DELETE 已提交 + chunk1 已提交 → 表内 10000 行,
|
||||
原来的 1000 行历史**永久丢失**。
|
||||
新实现(单事务):
|
||||
全部回滚 → 表内仍是 1000 行。
|
||||
"""
|
||||
manager.replace(_make_df(1000), "it_rows")
|
||||
assert _count_rows(manager) == 1000
|
||||
|
||||
bad = _make_df(15000, duplicate_row=10000)
|
||||
with pytest.raises(Exception) as ei:
|
||||
manager.replace(bad, "it_rows", allow_shrink=True)
|
||||
|
||||
# 唯一键冲突的错误信息因驱动版本而异,只断言"确实失败了"
|
||||
assert ei.value is not None
|
||||
|
||||
# 核心断言:旧数据完好,且没有半成品残留
|
||||
assert _count_rows(manager) == 1000, (
|
||||
"回滚不完整:表内行数既不是旧值 1000,说明留下了半成品数据"
|
||||
)
|
||||
|
||||
def test_empty_df_does_not_truncate(self, manager):
|
||||
"""空批次绝不清表——「本次没取到数」不等于「表应该为空」。"""
|
||||
manager.replace(_make_df(200), "it_rows")
|
||||
assert _count_rows(manager) == 200
|
||||
|
||||
assert manager.replace(pd.DataFrame(), "it_rows") == 0
|
||||
assert _count_rows(manager) == 200
|
||||
|
||||
def test_repeated_replace_is_idempotent(self, manager):
|
||||
"""连续两次相同内容的全量写入,结果一致(不会累积重复行)。"""
|
||||
df = _make_df(300)
|
||||
manager.replace(df, "it_rows")
|
||||
manager.replace(df, "it_rows")
|
||||
assert _count_rows(manager) == 300
|
||||
Reference in new issue
Block a user