fix(测试): 清理本次创建的隔离资源并保留失败码
记录数据库与账号的创建结果,只清理本次成功创建的对象。 支持显式保留资源,拒绝误删复用库或重名对象,补充退出码回归。
This commit is contained in:
parent
c684a44753
commit
908851a704
88
backend/tests/unit/test_isolated_runner.py
Normal file
88
backend/tests/unit/test_isolated_runner.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""运行器必须保留失败码,并仅清理本次成功创建的对象。"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(monkeypatch):
|
||||
path = Path(__file__).resolve().parents[3] / "scripts/run_isolated_tests.py"
|
||||
spec = importlib.util.spec_from_file_location("isolated_runner", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
calls, commands = [], []
|
||||
|
||||
def sql(statement):
|
||||
calls.append(statement)
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
def run(command, **kwargs):
|
||||
commands.append(command)
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
monkeypatch.setattr(module, "run_mysql", sql)
|
||||
monkeypatch.setattr(module.subprocess, "run", run)
|
||||
monkeypatch.setattr(module.sys, "argv", ["run_isolated_tests.py"])
|
||||
return module, calls, commands
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args", [[], ["--keep"], ["--reuse-database", "payroll_test_0123456789ab"]]
|
||||
)
|
||||
def test_失败码不被清理覆盖(runner, monkeypatch, args):
|
||||
module, calls, _ = runner
|
||||
monkeypatch.setattr(module.sys, "argv", ["runner", *args])
|
||||
monkeypatch.setattr(module.subprocess, "run", lambda *_, **__: SimpleNamespace(returncode=5))
|
||||
assert module.main() == 5
|
||||
drops = [sql for sql in calls if sql.startswith("DROP")]
|
||||
assert bool(drops) == ("--keep" not in args)
|
||||
if "--reuse-database" in args:
|
||||
assert not any(sql.startswith("DROP DATABASE") for sql in drops)
|
||||
|
||||
|
||||
def test_只清理成功创建的对象(runner, monkeypatch):
|
||||
module, calls, _ = runner
|
||||
|
||||
def sql(statement):
|
||||
calls.append(statement)
|
||||
return SimpleNamespace(returncode=int(statement.startswith("CREATE USER")))
|
||||
|
||||
monkeypatch.setattr(module, "run_mysql", sql)
|
||||
assert module.main() == 1
|
||||
assert any(sql.startswith("DROP DATABASE") for sql in calls)
|
||||
assert not any(sql.startswith("DROP USER") for sql in calls)
|
||||
|
||||
|
||||
def test_创建重名库失败绝不删除原库(runner, monkeypatch):
|
||||
module, calls, _ = runner
|
||||
|
||||
def sql(statement):
|
||||
calls.append(statement)
|
||||
return SimpleNamespace(returncode=1)
|
||||
|
||||
monkeypatch.setattr(module, "run_mysql", sql)
|
||||
assert module.main() == 1
|
||||
assert not any(sql.startswith("DROP") for sql in calls)
|
||||
|
||||
|
||||
def test_参数分隔符不传给pytest(runner, monkeypatch):
|
||||
module, _, commands = runner
|
||||
monkeypatch.setattr(module.sys, "argv", ["runner", "--", "tests/integration/test_auth_flow.py"])
|
||||
assert module.main() == 0
|
||||
assert "--" not in commands[-1]
|
||||
assert "tests/integration/test_auth_flow.py" in commands[-1]
|
||||
|
||||
|
||||
def test_清理失败不能报成功(runner, monkeypatch):
|
||||
module, calls, _ = runner
|
||||
|
||||
def sql(statement):
|
||||
calls.append(statement)
|
||||
return SimpleNamespace(returncode=int(statement.startswith("DROP DATABASE")))
|
||||
|
||||
monkeypatch.setattr(module, "run_mysql", sql)
|
||||
assert module.main() == 1
|
||||
assert any(sql.startswith("DROP USER") for sql in calls)
|
||||
@ -1,7 +1,7 @@
|
||||
"""使用唯一的本地测试库运行迁移和集成测试,绝不清理原业务库。"""
|
||||
|
||||
import os
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
@ -9,43 +9,99 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_mysql(sql: str) -> subprocess.CompletedProcess:
|
||||
"""密码仅在容器环境和标准输入中使用,不回显 SQL 错误内容。"""
|
||||
return subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
"-i",
|
||||
"ys-mysql-dev",
|
||||
"sh",
|
||||
"-c",
|
||||
'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mysql -uroot',
|
||||
],
|
||||
input=sql.encode(),
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--reuse-database')
|
||||
parser.add_argument('--migration-roundtrip', action='store_true')
|
||||
parser.add_argument("--reuse-database")
|
||||
parser.add_argument("--migration-roundtrip", action="store_true")
|
||||
parser.add_argument("--keep", action="store_true", help="保留本次隔离库和账号供复查")
|
||||
parsed, test_args = parser.parse_known_args()
|
||||
if parsed.reuse_database and not re.fullmatch(r'payroll_test_[0-9a-f]{12}', parsed.reuse_database):
|
||||
raise SystemExit('只允许复用本运行器创建的隔离测试库')
|
||||
if test_args[:1] == ["--"]:
|
||||
test_args = test_args[1:]
|
||||
if parsed.reuse_database and not re.fullmatch(
|
||||
r"payroll_test_[0-9a-f]{12}", parsed.reuse_database
|
||||
):
|
||||
raise SystemExit("只允许复用本运行器创建的隔离测试库")
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
python = root / "backend/.venv-workbench/Scripts/python.exe"
|
||||
if not python.exists():
|
||||
python = Path(sys.executable)
|
||||
python = Path(sys.executable)
|
||||
if sys.prefix == sys.base_prefix:
|
||||
for candidate in (".venv", ".venv-workbench"):
|
||||
local_python = root / "backend" / candidate / "Scripts/python.exe"
|
||||
if local_python.exists():
|
||||
python = local_python
|
||||
break
|
||||
suffix = secrets.token_hex(6)
|
||||
database, username = parsed.reuse_database or "payroll_test_" + suffix, "pytest_" + suffix
|
||||
database = parsed.reuse_database or "payroll_test_" + suffix
|
||||
username = "pytest_" + suffix
|
||||
password = secrets.token_urlsafe(30)
|
||||
sql = ("" if parsed.reuse_database else f"CREATE DATABASE `{database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; ") + f"CREATE USER '{username}'@'%' IDENTIFIED BY '{password}'; GRANT ALL PRIVILEGES ON `{database}`.* TO '{username}'@'%';"
|
||||
setup = subprocess.run(
|
||||
["docker", "exec", "-i", "ys-mysql-dev", "sh", "-c", 'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mysql -uroot'],
|
||||
input=sql.encode(), capture_output=True,
|
||||
)
|
||||
if setup.returncode:
|
||||
raise SystemExit("隔离库创建失败,未执行迁移或测试")
|
||||
env = os.environ | {
|
||||
"MYSQL_DATABASE": database, "MYSQL_USER": username, "MYSQL_PASSWORD": password,
|
||||
"PAYROLL_TEST_DATABASE": database, "REDIS_DB": "15", "PYTHONIOENCODING": "utf-8",
|
||||
"UPLOAD_DIR": str(root / ".ai" / "isolated-uploads" / database),
|
||||
}
|
||||
print("独立测试库:" + database, flush=True)
|
||||
commands = [["-m", "alembic", "upgrade", "head"]]
|
||||
if parsed.migration_roundtrip:
|
||||
commands += [["-m", "alembic", "downgrade", "07b8c9d0e1f2"], ["-m", "alembic", "upgrade", "head"]]
|
||||
args = test_args
|
||||
commands.append(["-m", "pytest", *(args or ["tests/integration"]), "-q", "--tb=short"])
|
||||
for args in commands:
|
||||
result = subprocess.run([str(python), *args], cwd=root / "backend", env=env)
|
||||
if result.returncode:
|
||||
return result.returncode
|
||||
return 0
|
||||
created_database = created_user = False
|
||||
result_code = 1
|
||||
try:
|
||||
if not parsed.reuse_database:
|
||||
if run_mysql(
|
||||
f"CREATE DATABASE `{database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"
|
||||
).returncode:
|
||||
raise RuntimeError("隔离库创建失败,未执行迁移或测试")
|
||||
created_database = True
|
||||
if run_mysql(f"CREATE USER '{username}'@'%' IDENTIFIED BY '{password}';").returncode:
|
||||
raise RuntimeError("隔离账号创建失败,未执行迁移或测试")
|
||||
created_user = True
|
||||
if run_mysql(f"GRANT ALL PRIVILEGES ON `{database}`.* TO '{username}'@'%';").returncode:
|
||||
raise RuntimeError("隔离账号授权失败,未执行迁移或测试")
|
||||
env = os.environ | {
|
||||
"MYSQL_DATABASE": database,
|
||||
"MYSQL_USER": username,
|
||||
"MYSQL_PASSWORD": password,
|
||||
"PAYROLL_TEST_DATABASE": database,
|
||||
"REDIS_DB": "15",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"UPLOAD_DIR": str(root / ".ai" / "isolated-uploads" / database),
|
||||
}
|
||||
print("独立测试库:" + database, flush=True)
|
||||
commands = [["-m", "alembic", "upgrade", "head"]]
|
||||
if parsed.migration_roundtrip:
|
||||
commands += [
|
||||
["-m", "alembic", "downgrade", "07b8c9d0e1f2"],
|
||||
["-m", "alembic", "upgrade", "head"],
|
||||
]
|
||||
commands.append(["-m", "pytest", *(test_args or ["tests/integration"]), "-q", "--tb=short"])
|
||||
for args in commands:
|
||||
result_code = subprocess.run(
|
||||
[str(python), *args], cwd=root / "backend", env=env
|
||||
).returncode
|
||||
if result_code:
|
||||
break
|
||||
except RuntimeError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
finally:
|
||||
# 只清理本次 CREATE 已成功的对象;重名创建失败及复用库绝不能被删除。
|
||||
if not parsed.keep:
|
||||
cleanup = []
|
||||
if created_database:
|
||||
cleanup.append(f"DROP DATABASE `{database}`;")
|
||||
if created_user:
|
||||
cleanup.append(f"DROP USER '{username}'@'%';")
|
||||
for statement in cleanup:
|
||||
if run_mysql(statement).returncode:
|
||||
print(f"隔离测试资源清理失败,请核对 {database} / {username}", file=sys.stderr)
|
||||
result_code = result_code or 1
|
||||
return result_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Loading…
Reference in New Issue
Block a user