Files
live-hub-py/tests/support/db.py
T
yml2213 c3fd299f3a test(db): 测试改用真实 MySQL,方言差异不再漏到生产
生产是 MySQL 8、测试是 SQLite,两者的方言差异在本地完全测不出来。今天为此
付出了两次代价:

1. `ORDER BY ck_checked_at ASC NULLS FIRST` 在 SQLite 上完全正常,本地 258 条
   全绿,一部署到生产第一次巡检就 1064 语法错误(MySQL 不支持 NULLS FIRST);
2. P1b 自动重登的结算复用调度器事务读取,在 SQLite 下看不出问题,但在 MySQL 的
   REPEATABLE READ 下读到的是批次开始前的快照 —— 10 条实际成功的重登全部被记成
   失败。SQLite 的读一致性与 MySQL 不同,这类问题跑一万遍也复现不出来。

改动:
- 新增 tests/support/db.py:测试专用引擎/会话工具,连接串从 .env 读取,
  默认指向独立的 douyu_login_test 库;
- 新增 tests/conftest.py:会话级建好表结构,每个用例前后清空所有表做隔离;
- 根 conftest.py 接管连接串设置:它在 pytest 收集阶段最先执行,在这里把
  DATABASE_URL 指向测试库,应用模块首次 import 拿到就是 MySQL,不依赖任何
  import 顺序(原先这里是 sqlite:// 兜底,改造后会变成「先建 SQLite engine
  再打补丁换成 MySQL」,顺序一变就静默跑错库);
- 21 个测试文件的 setup 样板统一改用共享测试引擎;
- 两个迁移测试改为在临时库 douyu_login_mig_<随机> 上跑完整 alembic upgrade head,
  跑完 drop —— 迁移脚本本身也终于在 MySQL 上被验证;
- dev.sh 与 README 的过时说明一并更新(原先写着「不需要 MySQL / .env 配置」;
  「测试各自建 SQLite 内存库」)。

安全与易用:
- 测试库名与开发库同名时**直接拒绝运行**(clear_tables 会 DELETE 所有表,
  一旦 TEST_MYSQL_DATABASE 被指到 douyu_login 就是数据灾难);
- 连不上 MySQL 时给出可操作提示(先 ./dev.sh 起库 / 用 TEST_MYSQL_DATABASE 覆盖),
  而不是抛一段 SQLAlchemy 原始堆栈。

验收:
- 全量 301 条通过,连跑两次结果一致(隔离有效),耗时约 12.5 秒(原 SQLite 约 3.6 秒);
- 单文件独立运行通过(不依赖全量执行顺序);
- `grep -rn sqlite tests/ conftest.py` 已无实际用法;
- **方言确实生效**:临时把一条查询改回 `NULLS FIRST`,13 条测试立刻变红(含运行期
  失败),还原即恢复;这条由 test_generated_sql_is_mysql_compatible 长期守着。

代价(已在 README 与规划附录 C.2 记明):跑测试前必须先有 MySQL,pre-push 钩子
同理;CI 需要 service container。
2026-10-02 21:22:08 +08:00

