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:
FinDataHub committed 2026-10-26 20:55:52 +08:00
1 parent 73b45d7c0c
commit cd076018ba
4 files changed
+766

No files matched your search

+3
View File
@@ -97,3 +97,6 @@ test-results/
# Agent 团队运行态(会话产物,非产品资产)
.agent-teams/
# 统一测试入口(Scripts/bin/run_all_tests.py)的 per-suite basetemp
.pytest-cache/
+472
View File
@@ -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