# -*- coding: utf-8 -*- """pytest 最小兼容垫片 + 测试运行器(无 pytest 环境下产出真实执行证据)。 QC 硬门禁要求「测试实际执行通过」的机械证据,但部署/核验环境无 pytest (``ModuleNotFoundError: No module named 'pytest'``)。本文件提供: 1. ``install()``:真实 pytest 可导入则直接返回它(零副作用);否则向 sys.modules 注入最小 pytest 替身,覆盖本仓测试实际用到的 API 面: ``pytest.fixture``(含 generator fixture 的 setup/teardown)、``pytest.mark.*``、 ``pytest.skip``、``pytest.raises``、``pytest.approx``、``pytest.param``。 2. ``run(paths)``:收集 ``test_*`` 模块级函数与 ``unittest.TestCase`` 子类并逐个执行, 输出 passed/failed/skipped 明细与退出码(0 = 全绿)。 只服务测试执行,不参与业务运行时;生产装配路径不 import 本文件。 """ import importlib import inspect import io import os import sys import traceback import types import unittest __all__ = ["install", "run", "Skipped", "HAS_REAL_PYTEST"] class Skipped(Exception): """pytest.skip 语义:用例跳过(不算失败)。""" HAS_REAL_PYTEST = False class _Mark(object): """pytest.mark.*:仅登记标记,不改变用例行为。""" def __getattr__(self, name): def deco(*a, **kw): if len(a) == 1 and callable(a[0]) and not kw: fn = a[0] marks = getattr(fn, "pytestmark", []) marks.append(name) fn.pytestmark = marks return fn def wrap(fn): marks = getattr(fn, "pytestmark", []) marks.append((name, a, kw)) fn.pytestmark = marks return fn return wrap return deco class _RaisesCtx(object): def __init__(self, expected, match=None): self.expected = expected self.match = match self.value = None def __enter__(self): return self def __exit__(self, exc_type, exc, tb): if exc_type is None: raise AssertionError("DID NOT RAISE %r" % (self.expected,)) _exp = self.expected _ok_exp = (isinstance(_exp, type) and issubclass(_exp, BaseException)) \ or isinstance(_exp, tuple) if not _ok_exp: _exp = BaseException if isinstance(self.expected, type) and issubclass(self.expected, BaseException): if not issubclass(exc_type, self.expected): return False elif isinstance(self.expected, tuple): if not issubclass(exc_type, self.expected): return False self.value = exc if self.match: import re as _re if not _re.search(self.match, str(exc)): raise AssertionError("pattern %r not found in %r" % (self.match, str(exc))) return True class _Approx(object): def __init__(self, expected, rel=None, abs=None): self.expected = expected self.rel = rel if rel is not None else 1e-6 self.abs = abs if abs is not None else 1e-12 def __eq__(self, other): try: if isinstance(self.expected, (list, tuple)): return all(abs(a - b) <= max(self.abs, self.rel * abs(b)) for a, b in zip(self.expected, other)) and \ len(self.expected) == len(other) return abs(other - self.expected) <= max(self.abs, self.rel * abs(self.expected)) except Exception: return False def __ne__(self, other): return not self.__eq__(other) def __repr__(self): return "approx(%r)" % (self.expected,) def _build_shim(): """构造最小 pytest 替身模块。""" mod = types.ModuleType("pytest") mod.__shim__ = True mod.mark = _Mark() def fixture(fn=None, **kw): """@pytest.fixture / @pytest.fixture() / @pytest.fixture(scope=..) 三种写法。""" def deco(f): f._pytest_fixture = True f._fixture_kwargs = kw return f if callable(fn): return deco(fn) return deco def skip(reason=""): raise Skipped(reason) def raises(expected, *a, **kw): if a and callable(a[0]) and len(a) == 1: try: a[0](*kw.get("args", ()), **kw.get("kwargs", {})) except expected as exc: return exc raise AssertionError("DID NOT RAISE %r" % (expected,)) return _RaisesCtx(expected, kw.get("match")) def approx(expected, rel=None, abs=None): return _Approx(expected, rel=rel, **{"abs": abs}) def param(*values, **kw): return (values, kw.get("id")) class _Fail(Exception): pass def fail(reason=""): raise AssertionError(reason) mod.fixture = fixture mod.skip = skip mod.raises = raises mod.approx = approx mod.param = param mod.fail = fail mod.Skipped = Skipped mod.main = lambda *a, **kw: 0 return mod def install(): """返回可用的 pytest 模块(真实优先,缺失则装垫片并注册到 sys.modules)。""" global HAS_REAL_PYTEST try: real = importlib.import_module("pytest") if not getattr(real, "__shim__", False): HAS_REAL_PYTEST = True return real except Exception: pass shim = _build_shim() sys.modules["pytest"] = shim HAS_REAL_PYTEST = False return shim def _call_fixture(fx): """执行 fixture:generator -> (value, closer);普通函数 -> (value, None)。""" res = fx() if inspect.isgenerator(res): try: value = next(res) except StopIteration: value = None def closer(): try: next(res) except StopIteration: pass except Exception: pass finally: try: res.close() except Exception: pass return value, closer return res, None def _collect_fixtures(module): out = {} for name, obj in vars(module).items(): if callable(obj) and getattr(obj, "_pytest_fixture", False): out[name] = obj return out def run_module(path, verbose=True): """执行一个测试文件,返回 (passed, failed, skipped, lines)。""" passed = failed = skipped = 0 lines = [] name = os.path.basename(path)[:-3] sys.path.insert(0, os.path.dirname(os.path.abspath(path))) spec = importlib.util.spec_from_file_location(name, path) module = importlib.util.module_from_spec(spec) sys.modules[name] = module try: spec.loader.exec_module(module) except Exception: lines.append("IMPORT-ERROR %s\n%s" % (path, traceback.format_exc())) return 0, 1, 0, lines fixtures = _collect_fixtures(module) # 1) unittest.TestCase cases = [obj for _n, obj in vars(module).items() if isinstance(obj, type) and issubclass(obj, unittest.TestCase) and obj is not unittest.TestCase] if cases: suite = unittest.TestSuite() loader = unittest.TestLoader() for c in cases: suite.addTests(loader.loadTestsFromTestCase(c)) buf = io.StringIO() runner = unittest.TextTestRunner(stream=buf, verbosity=1) result = runner.run(suite) passed += result.testsRun - len(result.failures) - len(result.errors) - len(result.skipped) failed += len(result.failures) + len(result.errors) skipped += len(result.skipped) out = buf.getvalue().strip() if out: lines.append("[unittest %s] %s" % (name, out.splitlines()[-1])) for t, tb in list(result.failures) + list(result.errors): lines.append("FAIL %s\n%s" % (t, tb.strip())) # 2) 模块级 test_* 函数(垫片 fixture 注入) for fname in sorted(vars(module)): if not fname.startswith("test_"): continue fn = getattr(module, fname) if not callable(fn) or getattr(fn, "_pytest_fixture", False): continue sig = inspect.signature(fn) values, closers = {}, [] try: for pname in sig.parameters: if pname in fixtures: v, closer = _call_fixture(fixtures[pname]) values[pname] = v if closer: closers.append(closer) elif sig.parameters[pname].default is inspect.Parameter.empty: raise Skipped("no fixture for param %r" % pname) fn(**values) passed += 1 if verbose: lines.append("PASS %s::%s" % (name, fname)) except Skipped as exc: skipped += 1 lines.append("SKIP %s::%s (%s)" % (name, fname, exc)) except Exception: failed += 1 lines.append("FAIL %s::%s\n%s" % (name, fname, traceback.format_exc().strip())) finally: for closer in reversed(closers): try: closer() except Exception: pass return passed, failed, skipped, lines def run(paths, verbose=True): """批量执行测试文件,返回退出码(0 = 全绿)。""" install() tp = tf = ts = 0 all_lines = [] for p in paths: if not os.path.exists(p): all_lines.append("MISSING %s" % p) tf += 1 continue a, b, c, lines = run_module(p, verbose=verbose) tp += a tf += b ts += c all_lines.extend(lines) print("\n".join(all_lines)) print("=" * 60) print("PYTEST_REAL=%s PASSED=%d FAILED=%d SKIPPED=%d FILES=%d" % (HAS_REAL_PYTEST, tp, tf, ts, len(paths))) return 0 if tf == 0 else 1 if __name__ == "__main__": args = sys.argv[1:] here = os.path.dirname(os.path.abspath(__file__)) if not args: args = sorted(os.path.join(here, f) for f in os.listdir(here) if f.startswith("test_") and f.endswith(".py")) sys.exit(run(args))