153 lines
5.4 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.
"""测试专用的 MySQL 数据库引擎与会话工具。"""
from __future__ import annotations
import os
from pathlib import Path
from urllib.parse import quote_plus
from sqlalchemy import Engine, create_engine, text
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session, sessionmaker
def get_mysql_credentials() -> dict[str, str]:
"""获取连接 MySQL 所需的凭据(优先环境变量,其次项目 .env)。"""
root_dir = Path(__file__).resolve().parents[2]
env_file = root_dir / ".env"
env_vars: dict[str, str] = {}
if env_file.exists():
with open(env_file, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
env_vars[k.strip()] = v.strip()
user = os.getenv("MYSQL_USER") or env_vars.get("MYSQL_USER") or "douyu_login"
pwd = os.getenv("MYSQL_PASSWORD") or env_vars.get("MYSQL_PASSWORD") or ""
host = (
os.getenv("MYSQL_BIND_HOST")
or env_vars.get("MYSQL_BIND_HOST")
or os.getenv("DEV_DB_HOST")
or "127.0.0.1"
)
port = (
os.getenv("MYSQL_HOST_PORT")
or env_vars.get("MYSQL_HOST_PORT")
or os.getenv("DEV_DB_PORT")
or "3307"
)
db_name = (
os.getenv("TEST_MYSQL_DATABASE")
or env_vars.get("TEST_MYSQL_DATABASE")
or "douyu_login_test"
)
assert_is_test_database(db_name)
return {
"user": user,
"password": pwd,
"host": host,
"port": port,
"database": db_name,
}
def assert_is_test_database(db_name: str) -> None:
"""拒绝把测试指向开发库。
``clear_tables()`` 会 DELETE 掉所有业务表;一旦 TEST_MYSQL_DATABASE 被指到
``douyu_login``(开发库)甚至生产库,就是数据灾难。这里做一道硬拦截:
库名与 .env 里的 MYSQL_DATABASE 相同就直接拒绝。
"""
root_dir = Path(__file__).resolve().parents[2]
dev_db = ""
env_file = root_dir / ".env"
if env_file.exists():
with open(env_file, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("MYSQL_DATABASE="):
dev_db = line.split("=", 1)[1].strip()
break
dev_db = os.getenv("MYSQL_DATABASE") or dev_db
if dev_db and db_name == dev_db:
raise RuntimeError(
f"测试库名 ({db_name}) 与开发库同名,拒绝运行:"
"测试会清空所有表。请改用独立的测试库(默认 douyu_login_test)。"
)
def get_test_database_url() -> str:
"""返回测试数据库的完整 URL。"""
creds = get_mysql_credentials()
return (
f"mysql+pymysql://{quote_plus(creds['user'])}:{quote_plus(creds['password'])}"
f"@{creds['host']}:{creds['port']}/{creds['database']}?charset=utf8mb4"
)
# 1. 确保环境变量始终指向测试库
TEST_DB_URL = get_test_database_url()
os.environ["DATABASE_URL"] = TEST_DB_URL
os.environ["DB_NAME"] = get_mysql_credentials()["database"]
import web.backend.database as database_module
from web.backend.database import Base
# 2. 覆盖 database_module 中的变量,保证即使被提前 import 过也能指向 MySQL 测试库
database_module.DATABASE_URL = TEST_DB_URL
_test_engine: Engine | None = None
def get_test_engine() -> Engine:
"""获取测试库共享单例 Engine。"""
global _test_engine
if _test_engine is None:
_test_engine = create_engine(
TEST_DB_URL,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
pool_recycle=1800,
)
database_module.engine = _test_engine
database_module.SessionLocal.configure(bind=_test_engine)
return _test_engine
def make_test_session(engine: Engine | None = None) -> Session:
"""为测试创建一个独立的 Session。"""
eng = engine or get_test_engine()
return sessionmaker(bind=eng)()
def clear_tables(engine: Engine | None = None) -> None:
"""快速清空当前 Base.metadata 中的所有表数据(耗时约 0.01s)。"""
eng = engine or get_test_engine()
tables = [t.name for t in Base.metadata.sorted_tables]
with eng.begin() as conn:
conn.execute(text("SET FOREIGN_KEY_CHECKS = 0;"))
for table in reversed(tables):
conn.execute(text(f"DELETE FROM `{table}`;"))
conn.execute(text("SET FOREIGN_KEY_CHECKS = 1;"))
def init_test_schema(engine: Engine | None = None) -> None:
"""确保测试库中所有表结构存在并清空所有数据(幂等高效)。"""
eng = engine or get_test_engine()
try:
Base.metadata.create_all(eng)
clear_tables(eng)
except OperationalError as exc:
# 测试不再使用 SQLite,连不上库时必须给出可操作的提示,
# 而不是抛一段 SQLAlchemy 原始堆栈。
raise RuntimeError(
"连接测试用 MySQL 失败,测试无法运行。\n"
f" 连接串: {TEST_DB_URL}\n"
" 请先启动数据库:./dev.sh(会起 docker compose,MySQL 映射到 127.0.0.1:3307)\n"
" 或用 TEST_MYSQL_DATABASE / MYSQL_USER / MYSQL_PASSWORD / MYSQL_HOST_PORT "
"指向一个可用的 MySQL 测试库。"
) from exc