32ab1a0561
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 1h3m24s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1h3m24s
275 lines
9.9 KiB
Python
275 lines
9.9 KiB
Python
"""
|
|
集成测试公共 fixtures
|
|
|
|
提供性能测试相关的工具、fixture 和 marker。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, field
|
|
from typing import Callable, Dict, List, Optional
|
|
|
|
import pytest
|
|
|
|
# ── 性能阈值配置 ──────────────────────────────────────────────────────────
|
|
PERF_THRESHOLDS: Dict[str, int] = {
|
|
"core": 500, # 核心接口:500ms
|
|
"normal": 1000, # 普通接口:1000ms
|
|
"heavy": 3000, # 重操作:3000ms(涉及外部调用或复杂计算)
|
|
}
|
|
|
|
# 性能测试是否跳过(通过环境变量控制)
|
|
SKIP_PERF_TESTS = os.environ.get("SKIP_PERF_TESTS", "").lower() in ("1", "true", "yes")
|
|
|
|
# 性能测试容忍度:允许一定比例的请求超标(避免CI偶发波动)
|
|
# 默认:3次请求中允许1次超标(取中位数判断)
|
|
PERF_SAMPLE_COUNT = int(os.environ.get("PERF_SAMPLE_COUNT", "3"))
|
|
PERF_TOLERANCE_RATIO = float(os.environ.get("PERF_TOLERANCE_RATIO", "0.34"))
|
|
|
|
|
|
# ── 数据类 ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class PerfResult:
|
|
"""单次性能测试结果"""
|
|
|
|
name: str
|
|
threshold_ms: int
|
|
times_ms: List[float] = field(default_factory=list)
|
|
status_code: Optional[int] = None
|
|
|
|
@property
|
|
def median_ms(self) -> float:
|
|
if not self.times_ms:
|
|
return 0.0
|
|
sorted_times = sorted(self.times_ms)
|
|
n = len(sorted_times)
|
|
if n % 2 == 0:
|
|
return (sorted_times[n // 2 - 1] + sorted_times[n // 2]) / 2
|
|
return sorted_times[n // 2]
|
|
|
|
@property
|
|
def mean_ms(self) -> float:
|
|
if not self.times_ms:
|
|
return 0.0
|
|
return sum(self.times_ms) / len(self.times_ms)
|
|
|
|
@property
|
|
def min_ms(self) -> float:
|
|
return min(self.times_ms) if self.times_ms else 0.0
|
|
|
|
@property
|
|
def max_ms(self) -> float:
|
|
return max(self.times_ms) if self.times_ms else 0.0
|
|
|
|
@property
|
|
def passed(self) -> bool:
|
|
"""判断是否通过:基于中位数 + 容忍比例"""
|
|
if not self.times_ms:
|
|
return False
|
|
# 中位数必须在阈值内
|
|
if self.median_ms > self.threshold_ms:
|
|
return False
|
|
# 超标比例不能超过容忍度
|
|
over_count = sum(1 for t in self.times_ms if t > self.threshold_ms)
|
|
over_ratio = over_count / len(self.times_ms)
|
|
return over_ratio <= PERF_TOLERANCE_RATIO
|
|
|
|
|
|
# ── 性能断言上下文管理器 ──────────────────────────────────────────────────
|
|
|
|
|
|
class PerfAssert:
|
|
"""
|
|
性能断言工具。
|
|
|
|
使用方式:
|
|
def test_login_performance(client, perf_assert):
|
|
with perf_assert("core", name="login") as result:
|
|
response = client.post("/api/v1/auth/login", json={...})
|
|
result.status_code = response.status_code
|
|
# 退出 with 块时自动断言
|
|
"""
|
|
|
|
def __init__(self, sample_count: int = PERF_SAMPLE_COUNT):
|
|
self.sample_count = sample_count
|
|
self.results: List[PerfResult] = []
|
|
|
|
@contextmanager
|
|
def __call__(self, threshold_level: str = "core", name: str = "", samples: Optional[int] = None):
|
|
"""
|
|
创建一个性能测试上下文。
|
|
|
|
Args:
|
|
threshold_level: 阈值级别 ("core", "normal", "heavy")
|
|
name: 测试名称(用于输出报告)
|
|
samples: 采样次数,默认使用全局配置
|
|
"""
|
|
if threshold_level not in PERF_THRESHOLDS:
|
|
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
|
|
|
|
threshold_ms = PERF_THRESHOLDS[threshold_level]
|
|
num_samples = samples or self.sample_count
|
|
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
|
|
|
|
# 预热(第一次请求可能有冷启动开销)
|
|
yield result
|
|
# 第一次调用已经记录在 result.times_ms 中(由调用方通过 measure 方法)
|
|
|
|
def measure(self, threshold_level: str = "core", name: str = "", samples: Optional[int] = None) -> Callable:
|
|
"""
|
|
返回一个装饰器/包装器,用于测量函数执行时间。
|
|
|
|
使用方式:
|
|
result = perf_assert.measure("core", "login")(
|
|
lambda: client.post("/api/v1/auth/login", json={...})
|
|
)
|
|
"""
|
|
|
|
def wrapper(func):
|
|
if threshold_level not in PERF_THRESHOLDS:
|
|
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
|
|
threshold_ms = PERF_THRESHOLDS[threshold_level]
|
|
num_samples = samples or self.sample_count
|
|
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
|
|
|
|
last_response = None
|
|
for i in range(num_samples):
|
|
start = time.perf_counter()
|
|
last_response = func()
|
|
elapsed = (time.perf_counter() - start) * 1000
|
|
result.times_ms.append(elapsed)
|
|
|
|
if hasattr(last_response, "status_code"):
|
|
result.status_code = last_response.status_code
|
|
|
|
self.results.append(result)
|
|
return result
|
|
|
|
return wrapper
|
|
|
|
def assert_all(self):
|
|
"""断言所有性能测试结果都通过"""
|
|
failed = [r for r in self.results if not r.passed]
|
|
if failed:
|
|
lines = []
|
|
for r in failed:
|
|
lines.append(
|
|
f" ❌ {r.name}: 中位数 {r.median_ms:.1f}ms "
|
|
f"(阈值 {r.threshold_ms}ms) "
|
|
f"[min={r.min_ms:.1f}, max={r.max_ms:.1f}, "
|
|
f"mean={r.mean_ms:.1f}, samples={len(r.times_ms)}]"
|
|
)
|
|
raise AssertionError(f"性能测试失败 ({len(failed)}/{len(self.results)}):\n" + "\n".join(lines))
|
|
|
|
def report(self) -> str:
|
|
"""生成性能报告文本"""
|
|
lines = ["=" * 60, " 性能测试报告", "=" * 60]
|
|
for r in self.results:
|
|
status = "✅" if r.passed else "❌"
|
|
lines.append(f" {status} {r.name:<40s} " f"median={r.median_ms:>7.1f}ms / {r.threshold_ms:>5d}ms")
|
|
lines.append(
|
|
f" min={r.min_ms:.1f}ms max={r.max_ms:.1f}ms "
|
|
f"mean={r.mean_ms:.1f}ms samples={len(r.times_ms)}"
|
|
f" status={r.status_code or 'N/A'}"
|
|
)
|
|
passed = sum(1 for r in self.results if r.passed)
|
|
lines.append("=" * 60)
|
|
lines.append(f" 总计: {passed}/{len(self.results)} 通过")
|
|
lines.append("=" * 60)
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ── pytest fixtures ──────────────────────────────────────────────────────
|
|
|
|
|
|
def pytest_configure(config):
|
|
"""注册自定义 marker"""
|
|
config.addinivalue_line("markers", "performance: 标记为性能测试(可通过 -m 'not performance' 跳过)")
|
|
config.addinivalue_line("markers", "perf_core: 核心接口性能测试(阈值 500ms)")
|
|
config.addinivalue_line("markers", "perf_normal: 普通接口性能测试(阈值 1000ms)")
|
|
config.addinivalue_line("markers", "perf_heavy: 重操作接口性能测试(阈值 3000ms)")
|
|
|
|
|
|
def pytest_collection_modifyitems(config, items):
|
|
"""根据环境变量自动跳过性能测试"""
|
|
if SKIP_PERF_TESTS:
|
|
skip_perf = pytest.mark.skip(reason="SKIP_PERF_TESTS=1,跳过性能测试")
|
|
for item in items:
|
|
if "performance" in item.keywords or "perf_" in item.keywords:
|
|
item.add_marker(skip_perf)
|
|
|
|
|
|
@pytest.fixture
|
|
def perf_assert():
|
|
"""
|
|
性能断言 fixture。
|
|
|
|
使用方式 1(推荐,自动断言):
|
|
def test_login(client, perf_assert):
|
|
@perf_assert.measure("core", "POST /auth/login")
|
|
def _call():
|
|
return client.post("/api/v1/auth/login", json={...})
|
|
|
|
result = _call()
|
|
assert result.status_code == 200
|
|
|
|
使用方式 2(手动多次调用):
|
|
def test_login(client, perf_assert):
|
|
result = perf_assert.run("core", "POST /auth/login",
|
|
lambda: client.post("/api/v1/auth/login", json={...})
|
|
)
|
|
assert result.status_code == 200
|
|
"""
|
|
return PerfAssert()
|
|
|
|
|
|
@pytest.fixture
|
|
def perf_thresholds():
|
|
"""返回性能阈值配置字典"""
|
|
return dict(PERF_THRESHOLDS)
|
|
|
|
|
|
# ── 辅助函数 ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def run_perf_test(
|
|
name: str,
|
|
threshold_level: str,
|
|
func: Callable,
|
|
samples: int = PERF_SAMPLE_COUNT,
|
|
) -> PerfResult:
|
|
"""
|
|
运行一次性能测试(独立函数,方便在 fixture 外部使用)。
|
|
|
|
Args:
|
|
name: 测试名称
|
|
threshold_level: 阈值级别
|
|
func: 要测量的函数(无参数)
|
|
samples: 采样次数
|
|
|
|
Returns:
|
|
PerfResult 对象
|
|
"""
|
|
if threshold_level not in PERF_THRESHOLDS:
|
|
raise ValueError(f"未知的阈值级别: {threshold_level},可选: {list(PERF_THRESHOLDS.keys())}")
|
|
|
|
threshold_ms = PERF_THRESHOLDS[threshold_level]
|
|
result = PerfResult(name=name, threshold_ms=threshold_ms)
|
|
|
|
last_response = None
|
|
for i in range(samples):
|
|
start = time.perf_counter()
|
|
last_response = func()
|
|
elapsed = (time.perf_counter() - start) * 1000
|
|
result.times_ms.append(elapsed)
|
|
|
|
if hasattr(last_response, "status_code"):
|
|
result.status_code = last_response.status_code
|
|
|
|
return result
|