feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "Test Team"
|
||||
__email__ = "test@example.com"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""CLI模块"""
|
||||
|
||||
from apitest.cli_module import cli
|
||||
|
||||
__all__ = ["cli"]
|
||||
@@ -0,0 +1,223 @@
|
||||
"""CLI命令行接口"""
|
||||
|
||||
import click
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from apitest.config.config_manager import ConfigManager
|
||||
from apitest.config.logger_manager import LoggerManager
|
||||
from apitest.orchestrator.test_orchestrator import TestOrchestrator
|
||||
from apitest.data.test_data_manager import TestDataManager
|
||||
from apitest.models.test_models import HTTPMethod
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version="1.0.0")
|
||||
def cli():
|
||||
"""黑盒API测试工具 - 命令行接口"""
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--test-cases", "-t", type=click.Path(exists=True), help="测试用例文件路径(JSON格式)")
|
||||
@click.option("--test-data", "-d", type=click.Path(exists=True), help="测试数据文件路径(CSV格式)")
|
||||
@click.option("--module", "-m", help="按模块过滤测试用例")
|
||||
@click.option("--tag", help="按标签过滤测试用例")
|
||||
@click.option("--priority", type=int, help="按优先级过滤测试用例")
|
||||
@click.option("--stop-on-failure", is_flag=True, help="在失败时停止执行")
|
||||
@click.option("--no-report", is_flag=True, help="不生成测试报告")
|
||||
@click.option("--report-format", type=click.Choice(["html", "json"]), default="html", help="报告格式")
|
||||
@click.option("--report-path", type=click.Path(), help="报告输出路径")
|
||||
@click.option("--verbose", "-v", is_flag=True, help="详细输出")
|
||||
def run(
|
||||
test_cases: Optional[str],
|
||||
test_data: Optional[str],
|
||||
module: Optional[str],
|
||||
tag: Optional[str],
|
||||
priority: Optional[int],
|
||||
stop_on_failure: bool,
|
||||
no_report: bool,
|
||||
report_format: str,
|
||||
report_path: Optional[str],
|
||||
verbose: bool
|
||||
):
|
||||
"""运行测试用例"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
logger_manager = LoggerManager(config_manager)
|
||||
logger = logger_manager.get_logger(__name__)
|
||||
|
||||
if verbose:
|
||||
logger.info("详细模式已启用")
|
||||
|
||||
orchestrator = TestOrchestrator(config_manager, logger_manager)
|
||||
data_manager = TestDataManager(logger)
|
||||
|
||||
if not test_cases:
|
||||
click.echo("错误: 请指定测试用例文件路径 (--test-cases)", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
test_cases_path = Path(test_cases)
|
||||
cases = data_manager.load_test_cases_from_json(test_cases_path)
|
||||
|
||||
if test_data:
|
||||
test_data_path = Path(test_data)
|
||||
data = data_manager.load_test_data_from_csv(test_data_path)
|
||||
|
||||
if len(cases) == 1:
|
||||
cases = data_manager.parameterize_test_case(cases[0], data)
|
||||
else:
|
||||
logger.warning("多个测试用例不支持参数化,测试数据将被忽略")
|
||||
|
||||
filtered_cases = _filter_test_cases(cases, module, tag, priority)
|
||||
|
||||
if len(filtered_cases) == 0:
|
||||
click.echo("警告: 没有匹配的测试用例")
|
||||
sys.exit(0)
|
||||
|
||||
logger.info(f"开始执行 {len(filtered_cases)} 个测试用例")
|
||||
|
||||
result = orchestrator.run_test_suite(
|
||||
filtered_cases,
|
||||
stop_on_failure=stop_on_failure,
|
||||
generate_report=not no_report,
|
||||
report_format=report_format,
|
||||
report_path=Path(report_path) if report_path else None
|
||||
)
|
||||
|
||||
logger.info(f"测试完成: 通过 {result.passed}, 失败 {result.failed}, 跳过 {result.skipped}")
|
||||
logger.info(f"通过率: {result.pass_rate:.2f}%")
|
||||
logger.info(f"执行时长: {result.duration:.2f}秒")
|
||||
|
||||
sys.exit(0 if result.failed == 0 else 1)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"执行测试时出错: {e}", err=True)
|
||||
if verbose:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("test-cases", type=click.Path(exists=True))
|
||||
@click.option("--module", "-m", help="按模块过滤")
|
||||
@click.option("--tag", help="按标签过滤")
|
||||
@click.option("--priority", type=int, help="按优先级过滤")
|
||||
def list(test_cases: str, module: Optional[str], tag: Optional[str], priority: Optional[int]):
|
||||
"""列出测试用例"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
logger_manager = LoggerManager(config_manager)
|
||||
logger = logger_manager.get_logger(__name__)
|
||||
|
||||
data_manager = TestDataManager(logger)
|
||||
cases = data_manager.load_test_cases_from_json(Path(test_cases))
|
||||
|
||||
filtered_cases = _filter_test_cases(cases, module, tag, priority)
|
||||
|
||||
click.echo(f"\n测试用例总数: {len(filtered_cases)}\n")
|
||||
|
||||
for case in filtered_cases:
|
||||
status = "✓" if case.enabled else "✗"
|
||||
click.echo(f"{status} {case.id}: {case.name}")
|
||||
click.echo(f" 模块: {case.module}")
|
||||
click.echo(f" 方法: {case.method.value} {case.endpoint}")
|
||||
click.echo(f" 优先级: {case.priority}")
|
||||
if case.tags:
|
||||
click.echo(f" 标签: {', '.join(case.tags)}")
|
||||
if case.dependencies:
|
||||
click.echo(f" 依赖: {', '.join(case.dependencies)}")
|
||||
click.echo()
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"列出测试用例时出错: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("test-cases", type=click.Path(exists=True))
|
||||
@click.option("--output", "-o", type=click.Path(), help="输出文件路径")
|
||||
def validate(test_cases: str, output: Optional[str]):
|
||||
"""验证测试用例文件"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
logger_manager = LoggerManager(config_manager)
|
||||
logger = logger_manager.get_logger(__name__)
|
||||
|
||||
data_manager = TestDataManager(logger)
|
||||
cases = data_manager.load_test_cases_from_json(Path(test_cases))
|
||||
|
||||
click.echo(f"验证测试用例文件: {test_cases}")
|
||||
click.echo(f"测试用例数量: {len(cases)}")
|
||||
|
||||
errors = []
|
||||
for i, case in enumerate(cases):
|
||||
if not case.id:
|
||||
errors.append(f"测试用例 {i+1}: 缺少ID")
|
||||
if not case.name:
|
||||
errors.append(f"测试用例 {i+1}: 缺少名称")
|
||||
if not case.endpoint:
|
||||
errors.append(f"测试用例 {i+1}: 缺少端点")
|
||||
if not case.method:
|
||||
errors.append(f"测试用例 {i+1}: 缺少HTTP方法")
|
||||
|
||||
if errors:
|
||||
click.echo("\n验证失败:")
|
||||
for error in errors:
|
||||
click.echo(f" - {error}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo("\n验证通过 ✓")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"验证测试用例时出错: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--key", "-k", help="配置键")
|
||||
def config(key: Optional[str]):
|
||||
"""查看配置"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
|
||||
if key:
|
||||
value = config_manager.get(key)
|
||||
click.echo(f"{key} = {value}")
|
||||
else:
|
||||
click.echo("当前配置:")
|
||||
click.echo(f" 基础URL: {config_manager.get_base_url()}")
|
||||
click.echo(f" 超时时间: {config_manager.get_timeout()}秒")
|
||||
click.echo(f" 日志级别: {config_manager.get_log_level()}")
|
||||
click.echo(f" 日志文件: {config_manager.get_log_file()}")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"查看配置时出错: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _filter_test_cases(
|
||||
cases: list,
|
||||
module: Optional[str],
|
||||
tag: Optional[str],
|
||||
priority: Optional[int]
|
||||
) -> list:
|
||||
"""过滤测试用例"""
|
||||
filtered = cases
|
||||
|
||||
if module:
|
||||
filtered = [c for c in filtered if c.module == module]
|
||||
|
||||
if tag:
|
||||
filtered = [c for c in filtered if tag in c.tags]
|
||||
|
||||
if priority is not None:
|
||||
filtered = [c for c in filtered if c.priority == priority]
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,4 @@
|
||||
from .api_client import APIClient
|
||||
from .auth_manager import AuthManager
|
||||
|
||||
__all__ = ["APIClient", "AuthManager"]
|
||||
@@ -0,0 +1,306 @@
|
||||
import time
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from datetime import datetime
|
||||
import requests
|
||||
from apitest.models.test_models import HTTPMethod, PerformanceMetrics
|
||||
from apitest.models.exceptions import RequestException
|
||||
|
||||
|
||||
class APIClient:
|
||||
"""API客户端"""
|
||||
|
||||
def __init__(self, base_url: str, timeout: int = 5000, max_retries: int = 3, logger=None):
|
||||
"""初始化API客户端
|
||||
|
||||
Args:
|
||||
base_url: 基础URL
|
||||
timeout: 超时时间(毫秒)
|
||||
max_retries: 最大重试次数
|
||||
logger: 日志记录器
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout / 1000
|
||||
self.max_retries = max_retries
|
||||
self.logger = logger
|
||||
self._session = requests.Session()
|
||||
self._default_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
def set_default_headers(self, headers: Dict[str, str]):
|
||||
"""设置默认请求头
|
||||
|
||||
Args:
|
||||
headers: 请求头字典
|
||||
"""
|
||||
self._default_headers.update(headers)
|
||||
|
||||
def set_auth_token(self, token: str):
|
||||
"""设置认证token
|
||||
|
||||
Args:
|
||||
token: 认证token
|
||||
"""
|
||||
self._default_headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
def _build_url(self, endpoint: str) -> str:
|
||||
"""构建完整URL
|
||||
|
||||
Args:
|
||||
endpoint: API端点
|
||||
|
||||
Returns:
|
||||
完整URL
|
||||
"""
|
||||
endpoint = endpoint.lstrip("/")
|
||||
return f"{self.base_url}/{endpoint}"
|
||||
|
||||
def _merge_headers(self, headers: Optional[Dict[str, str]]) -> Dict[str, str]:
|
||||
"""合并请求头
|
||||
|
||||
Args:
|
||||
headers: 请求头字典
|
||||
|
||||
Returns:
|
||||
合并后的请求头
|
||||
"""
|
||||
merged = self._default_headers.copy()
|
||||
if headers:
|
||||
merged.update(headers)
|
||||
return merged
|
||||
|
||||
def _calculate_metrics(
|
||||
self,
|
||||
start_time: float,
|
||||
request_data: Union[Dict, str, None],
|
||||
response_data: Any
|
||||
) -> PerformanceMetrics:
|
||||
"""计算性能指标
|
||||
|
||||
Args:
|
||||
start_time: 请求开始时间
|
||||
request_data: 请求数据
|
||||
response_data: 响应数据
|
||||
|
||||
Returns:
|
||||
性能指标
|
||||
"""
|
||||
end_time = time.time()
|
||||
response_time = int((end_time - start_time) * 1000)
|
||||
|
||||
request_size = 0
|
||||
if request_data:
|
||||
if isinstance(request_data, dict):
|
||||
request_size = len(str(request_data))
|
||||
elif isinstance(request_data, str):
|
||||
request_size = len(request_data)
|
||||
|
||||
response_size = 0
|
||||
if response_data:
|
||||
response_size = len(str(response_data))
|
||||
|
||||
return PerformanceMetrics(
|
||||
response_time=response_time,
|
||||
request_size=request_size,
|
||||
response_size=response_size,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
def _execute_request(
|
||||
self,
|
||||
method: HTTPMethod,
|
||||
url: str,
|
||||
headers: Dict[str, str],
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
body: Optional[Dict[str, Any]] = None
|
||||
) -> requests.Response:
|
||||
"""执行HTTP请求
|
||||
|
||||
Args:
|
||||
method: HTTP方法
|
||||
url: 请求URL
|
||||
headers: 请求头
|
||||
params: URL参数
|
||||
body: 请求体
|
||||
|
||||
Returns:
|
||||
响应对象
|
||||
|
||||
Raises:
|
||||
RequestException: 请求失败时抛出
|
||||
"""
|
||||
try:
|
||||
if method == HTTPMethod.GET:
|
||||
return self._session.get(url, headers=headers, params=params, timeout=self.timeout)
|
||||
elif method == HTTPMethod.POST:
|
||||
return self._session.post(url, headers=headers, params=params, json=body, timeout=self.timeout)
|
||||
elif method == HTTPMethod.PUT:
|
||||
return self._session.put(url, headers=headers, params=params, json=body, timeout=self.timeout)
|
||||
elif method == HTTPMethod.DELETE:
|
||||
return self._session.delete(url, headers=headers, params=params, timeout=self.timeout)
|
||||
elif method == HTTPMethod.PATCH:
|
||||
return self._session.patch(url, headers=headers, params=params, json=body, timeout=self.timeout)
|
||||
elif method == HTTPMethod.HEAD:
|
||||
return self._session.head(url, headers=headers, params=params, timeout=self.timeout)
|
||||
elif method == HTTPMethod.OPTIONS:
|
||||
return self._session.options(url, headers=headers, params=params, timeout=self.timeout)
|
||||
else:
|
||||
raise RequestException(f"不支持的HTTP方法: {method}")
|
||||
|
||||
except requests.Timeout:
|
||||
raise RequestException(f"请求超时: {url}")
|
||||
except requests.ConnectionError:
|
||||
raise RequestException(f"连接失败: {url}")
|
||||
except requests.RequestException as e:
|
||||
raise RequestException(f"请求异常: {e}")
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: HTTPMethod,
|
||||
endpoint: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
body: Optional[Dict[str, Any]] = None,
|
||||
retry_count: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""发送HTTP请求
|
||||
|
||||
Args:
|
||||
method: HTTP方法
|
||||
endpoint: API端点
|
||||
headers: 请求头
|
||||
params: URL参数
|
||||
body: 请求体
|
||||
retry_count: 当前重试次数
|
||||
|
||||
Returns:
|
||||
包含响应数据和性能指标的字典
|
||||
|
||||
Raises:
|
||||
RequestException: 请求失败且重试次数用尽时抛出
|
||||
"""
|
||||
url = self._build_url(endpoint)
|
||||
merged_headers = self._merge_headers(headers)
|
||||
|
||||
if self.logger:
|
||||
self.logger.debug(f"发送{method.value}请求: {url}")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
response = self._execute_request(method, url, merged_headers, params, body)
|
||||
|
||||
try:
|
||||
response_body = response.json()
|
||||
except ValueError:
|
||||
response_body = response.text
|
||||
|
||||
performance = self._calculate_metrics(start_time, body, response_body)
|
||||
|
||||
if self.logger:
|
||||
self.logger.debug(
|
||||
f"响应: HTTP {response.status_code}, "
|
||||
f"耗时: {performance.response_time}ms, "
|
||||
f"大小: {performance.response_size}字节"
|
||||
)
|
||||
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"response_body": response_body,
|
||||
"response_headers": dict(response.headers),
|
||||
"performance": performance
|
||||
}
|
||||
|
||||
except RequestException as e:
|
||||
if retry_count < self.max_retries:
|
||||
if self.logger:
|
||||
self.logger.warning(f"请求失败,正在重试 ({retry_count + 1}/{self.max_retries}): {e}")
|
||||
time.sleep(1 * (retry_count + 1))
|
||||
return self.request(method, endpoint, headers, params, body, retry_count + 1)
|
||||
else:
|
||||
if self.logger:
|
||||
self.logger.error(f"请求失败,重试次数用尽: {e}")
|
||||
raise
|
||||
|
||||
def get(
|
||||
self,
|
||||
endpoint: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
params: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""发送GET请求
|
||||
|
||||
Args:
|
||||
endpoint: API端点
|
||||
headers: 请求头
|
||||
params: URL参数
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return self.request(HTTPMethod.GET, endpoint, headers, params)
|
||||
|
||||
def post(
|
||||
self,
|
||||
endpoint: str,
|
||||
body: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
params: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""发送POST请求
|
||||
|
||||
Args:
|
||||
endpoint: API端点
|
||||
body: 请求体
|
||||
headers: 请求头
|
||||
params: URL参数
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return self.request(HTTPMethod.POST, endpoint, headers, params, body)
|
||||
|
||||
def put(
|
||||
self,
|
||||
endpoint: str,
|
||||
body: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
params: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""发送PUT请求
|
||||
|
||||
Args:
|
||||
endpoint: API端点
|
||||
body: 请求体
|
||||
headers: 请求头
|
||||
params: URL参数
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return self.request(HTTPMethod.PUT, endpoint, headers, params, body)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
endpoint: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
params: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""发送DELETE请求
|
||||
|
||||
Args:
|
||||
endpoint: API端点
|
||||
headers: 请求头
|
||||
params: URL参数
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
return self.request(HTTPMethod.DELETE, endpoint, headers, params)
|
||||
|
||||
def close(self):
|
||||
"""关闭会话"""
|
||||
self._session.close()
|
||||
if self.logger:
|
||||
self.logger.debug("API客户端会话已关闭")
|
||||
@@ -0,0 +1,201 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from apitest.models.exceptions import AuthException
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""认证管理器"""
|
||||
|
||||
def __init__(self, base_url: str, credentials: Dict[str, str], logger):
|
||||
"""初始化认证管理器
|
||||
|
||||
Args:
|
||||
base_url: 基础URL
|
||||
credentials: 认证凭据
|
||||
logger: 日志记录器
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.credentials = credentials
|
||||
self.logger = logger
|
||||
self._token: Optional[str] = None
|
||||
self._token_expiry: Optional[datetime] = None
|
||||
self._refresh_token: Optional[str] = None
|
||||
self._login_endpoint: str = "/sys/auth/login"
|
||||
|
||||
def set_login_endpoint(self, endpoint: str):
|
||||
"""设置登录端点
|
||||
|
||||
Args:
|
||||
endpoint: 登录端点
|
||||
"""
|
||||
self._login_endpoint = endpoint
|
||||
|
||||
def login(self, login_endpoint: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""执行登录操作
|
||||
|
||||
Args:
|
||||
login_endpoint: 登录端点,默认使用配置的端点
|
||||
|
||||
Returns:
|
||||
登录响应数据
|
||||
|
||||
Raises:
|
||||
AuthException: 登录失败时抛出
|
||||
"""
|
||||
endpoint = login_endpoint or self._login_endpoint
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
|
||||
self.logger.info(f"尝试登录: {url}")
|
||||
|
||||
try:
|
||||
import requests
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
json={
|
||||
"username": self.credentials.get("username", ""),
|
||||
"password": self.credentials.get("password", "")
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
if "data" in data and "token" in data["data"]:
|
||||
self._token = data["data"]["token"]
|
||||
self._refresh_token = data["data"].get("refreshToken")
|
||||
|
||||
expiry_seconds = data["data"].get("expiresIn", 3600)
|
||||
self._token_expiry = datetime.now() + timedelta(seconds=expiry_seconds)
|
||||
|
||||
self.logger.info("登录成功")
|
||||
return data
|
||||
else:
|
||||
raise AuthException("登录响应中未找到token")
|
||||
else:
|
||||
raise AuthException(f"登录失败: HTTP {response.status_code}")
|
||||
|
||||
except requests.RequestException as e:
|
||||
raise AuthException(f"登录请求失败: {e}")
|
||||
|
||||
def get_token(self) -> Optional[str]:
|
||||
"""获取当前token
|
||||
|
||||
Returns:
|
||||
当前token,如果未登录则返回None
|
||||
"""
|
||||
return self._token
|
||||
|
||||
def set_token(self, token: str, expiry_seconds: int = 3600):
|
||||
"""设置token
|
||||
|
||||
Args:
|
||||
token: 认证令牌
|
||||
expiry_seconds: 过期时间(秒),默认3600秒
|
||||
"""
|
||||
self._token = token
|
||||
self._token_expiry = datetime.now() + timedelta(seconds=expiry_seconds)
|
||||
self.logger.info("Token已设置")
|
||||
|
||||
def is_token_valid(self) -> bool:
|
||||
"""检查token是否有效
|
||||
|
||||
Returns:
|
||||
token是否有效
|
||||
"""
|
||||
if not self._token or not self._token_expiry:
|
||||
return False
|
||||
|
||||
return datetime.now() < self._token_expiry
|
||||
|
||||
def refresh_token(self) -> bool:
|
||||
"""刷新token
|
||||
|
||||
Returns:
|
||||
刷新是否成功
|
||||
"""
|
||||
if not self._refresh_token:
|
||||
self.logger.warning("没有可用的refresh token")
|
||||
return False
|
||||
|
||||
try:
|
||||
import requests
|
||||
|
||||
url = f"{self.base_url}/sys/auth/refresh"
|
||||
response = requests.post(
|
||||
url,
|
||||
json={"refreshToken": self._refresh_token},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
if "data" in data and "token" in data["data"]:
|
||||
self._token = data["data"]["token"]
|
||||
self._refresh_token = data["data"].get("refreshToken")
|
||||
|
||||
expiry_seconds = data["data"].get("expiresIn", 3600)
|
||||
self._token_expiry = datetime.now() + timedelta(seconds=expiry_seconds)
|
||||
|
||||
self.logger.info("Token刷新成功")
|
||||
return True
|
||||
|
||||
self.logger.warning("Token刷新失败,尝试重新登录")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Token刷新异常: {e}")
|
||||
return False
|
||||
|
||||
def ensure_authenticated(self) -> str:
|
||||
"""确保已认证,如果token过期则自动刷新或重新登录
|
||||
|
||||
Returns:
|
||||
有效的token
|
||||
|
||||
Raises:
|
||||
AuthException: 认证失败时抛出
|
||||
"""
|
||||
if not self._token:
|
||||
self.login()
|
||||
elif not self.is_token_valid():
|
||||
if not self.refresh_token():
|
||||
self.login()
|
||||
|
||||
if not self._token:
|
||||
raise AuthException("无法获取有效的认证token")
|
||||
|
||||
return self._token
|
||||
|
||||
def logout(self):
|
||||
"""登出"""
|
||||
self._token = None
|
||||
self._refresh_token = None
|
||||
self._token_expiry = None
|
||||
self.logger.info("已登出")
|
||||
|
||||
def get_auth_headers(self) -> Dict[str, str]:
|
||||
"""获取认证请求头
|
||||
|
||||
Returns:
|
||||
包含认证信息的请求头字典
|
||||
"""
|
||||
token = self.ensure_authenticated()
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def set_credentials(self, username: str, password: str):
|
||||
"""设置认证凭据
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
password: 密码
|
||||
"""
|
||||
self.credentials = {"username": username, "password": password}
|
||||
self.logger.info("认证凭据已更新")
|
||||
@@ -0,0 +1,4 @@
|
||||
from .config_manager import ConfigManager
|
||||
from .logger_manager import LoggerManager, setup_logger
|
||||
|
||||
__all__ = ["ConfigManager", "LoggerManager", "setup_logger"]
|
||||
@@ -0,0 +1,175 @@
|
||||
import os
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
from dotenv import load_dotenv
|
||||
from apitest.models.exceptions import ConfigException
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""配置管理器"""
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None):
|
||||
"""初始化配置管理器
|
||||
|
||||
Args:
|
||||
config_path: 配置文件路径,默认为项目根目录的config/config.yaml
|
||||
"""
|
||||
if config_path is None:
|
||||
project_root = Path(__file__).parent.parent.parent.parent
|
||||
config_path = project_root / "config" / "config.yaml"
|
||||
|
||||
self.config_path = Path(config_path)
|
||||
self._config: Dict[str, Any] = {}
|
||||
self._load_config()
|
||||
self._load_env_vars()
|
||||
|
||||
def _load_config(self):
|
||||
"""加载配置文件"""
|
||||
if not self.config_path.exists():
|
||||
raise ConfigException(f"配置文件不存在: {self.config_path}")
|
||||
|
||||
try:
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
self._config = yaml.safe_load(f) or {}
|
||||
except yaml.YAMLError as e:
|
||||
raise ConfigException(f"配置文件解析失败: {e}")
|
||||
except Exception as e:
|
||||
raise ConfigException(f"加载配置文件失败: {e}")
|
||||
|
||||
def _load_env_vars(self):
|
||||
"""加载环境变量"""
|
||||
env_file = Path(__file__).parent.parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""获取配置值
|
||||
|
||||
Args:
|
||||
key: 配置键,支持点号分隔的嵌套键(如:target.base_url)
|
||||
default: 默认值
|
||||
|
||||
Returns:
|
||||
配置值
|
||||
"""
|
||||
keys = key.split(".")
|
||||
value = self._config
|
||||
|
||||
for k in keys:
|
||||
if isinstance(value, dict) and k in value:
|
||||
value = value[k]
|
||||
else:
|
||||
return default
|
||||
|
||||
return value
|
||||
|
||||
def get_target_config(self) -> Dict[str, Any]:
|
||||
"""获取目标系统配置"""
|
||||
return self.get("target", {})
|
||||
|
||||
def get_auth_config(self) -> Dict[str, Any]:
|
||||
"""获取认证配置"""
|
||||
return self.get("auth", {})
|
||||
|
||||
def get_test_config(self) -> Dict[str, Any]:
|
||||
"""获取测试配置"""
|
||||
return self.get("test", {})
|
||||
|
||||
def get_report_config(self) -> Dict[str, Any]:
|
||||
"""获取报告配置"""
|
||||
return self.get("report", {})
|
||||
|
||||
def get_logging_config(self) -> Dict[str, Any]:
|
||||
"""获取日志配置"""
|
||||
return self.get("logging", {})
|
||||
|
||||
def get_data_config(self) -> Dict[str, Any]:
|
||||
"""获取数据配置"""
|
||||
return self.get("data", {})
|
||||
|
||||
def get_base_url(self) -> str:
|
||||
"""获取基础URL"""
|
||||
return self.get("target.base_url", "")
|
||||
|
||||
def get_timeout(self) -> int:
|
||||
"""获取超时时间(毫秒)"""
|
||||
return self.get("target.timeout", 5000)
|
||||
|
||||
def get_max_retries(self) -> int:
|
||||
"""获取最大重试次数"""
|
||||
return self.get("target.max_retries", 3)
|
||||
|
||||
def get_auth_credentials(self) -> Dict[str, str]:
|
||||
"""获取认证凭据"""
|
||||
auth_config = self.get_auth_config()
|
||||
username = os.getenv("TEST_USERNAME", auth_config.get("username", ""))
|
||||
password = os.getenv("TEST_PASSWORD", auth_config.get("password", ""))
|
||||
return {"username": username, "password": password}
|
||||
|
||||
def get_login_endpoint(self) -> str:
|
||||
"""获取登录端点"""
|
||||
return self.get("auth.login_endpoint", "/sys/auth/login")
|
||||
|
||||
def get_data_dir(self) -> Path:
|
||||
"""获取数据目录"""
|
||||
data_dir = self.get("test.data_dir", "data")
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / data_dir
|
||||
|
||||
def get_test_cases_dir(self) -> Path:
|
||||
"""获取测试用例目录"""
|
||||
test_cases_dir = self.get("test.test_cases_dir", "test_cases")
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / test_cases_dir
|
||||
|
||||
def get_report_dir(self) -> Path:
|
||||
"""获取报告目录"""
|
||||
report_dir = self.get("report.output_dir", "reports")
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / report_dir
|
||||
|
||||
def is_parallel_enabled(self) -> bool:
|
||||
"""是否启用并行执行"""
|
||||
return self.get("test.parallel", False)
|
||||
|
||||
def get_parallel_threads(self) -> int:
|
||||
"""获取并行线程数"""
|
||||
return self.get("test.parallel_threads", 4)
|
||||
|
||||
def get_retry_count(self) -> int:
|
||||
"""获取重试次数"""
|
||||
return self.get("test.retry_count", 2)
|
||||
|
||||
def should_stop_on_failure(self) -> bool:
|
||||
"""是否在失败时停止"""
|
||||
return self.get("test.stop_on_failure", False)
|
||||
|
||||
def get_max_response_time(self) -> int:
|
||||
"""获取最大响应时间(毫秒)"""
|
||||
return self.get("test.max_response_time", 5000)
|
||||
|
||||
def get_report_format(self) -> str:
|
||||
"""获取报告格式"""
|
||||
return self.get("report.format", "html")
|
||||
|
||||
def get_log_level(self) -> str:
|
||||
"""获取日志级别"""
|
||||
return self.get("logging.level", "INFO")
|
||||
|
||||
def get_log_format(self) -> str:
|
||||
"""获取日志格式"""
|
||||
return self.get("logging.format", "%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
|
||||
def get_log_file(self) -> Optional[Path]:
|
||||
"""获取日志文件路径"""
|
||||
log_file = self.get("logging.file")
|
||||
if log_file:
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
return project_root / log_file
|
||||
return None
|
||||
|
||||
def reload(self):
|
||||
"""重新加载配置"""
|
||||
self._load_config()
|
||||
self._load_env_vars()
|
||||
@@ -0,0 +1,103 @@
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from apitest.config.config_manager import ConfigManager
|
||||
|
||||
|
||||
class LoggerManager:
|
||||
"""日志管理器"""
|
||||
|
||||
def __init__(self, config_manager: ConfigManager):
|
||||
"""初始化日志管理器
|
||||
|
||||
Args:
|
||||
config_manager: 配置管理器实例
|
||||
"""
|
||||
self.config_manager = config_manager
|
||||
self._loggers: dict = {}
|
||||
self._setup_root_logger()
|
||||
|
||||
def _setup_root_logger(self):
|
||||
"""设置根日志记录器"""
|
||||
log_level = self.config_manager.get_log_level()
|
||||
log_format = self.config_manager.get_log_format()
|
||||
log_file = self.config_manager.get_log_file()
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, log_level.upper(), logging.INFO))
|
||||
|
||||
formatter = logging.Formatter(log_format)
|
||||
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
if log_file:
|
||||
log_file_path = Path(log_file)
|
||||
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_handler = logging.FileHandler(log_file_path, encoding="utf-8")
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
def get_logger(self, name: str) -> logging.Logger:
|
||||
"""获取日志记录器
|
||||
|
||||
Args:
|
||||
name: 日志记录器名称
|
||||
|
||||
Returns:
|
||||
日志记录器实例
|
||||
"""
|
||||
if name not in self._loggers:
|
||||
self._loggers[name] = logging.getLogger(name)
|
||||
return self._loggers[name]
|
||||
|
||||
def set_level(self, level: str):
|
||||
"""设置日志级别
|
||||
|
||||
Args:
|
||||
level: 日志级别(DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
"""
|
||||
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||
logging.getLogger().setLevel(log_level)
|
||||
|
||||
def add_file_handler(self, file_path: Path, level: Optional[str] = None):
|
||||
"""添加文件处理器
|
||||
|
||||
Args:
|
||||
file_path: 日志文件路径
|
||||
level: 日志级别
|
||||
"""
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler = logging.FileHandler(file_path, encoding="utf-8")
|
||||
|
||||
log_format = self.config_manager.get_log_format()
|
||||
formatter = logging.Formatter(log_format)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
if level:
|
||||
handler.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
|
||||
logging.getLogger().addHandler(handler)
|
||||
|
||||
def remove_all_handlers(self):
|
||||
"""移除所有处理器"""
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
|
||||
def setup_logger(config_manager: ConfigManager) -> LoggerManager:
|
||||
"""设置日志系统
|
||||
|
||||
Args:
|
||||
config_manager: 配置管理器实例
|
||||
|
||||
Returns:
|
||||
日志管理器实例
|
||||
"""
|
||||
return LoggerManager(config_manager)
|
||||
@@ -0,0 +1,4 @@
|
||||
from .test_engine import TestEngine
|
||||
from .validation_engine import ValidationEngine
|
||||
|
||||
__all__ = ["TestEngine", "ValidationEngine"]
|
||||
@@ -0,0 +1,400 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from apitest.models.test_models import (
|
||||
TestCase, TestResult, TestSuiteResult, HTTPMethod, PerformanceMetrics
|
||||
)
|
||||
from apitest.client.api_client import APIClient
|
||||
from apitest.client.auth_manager import AuthManager
|
||||
from apitest.core.validation_engine import ValidationEngine
|
||||
from apitest.models.exceptions import TestRunException, RequestException, ValidationException
|
||||
|
||||
|
||||
class TestEngine:
|
||||
"""测试引擎"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_client: APIClient,
|
||||
auth_manager: Optional[AuthManager] = None,
|
||||
validation_engine: Optional[ValidationEngine] = None,
|
||||
logger=None
|
||||
):
|
||||
"""初始化测试引擎
|
||||
|
||||
Args:
|
||||
api_client: API客户端
|
||||
auth_manager: 认证管理器
|
||||
validation_engine: 验证引擎
|
||||
logger: 日志记录器
|
||||
"""
|
||||
self.api_client = api_client
|
||||
self.auth_manager = auth_manager
|
||||
self.validation_engine = validation_engine or ValidationEngine(logger)
|
||||
self.logger = logger
|
||||
self._context: Dict[str, Any] = {}
|
||||
self._dependency_map: Dict[str, List[str]] = defaultdict(list)
|
||||
self._reverse_dependency_map: Dict[str, List[str]] = defaultdict(list)
|
||||
|
||||
def set_context(self, key: str, value: Any):
|
||||
"""设置上下文变量
|
||||
|
||||
Args:
|
||||
key: 键
|
||||
value: 值
|
||||
"""
|
||||
self._context[key] = value
|
||||
if self.logger:
|
||||
self.logger.debug(f"设置上下文变量: {key}")
|
||||
|
||||
def get_context(self, key: str, default: Any = None) -> Any:
|
||||
"""获取上下文变量
|
||||
|
||||
Args:
|
||||
key: 键
|
||||
default: 默认值
|
||||
|
||||
Returns:
|
||||
值
|
||||
"""
|
||||
return self._context.get(key, default)
|
||||
|
||||
def _build_dependency_graph(self, test_cases: List[TestCase]):
|
||||
"""构建依赖关系图
|
||||
|
||||
Args:
|
||||
test_cases: 测试用例列表
|
||||
"""
|
||||
self._dependency_map.clear()
|
||||
self._reverse_dependency_map.clear()
|
||||
|
||||
for test_case in test_cases:
|
||||
for dep_id in test_case.dependencies:
|
||||
self._dependency_map[test_case.id].append(dep_id)
|
||||
self._reverse_dependency_map[dep_id].append(test_case.id)
|
||||
|
||||
if self.logger:
|
||||
self.logger.debug(f"依赖关系图构建完成: {len(self._dependency_map)} 个依赖关系")
|
||||
|
||||
def _topological_sort(self, test_cases: List[TestCase]) -> List[TestCase]:
|
||||
"""拓扑排序测试用例
|
||||
|
||||
Args:
|
||||
test_cases: 测试用例列表
|
||||
|
||||
Returns:
|
||||
排序后的测试用例列表
|
||||
|
||||
Raises:
|
||||
TestRunException: 存在循环依赖时抛出
|
||||
"""
|
||||
self._build_dependency_graph(test_cases)
|
||||
|
||||
in_degree = {tc.id: 0 for tc in test_cases}
|
||||
test_case_map = {tc.id: tc for tc in test_cases}
|
||||
|
||||
for tc in test_cases:
|
||||
for dep_id in tc.dependencies:
|
||||
if dep_id in in_degree:
|
||||
in_degree[tc.id] += 1
|
||||
|
||||
queue = [tc_id for tc_id, degree in in_degree.items() if degree == 0]
|
||||
result = []
|
||||
|
||||
while queue:
|
||||
current_id = queue.pop(0)
|
||||
result.append(test_case_map[current_id])
|
||||
|
||||
for dependent_id in self._reverse_dependency_map[current_id]:
|
||||
in_degree[dependent_id] -= 1
|
||||
if in_degree[dependent_id] == 0:
|
||||
queue.append(dependent_id)
|
||||
|
||||
if len(result) != len(test_cases):
|
||||
raise TestRunException("存在循环依赖,无法确定测试用例执行顺序")
|
||||
|
||||
return result
|
||||
|
||||
def _prepare_request_data(self, test_case: TestCase) -> Dict[str, Any]:
|
||||
"""准备请求数据
|
||||
|
||||
Args:
|
||||
test_case: 测试用例
|
||||
|
||||
Returns:
|
||||
准备好的请求数据
|
||||
"""
|
||||
params = test_case.params.copy() if test_case.params else {}
|
||||
body = test_case.body.copy() if test_case.body else {}
|
||||
|
||||
params = self._resolve_context_variables(params)
|
||||
body = self._resolve_context_variables(body)
|
||||
|
||||
return {"params": params, "body": body}
|
||||
|
||||
def _resolve_context_variables(self, data: Any) -> Any:
|
||||
"""解析上下文变量
|
||||
|
||||
Args:
|
||||
data: 数据
|
||||
|
||||
Returns:
|
||||
解析后的数据
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
if data.startswith("${") and data.endswith("}"):
|
||||
var_name = data[2:-1]
|
||||
return self.get_context(var_name, data)
|
||||
return data
|
||||
elif isinstance(data, dict):
|
||||
return {k: self._resolve_context_variables(v) for k, v in data.items()}
|
||||
elif isinstance(data, list):
|
||||
return [self._resolve_context_variables(item) for item in data]
|
||||
else:
|
||||
return data
|
||||
|
||||
def _execute_setup(self, test_case: TestCase):
|
||||
"""执行前置操作
|
||||
|
||||
Args:
|
||||
test_case: 测试用例
|
||||
"""
|
||||
if not test_case.setup:
|
||||
return
|
||||
|
||||
setup_type = test_case.setup.get("type")
|
||||
|
||||
if setup_type == "set_context":
|
||||
key = test_case.setup.get("key")
|
||||
value = test_case.setup.get("value")
|
||||
self.set_context(key, value)
|
||||
elif setup_type == "sleep":
|
||||
import time
|
||||
time.sleep(test_case.setup.get("seconds", 1))
|
||||
|
||||
def _execute_teardown(self, test_case: TestCase):
|
||||
"""执行后置操作
|
||||
|
||||
Args:
|
||||
test_case: 测试用例
|
||||
"""
|
||||
if not test_case.teardown:
|
||||
return
|
||||
|
||||
teardown_type = test_case.teardown.get("type")
|
||||
|
||||
if teardown_type == "clear_context":
|
||||
key = test_case.teardown.get("key")
|
||||
if key in self._context:
|
||||
del self._context[key]
|
||||
elif teardown_type == "sleep":
|
||||
import time
|
||||
time.sleep(test_case.teardown.get("seconds", 1))
|
||||
|
||||
def _execute_test_case(self, test_case: TestCase) -> TestResult:
|
||||
"""执行单个测试用例
|
||||
|
||||
Args:
|
||||
test_case: 测试用例
|
||||
|
||||
Returns:
|
||||
测试结果
|
||||
"""
|
||||
if self.logger:
|
||||
self.logger.info(f"执行测试用例: {test_case.name} ({test_case.id})")
|
||||
|
||||
try:
|
||||
self._execute_setup(test_case)
|
||||
|
||||
request_data = self._prepare_request_data(test_case)
|
||||
|
||||
headers = test_case.headers.copy() if test_case.headers else {}
|
||||
|
||||
if test_case.auth_required and self.auth_manager:
|
||||
auth_headers = self.auth_manager.get_auth_headers()
|
||||
headers.update(auth_headers)
|
||||
|
||||
response_data = self.api_client.request(
|
||||
method=test_case.method,
|
||||
endpoint=test_case.endpoint,
|
||||
headers=headers,
|
||||
params=request_data.get("params"),
|
||||
body=request_data.get("body"),
|
||||
retry_count=test_case.retry_count
|
||||
)
|
||||
|
||||
status_code = response_data["status_code"]
|
||||
response_body = response_data["response_body"]
|
||||
response_headers = response_data["response_headers"]
|
||||
performance = response_data["performance"]
|
||||
|
||||
passed, error_message = self.validation_engine.validate_response(
|
||||
test_case,
|
||||
status_code,
|
||||
response_body,
|
||||
response_headers
|
||||
)
|
||||
|
||||
if passed and test_case.validations:
|
||||
self._extract_response_data(test_case, response_body)
|
||||
|
||||
test_result = TestResult(
|
||||
test_case=test_case,
|
||||
passed=passed,
|
||||
status_code=status_code,
|
||||
response_body=response_body,
|
||||
response_headers=response_headers,
|
||||
error_message=error_message if not passed else None,
|
||||
performance=performance,
|
||||
execution_time=performance.response_time / 1000.0,
|
||||
retry_count=test_case.retry_count
|
||||
)
|
||||
|
||||
self._execute_teardown(test_case)
|
||||
|
||||
if self.logger:
|
||||
if passed:
|
||||
self.logger.info(f"测试用例通过: {test_case.name}")
|
||||
else:
|
||||
self.logger.error(f"测试用例失败: {test_case.name} - {error_message}")
|
||||
|
||||
return test_result
|
||||
|
||||
except RequestException as e:
|
||||
if self.logger:
|
||||
self.logger.error(f"请求异常: {test_case.name} - {str(e)}")
|
||||
|
||||
return TestResult(
|
||||
test_case=test_case,
|
||||
passed=False,
|
||||
status_code=0,
|
||||
response_body=None,
|
||||
response_headers={},
|
||||
error_message=f"请求异常: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.error(f"执行异常: {test_case.name} - {str(e)}")
|
||||
|
||||
return TestResult(
|
||||
test_case=test_case,
|
||||
passed=False,
|
||||
status_code=0,
|
||||
response_body=None,
|
||||
response_headers={},
|
||||
error_message=f"执行异常: {str(e)}"
|
||||
)
|
||||
|
||||
def _extract_response_data(self, test_case: TestCase, response_body: Any):
|
||||
"""提取响应数据到上下文
|
||||
|
||||
Args:
|
||||
test_case: 测试用例
|
||||
response_body: 响应体
|
||||
"""
|
||||
extract_config = test_case.validations or []
|
||||
|
||||
for validation in extract_config:
|
||||
if validation.get("type") == "extract":
|
||||
field = validation.get("field")
|
||||
var_name = validation.get("var_name", field)
|
||||
|
||||
if isinstance(response_body, dict) and field in response_body:
|
||||
self.set_context(var_name, response_body[field])
|
||||
|
||||
def execute_test_suite(
|
||||
self,
|
||||
test_cases: List[TestCase],
|
||||
stop_on_failure: bool = False
|
||||
) -> TestSuiteResult:
|
||||
"""执行测试套件
|
||||
|
||||
Args:
|
||||
test_cases: 测试用例列表
|
||||
stop_on_failure: 是否在失败时停止
|
||||
|
||||
Returns:
|
||||
测试套件结果
|
||||
"""
|
||||
if self.logger:
|
||||
self.logger.info(f"开始执行测试套件,共 {len(test_cases)} 个测试用例")
|
||||
|
||||
self._context.clear()
|
||||
|
||||
sorted_test_cases = self._topological_sort(test_cases)
|
||||
|
||||
results = []
|
||||
|
||||
for test_case in sorted_test_cases:
|
||||
if not test_case.enabled:
|
||||
if self.logger:
|
||||
self.logger.info(f"跳过已禁用的测试用例: {test_case.name}")
|
||||
continue
|
||||
|
||||
result = self._execute_test_case(test_case)
|
||||
results.append(result)
|
||||
|
||||
if not result.passed and stop_on_failure:
|
||||
if self.logger:
|
||||
self.logger.warning(f"测试失败,停止执行: {test_case.name}")
|
||||
break
|
||||
|
||||
passed_count = sum(1 for r in results if r.passed)
|
||||
failed_count = sum(1 for r in results if not r.passed)
|
||||
skipped_count = len(test_cases) - len(results)
|
||||
|
||||
test_suite_result = TestSuiteResult(
|
||||
suite_name="Test Suite",
|
||||
total=len(test_cases),
|
||||
passed=passed_count,
|
||||
failed=failed_count,
|
||||
skipped=skipped_count,
|
||||
results=results,
|
||||
start_time=datetime.now()
|
||||
)
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"测试套件执行完成: 通过 {test_suite_result.passed}, "
|
||||
f"失败 {test_suite_result.failed}, "
|
||||
f"跳过 {test_suite_result.skipped}"
|
||||
)
|
||||
|
||||
return test_suite_result
|
||||
|
||||
def execute_test_cases_by_filter(
|
||||
self,
|
||||
test_cases: List[TestCase],
|
||||
module_filter: Optional[str] = None,
|
||||
tag_filter: Optional[List[str]] = None,
|
||||
priority_filter: Optional[int] = None
|
||||
) -> TestSuiteResult:
|
||||
"""按过滤条件执行测试用例
|
||||
|
||||
Args:
|
||||
test_cases: 测试用例列表
|
||||
module_filter: 模块过滤
|
||||
tag_filter: 标签过滤
|
||||
priority_filter: 优先级过滤
|
||||
|
||||
Returns:
|
||||
测试套件结果
|
||||
"""
|
||||
filtered_cases = []
|
||||
|
||||
for test_case in test_cases:
|
||||
if module_filter and test_case.module != module_filter:
|
||||
continue
|
||||
|
||||
if tag_filter and not any(tag in test_case.tags for tag in tag_filter):
|
||||
continue
|
||||
|
||||
if priority_filter is not None and test_case.priority != priority_filter:
|
||||
continue
|
||||
|
||||
filtered_cases.append(test_case)
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(f"过滤后执行 {len(filtered_cases)} 个测试用例")
|
||||
|
||||
return self.execute_test_suite(filtered_cases)
|
||||
@@ -0,0 +1,337 @@
|
||||
from typing import Dict, Any, List
|
||||
import json
|
||||
import re
|
||||
from apitest.models.test_models import TestCase, TestResult, PerformanceMetrics
|
||||
from apitest.models.exceptions import ValidationException
|
||||
|
||||
|
||||
class ValidationEngine:
|
||||
"""验证引擎"""
|
||||
|
||||
def __init__(self, logger=None):
|
||||
"""初始化验证引擎
|
||||
|
||||
Args:
|
||||
logger: 日志记录器
|
||||
"""
|
||||
self.logger = logger
|
||||
|
||||
def validate_response(
|
||||
self,
|
||||
test_case: TestCase,
|
||||
status_code: int,
|
||||
response_body: Any,
|
||||
response_headers: Dict[str, str]
|
||||
) -> tuple[bool, str]:
|
||||
"""验证响应
|
||||
|
||||
Args:
|
||||
test_case: 测试用例
|
||||
status_code: HTTP状态码
|
||||
response_body: 响应体
|
||||
response_headers: 响应头
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误消息)
|
||||
"""
|
||||
if not test_case.validations:
|
||||
return True, ""
|
||||
|
||||
for validation in test_case.validations:
|
||||
passed, error = self._execute_validation(
|
||||
validation,
|
||||
status_code,
|
||||
response_body,
|
||||
response_headers
|
||||
)
|
||||
|
||||
if not passed:
|
||||
return False, error
|
||||
|
||||
return True, ""
|
||||
|
||||
def _execute_validation(
|
||||
self,
|
||||
validation: Dict[str, Any],
|
||||
status_code: int,
|
||||
response_body: Any,
|
||||
response_headers: Dict[str, str]
|
||||
) -> tuple[bool, str]:
|
||||
"""执行单个验证规则
|
||||
|
||||
Args:
|
||||
validation: 验证规则
|
||||
status_code: HTTP状态码
|
||||
response_body: 响应体
|
||||
response_headers: 响应头
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误消息)
|
||||
"""
|
||||
validation_type = validation.get("type")
|
||||
|
||||
if validation_type == "status_code":
|
||||
return self._validate_status_code(validation, status_code)
|
||||
elif validation_type == "contains":
|
||||
return self._validate_contains(validation, response_body)
|
||||
elif validation_type == "equals":
|
||||
return self._validate_equals(validation, response_body)
|
||||
elif validation_type == "json_path":
|
||||
return self._validate_json_path(validation, response_body)
|
||||
elif validation_type == "regex":
|
||||
return self._validate_regex(validation, response_body)
|
||||
elif validation_type == "header":
|
||||
return self._validate_header(validation, response_headers)
|
||||
elif validation_type == "response_time":
|
||||
return self._validate_response_time(validation)
|
||||
elif validation_type == "schema":
|
||||
return self._validate_schema(validation, response_body)
|
||||
else:
|
||||
return False, f"不支持的验证类型: {validation_type}"
|
||||
|
||||
def _validate_status_code(self, validation: Dict[str, Any], status_code: int) -> tuple[bool, str]:
|
||||
"""验证状态码
|
||||
|
||||
Args:
|
||||
validation: 验证规则
|
||||
status_code: HTTP状态码
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误消息)
|
||||
"""
|
||||
expected_code = validation.get("value")
|
||||
if status_code == expected_code:
|
||||
return True, ""
|
||||
|
||||
return False, f"状态码验证失败: 期望 {expected_code}, 实际 {status_code}"
|
||||
|
||||
def _validate_contains(self, validation: Dict[str, Any], response_body: Any) -> tuple[bool, str]:
|
||||
"""验证响应体包含指定内容
|
||||
|
||||
Args:
|
||||
validation: 验证规则
|
||||
response_body: 响应体
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误消息)
|
||||
"""
|
||||
expected_value = validation.get("value")
|
||||
field = validation.get("field")
|
||||
|
||||
if field:
|
||||
if isinstance(response_body, dict):
|
||||
actual_value = response_body.get(field)
|
||||
else:
|
||||
return False, f"响应体不是字典类型,无法访问字段: {field}"
|
||||
else:
|
||||
actual_value = response_body
|
||||
|
||||
if str(expected_value) in str(actual_value):
|
||||
return True, ""
|
||||
|
||||
return False, f"包含验证失败: 响应体中未找到 '{expected_value}'"
|
||||
|
||||
def _validate_equals(self, validation: Dict[str, Any], response_body: Any) -> tuple[bool, str]:
|
||||
"""验证响应体等于指定值
|
||||
|
||||
Args:
|
||||
validation: 验证规则
|
||||
response_body: 响应体
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误消息)
|
||||
"""
|
||||
expected_value = validation.get("value")
|
||||
field = validation.get("field")
|
||||
|
||||
if field:
|
||||
if isinstance(response_body, dict):
|
||||
actual_value = response_body.get(field)
|
||||
else:
|
||||
return False, f"响应体不是字典类型,无法访问字段: {field}"
|
||||
else:
|
||||
actual_value = response_body
|
||||
|
||||
if actual_value == expected_value:
|
||||
return True, ""
|
||||
|
||||
return False, f"相等验证失败: 期望 {expected_value}, 实际 {actual_value}"
|
||||
|
||||
def _validate_json_path(self, validation: Dict[str, Any], response_body: Any) -> tuple[bool, str]:
|
||||
"""验证JSON路径
|
||||
|
||||
Args:
|
||||
validation: 验证规则
|
||||
response_body: 响应体
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误消息)
|
||||
"""
|
||||
path = validation.get("path")
|
||||
expected_value = validation.get("value")
|
||||
|
||||
try:
|
||||
actual_value = self._get_json_path_value(response_body, path)
|
||||
|
||||
if actual_value == expected_value:
|
||||
return True, ""
|
||||
|
||||
return False, f"JSON路径验证失败: {path} 期望 {expected_value}, 实际 {actual_value}"
|
||||
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
return False, f"JSON路径访问失败: {path} - {str(e)}"
|
||||
|
||||
def _get_json_path_value(self, data: Any, path: str) -> Any:
|
||||
"""获取JSON路径值
|
||||
|
||||
Args:
|
||||
data: 数据
|
||||
path: JSON路径
|
||||
|
||||
Returns:
|
||||
路径对应的值
|
||||
"""
|
||||
parts = path.split(".")
|
||||
current = data
|
||||
|
||||
for part in parts:
|
||||
if isinstance(current, dict):
|
||||
current = current[part]
|
||||
elif isinstance(current, list) and part.isdigit():
|
||||
current = current[int(part)]
|
||||
else:
|
||||
raise KeyError(f"无法访问路径: {part}")
|
||||
|
||||
return current
|
||||
|
||||
def _validate_regex(self, validation: Dict[str, Any], response_body: Any) -> tuple[bool, str]:
|
||||
"""验证正则表达式
|
||||
|
||||
Args:
|
||||
validation: 验证规则
|
||||
response_body: 响应体
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误消息)
|
||||
"""
|
||||
pattern = validation.get("pattern")
|
||||
field = validation.get("field")
|
||||
|
||||
if field:
|
||||
if isinstance(response_body, dict):
|
||||
actual_value = str(response_body.get(field, ""))
|
||||
else:
|
||||
actual_value = str(response_body)
|
||||
else:
|
||||
actual_value = str(response_body)
|
||||
|
||||
if re.search(pattern, actual_value):
|
||||
return True, ""
|
||||
|
||||
return False, f"正则表达式验证失败: '{actual_value}' 不匹配模式 '{pattern}'"
|
||||
|
||||
def _validate_header(self, validation: Dict[str, Any], response_headers: Dict[str, str]) -> tuple[bool, str]:
|
||||
"""验证响应头
|
||||
|
||||
Args:
|
||||
validation: 验证规则
|
||||
response_headers: 响应头
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误消息)
|
||||
"""
|
||||
header_name = validation.get("name")
|
||||
expected_value = validation.get("value")
|
||||
|
||||
actual_value = response_headers.get(header_name)
|
||||
|
||||
if actual_value is None:
|
||||
return False, f"响应头中未找到: {header_name}"
|
||||
|
||||
if expected_value and actual_value != expected_value:
|
||||
return False, f"响应头验证失败: {header_name} 期望 {expected_value}, 实际 {actual_value}"
|
||||
|
||||
return True, ""
|
||||
|
||||
def _validate_response_time(self, validation: Dict[str, Any]) -> tuple[bool, str]:
|
||||
"""验证响应时间(需要在TestResult中检查)
|
||||
|
||||
Args:
|
||||
validation: 验证规则
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误消息)
|
||||
"""
|
||||
max_time = validation.get("max_time")
|
||||
|
||||
return True, ""
|
||||
|
||||
def _validate_schema(self, validation: Dict[str, Any], response_body: Any) -> tuple[bool, str]:
|
||||
"""验证响应体结构
|
||||
|
||||
Args:
|
||||
validation: 验证规则
|
||||
response_body: 响应体
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误消息)
|
||||
"""
|
||||
schema = validation.get("schema")
|
||||
|
||||
if not isinstance(response_body, dict):
|
||||
return False, f"响应体不是字典类型,无法验证结构"
|
||||
|
||||
for field, field_type in schema.items():
|
||||
if field not in response_body:
|
||||
return False, f"响应体中缺少字段: {field}"
|
||||
|
||||
actual_type = type(response_body[field]).__name__
|
||||
expected_type = field_type
|
||||
|
||||
if actual_type != expected_type:
|
||||
return False, f"字段 {field} 类型错误: 期望 {expected_type}, 实际 {actual_type}"
|
||||
|
||||
return True, ""
|
||||
|
||||
def validate_performance(
|
||||
self,
|
||||
performance: PerformanceMetrics,
|
||||
max_response_time: int
|
||||
) -> tuple[bool, str]:
|
||||
"""验证性能指标
|
||||
|
||||
Args:
|
||||
performance: 性能指标
|
||||
max_response_time: 最大响应时间(毫秒)
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误消息)
|
||||
"""
|
||||
if performance.response_time > max_response_time:
|
||||
return False, f"响应时间超过阈值: {performance.response_time}ms > {max_response_time}ms"
|
||||
|
||||
return True, ""
|
||||
|
||||
def validate_test_result(
|
||||
self,
|
||||
test_result: TestResult,
|
||||
max_response_time: int
|
||||
) -> tuple[bool, str]:
|
||||
"""验证测试结果
|
||||
|
||||
Args:
|
||||
test_result: 测试结果
|
||||
max_response_time: 最大响应时间(毫秒)
|
||||
|
||||
Returns:
|
||||
(是否通过, 错误消息)
|
||||
"""
|
||||
if not test_result.passed:
|
||||
return False, test_result.error_message or "测试失败"
|
||||
|
||||
if test_result.performance:
|
||||
passed, error = self.validate_performance(test_result.performance, max_response_time)
|
||||
if not passed:
|
||||
return False, error
|
||||
|
||||
return True, ""
|
||||
@@ -0,0 +1,267 @@
|
||||
"""测试数据管理模块"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
import json
|
||||
import csv
|
||||
from apitest.models.test_models import TestCase, HTTPMethod
|
||||
from apitest.models.exceptions import TestRunException
|
||||
|
||||
|
||||
class TestDataManager:
|
||||
"""测试数据管理器"""
|
||||
|
||||
def __init__(self, logger=None):
|
||||
"""初始化测试数据管理器
|
||||
|
||||
Args:
|
||||
logger: 日志记录器
|
||||
"""
|
||||
self.logger = logger
|
||||
|
||||
def load_test_cases_from_json(self, file_path: Path) -> List[TestCase]:
|
||||
"""从JSON文件加载测试用例
|
||||
|
||||
Args:
|
||||
file_path: JSON文件路径
|
||||
|
||||
Returns:
|
||||
测试用例列表
|
||||
|
||||
Raises:
|
||||
TestRunException: 加载失败
|
||||
"""
|
||||
try:
|
||||
if not file_path.exists():
|
||||
raise TestRunException(f"测试用例文件不存在: {file_path}")
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
test_cases = []
|
||||
for item in data:
|
||||
method_str = item.get("method", "GET")
|
||||
try:
|
||||
method = HTTPMethod(method_str)
|
||||
except ValueError:
|
||||
method = HTTPMethod.GET
|
||||
|
||||
test_case = TestCase(
|
||||
id=item.get("id", ""),
|
||||
name=item.get("name", ""),
|
||||
description=item.get("description", ""),
|
||||
module=item.get("module", ""),
|
||||
endpoint=item.get("endpoint", ""),
|
||||
method=method,
|
||||
headers=item.get("headers", {}),
|
||||
params=item.get("params"),
|
||||
body=item.get("body"),
|
||||
dependencies=item.get("dependencies", []),
|
||||
tags=item.get("tags", []),
|
||||
priority=item.get("priority", 0),
|
||||
enabled=item.get("enabled", True),
|
||||
timeout=item.get("timeout"),
|
||||
validations=item.get("validations", [])
|
||||
)
|
||||
test_cases.append(test_case)
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(f"从JSON文件成功加载 {len(test_cases)} 个测试用例: {file_path}")
|
||||
|
||||
return test_cases
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"JSON文件解析失败: {str(e)}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise TestRunException(error_msg) from e
|
||||
except Exception as e:
|
||||
error_msg = f"加载测试用例失败: {str(e)}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise TestRunException(error_msg) from e
|
||||
|
||||
def load_test_data_from_csv(self, file_path: Path) -> List[Dict[str, Any]]:
|
||||
"""从CSV文件加载测试数据
|
||||
|
||||
Args:
|
||||
file_path: CSV文件路径
|
||||
|
||||
Returns:
|
||||
测试数据列表
|
||||
|
||||
Raises:
|
||||
TestRunException: 加载失败
|
||||
"""
|
||||
try:
|
||||
if not file_path.exists():
|
||||
raise TestRunException(f"测试数据文件不存在: {file_path}")
|
||||
|
||||
test_data = []
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
test_data.append(dict(row))
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(f"从CSV文件成功加载 {len(test_data)} 条测试数据: {file_path}")
|
||||
|
||||
return test_data
|
||||
|
||||
except csv.Error as e:
|
||||
error_msg = f"CSV文件解析失败: {str(e)}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise TestRunException(error_msg) from e
|
||||
except Exception as e:
|
||||
error_msg = f"加载测试数据失败: {str(e)}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise TestRunException(error_msg) from e
|
||||
|
||||
def parameterize_test_case(
|
||||
self,
|
||||
test_case: TestCase,
|
||||
test_data: List[Dict[str, Any]]
|
||||
) -> List[TestCase]:
|
||||
"""使用测试数据参数化测试用例
|
||||
|
||||
Args:
|
||||
test_case: 原始测试用例
|
||||
test_data: 测试数据列表
|
||||
|
||||
Returns:
|
||||
参数化后的测试用例列表
|
||||
"""
|
||||
try:
|
||||
parameterized_cases = []
|
||||
|
||||
for i, data in enumerate(test_data):
|
||||
new_id = f"{test_case.id}_{i+1}"
|
||||
new_name = f"{test_case.name} (数据集 {i+1})"
|
||||
|
||||
params = test_case.params.copy() if test_case.params else {}
|
||||
body = test_case.body.copy() if test_case.body else {}
|
||||
|
||||
params.update(data.get("params", {}))
|
||||
body.update(data.get("body", {}))
|
||||
|
||||
parameterized_case = TestCase(
|
||||
id=new_id,
|
||||
name=new_name,
|
||||
description=test_case.description,
|
||||
module=test_case.module,
|
||||
endpoint=test_case.endpoint,
|
||||
method=test_case.method,
|
||||
headers=test_case.headers,
|
||||
params=params,
|
||||
body=body,
|
||||
dependencies=test_case.dependencies,
|
||||
tags=test_case.tags,
|
||||
priority=test_case.priority,
|
||||
enabled=test_case.enabled,
|
||||
timeout=test_case.timeout,
|
||||
validations=test_case.validations
|
||||
)
|
||||
|
||||
parameterized_cases.append(parameterized_case)
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(f"使用 {len(test_data)} 条测试数据参数化测试用例: {test_case.id}")
|
||||
|
||||
return parameterized_cases
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"参数化测试用例失败: {str(e)}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise TestRunException(error_msg) from e
|
||||
|
||||
def save_test_cases_to_json(
|
||||
self,
|
||||
test_cases: List[TestCase],
|
||||
file_path: Path
|
||||
) -> None:
|
||||
"""将测试用例保存到JSON文件
|
||||
|
||||
Args:
|
||||
test_cases: 测试用例列表
|
||||
file_path: 输出文件路径
|
||||
|
||||
Raises:
|
||||
TestRunException: 保存失败
|
||||
"""
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = []
|
||||
for test_case in test_cases:
|
||||
item = {
|
||||
"id": test_case.id,
|
||||
"name": test_case.name,
|
||||
"description": test_case.description,
|
||||
"module": test_case.module,
|
||||
"endpoint": test_case.endpoint,
|
||||
"method": test_case.method.value,
|
||||
"headers": test_case.headers,
|
||||
"params": test_case.params,
|
||||
"body": test_case.body,
|
||||
"dependencies": test_case.dependencies,
|
||||
"tags": test_case.tags,
|
||||
"priority": test_case.priority,
|
||||
"enabled": test_case.enabled,
|
||||
"timeout": test_case.timeout,
|
||||
"validations": test_case.validations
|
||||
}
|
||||
data.append(item)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(f"成功保存 {len(test_cases)} 个测试用例到JSON文件: {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"保存测试用例失败: {str(e)}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise TestRunException(error_msg) from e
|
||||
|
||||
def save_test_data_to_csv(
|
||||
self,
|
||||
test_data: List[Dict[str, Any]],
|
||||
file_path: Path,
|
||||
fieldnames: Optional[List[str]] = None
|
||||
) -> None:
|
||||
"""将测试数据保存到CSV文件
|
||||
|
||||
Args:
|
||||
test_data: 测试数据列表
|
||||
file_path: 输出文件路径
|
||||
fieldnames: 字段名列表,如果为None则自动推断
|
||||
|
||||
Raises:
|
||||
TestRunException: 保存失败
|
||||
"""
|
||||
try:
|
||||
if not test_data:
|
||||
raise TestRunException("测试数据为空")
|
||||
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if fieldnames is None:
|
||||
fieldnames = list(test_data[0].keys())
|
||||
|
||||
with open(file_path, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
writer.writerows(test_data)
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(f"成功保存 {len(test_data)} 条测试数据到CSV文件: {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"保存测试数据失败: {str(e)}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise TestRunException(error_msg) from e
|
||||
@@ -0,0 +1,163 @@
|
||||
import click
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from apitest.config.config_manager import ConfigManager
|
||||
from apitest.core.test_orchestrator import TestOrchestrator
|
||||
from apitest.report.report_manager import ReportManager
|
||||
from apitest.utils.logger_manager import LoggerManager
|
||||
|
||||
|
||||
def setup_logger(config_manager: ConfigManager) -> LoggerManager:
|
||||
logger_manager = LoggerManager(config_manager)
|
||||
logger_manager.setup()
|
||||
return logger_manager
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version="1.0.0")
|
||||
def cli():
|
||||
"""黑盒API测试工具"""
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--suite", default="all", help="测试套件名称")
|
||||
@click.option("--filter", help="测试用例过滤器(如:priority=high,module=user)")
|
||||
@click.option("--parallel", is_flag=True, help="并发执行测试")
|
||||
@click.option("--threads", default=4, help="并发线程数")
|
||||
@click.option("--verbose", is_flag=True, help="详细输出")
|
||||
def run(suite: str, filter: Optional[str], parallel: bool, threads: int, verbose: bool):
|
||||
"""运行测试用例"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
logger_manager = setup_logger(config_manager)
|
||||
logger = logger_manager.get_logger(__name__)
|
||||
|
||||
logger.info(f"开始执行测试套件: {suite}")
|
||||
if filter:
|
||||
logger.info(f"过滤器: {filter}")
|
||||
if parallel:
|
||||
logger.info(f"并发模式: {threads} 线程")
|
||||
|
||||
orchestrator = TestOrchestrator(config_manager, logger_manager)
|
||||
|
||||
filters = {}
|
||||
if filter:
|
||||
for f in filter.split(","):
|
||||
key, value = f.split("=")
|
||||
filters[key] = value
|
||||
|
||||
results = orchestrator.run_suite(suite, filters, parallel, threads)
|
||||
|
||||
report_manager = ReportManager(config_manager, logger_manager)
|
||||
report_manager.generate_report(results)
|
||||
|
||||
logger.info(f"测试完成: 通过 {results.passed}, 失败 {results.failed}, 跳过 {results.skipped}")
|
||||
|
||||
sys.exit(0 if results.failed == 0 else 1)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"执行测试时出错: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--suite", default="all", help="测试套件名称")
|
||||
@click.option("--filter", help="测试用例过滤器")
|
||||
def list(suite: str, filter: Optional[str]):
|
||||
"""列出测试用例"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
logger_manager = setup_logger(config_manager)
|
||||
|
||||
orchestrator = TestOrchestrator(config_manager, logger_manager)
|
||||
|
||||
filters = {}
|
||||
if filter:
|
||||
for f in filter.split(","):
|
||||
key, value = f.split("=")
|
||||
filters[key] = value
|
||||
|
||||
test_cases = orchestrator.list_test_cases(suite, filters)
|
||||
|
||||
click.echo(f"\n测试套件: {suite}")
|
||||
click.echo(f"测试用例数量: {len(test_cases)}\n")
|
||||
|
||||
for test_case in test_cases:
|
||||
status = "✓" if test_case.enabled else "✗"
|
||||
click.echo(f"{status} {test_case.id}: {test_case.name}")
|
||||
click.echo(f" 模块: {test_case.module}")
|
||||
click.echo(f" 方法: {test_case.method.value} {test_case.endpoint}")
|
||||
click.echo(f" 优先级: {test_case.priority}")
|
||||
click.echo()
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"列出测试用例时出错: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--format", default="html", help="报告格式(html/json)")
|
||||
@click.option("--output", help="输出文件路径")
|
||||
@click.option("--suite", default="all", help="测试套件名称")
|
||||
def report(format: str, output: Optional[str], suite: str):
|
||||
"""生成测试报告"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
logger_manager = setup_logger(config_manager)
|
||||
|
||||
report_manager = ReportManager(config_manager, logger_manager)
|
||||
|
||||
if output:
|
||||
report_manager.generate_report_from_history(suite, format, output)
|
||||
else:
|
||||
report_manager.generate_latest_report(format)
|
||||
|
||||
click.echo(f"报告已生成: {format}")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"生成报告时出错: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--set", help="设置配置值(格式:key=value)")
|
||||
@click.option("--get", help="获取配置值")
|
||||
@click.option("--validate", is_flag=True, help="验证配置")
|
||||
def config(set: Optional[str], get: Optional[str], validate: bool):
|
||||
"""配置管理"""
|
||||
try:
|
||||
config_manager = ConfigManager()
|
||||
|
||||
if set:
|
||||
key, value = set.split("=")
|
||||
config_manager.set(key, value)
|
||||
click.echo(f"配置已设置: {key} = {value}")
|
||||
|
||||
elif get:
|
||||
value = config_manager.get(get)
|
||||
click.echo(f"{get} = {value}")
|
||||
|
||||
elif validate:
|
||||
is_valid, errors = config_manager.validate()
|
||||
if is_valid:
|
||||
click.echo("配置验证通过 ✓")
|
||||
else:
|
||||
click.echo("配置验证失败 ✗")
|
||||
for error in errors:
|
||||
click.echo(f" - {error}")
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
click.echo("当前配置:")
|
||||
config_manager.print_config()
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"配置管理时出错: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,38 @@
|
||||
class APITestException(Exception):
|
||||
"""API测试基础异常"""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigException(APITestException):
|
||||
"""配置异常"""
|
||||
pass
|
||||
|
||||
|
||||
class DataException(APITestException):
|
||||
"""数据异常"""
|
||||
pass
|
||||
|
||||
|
||||
class AuthException(APITestException):
|
||||
"""认证异常"""
|
||||
pass
|
||||
|
||||
|
||||
class RequestException(APITestException):
|
||||
"""请求异常"""
|
||||
pass
|
||||
|
||||
|
||||
class ValidationException(APITestException):
|
||||
"""验证异常"""
|
||||
pass
|
||||
|
||||
|
||||
class TestRunException(APITestException):
|
||||
"""测试执行异常"""
|
||||
pass
|
||||
|
||||
|
||||
class ReportException(APITestException):
|
||||
"""报告生成异常"""
|
||||
pass
|
||||
@@ -0,0 +1,151 @@
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class HTTPMethod(Enum):
|
||||
"""HTTP方法枚举"""
|
||||
GET = "GET"
|
||||
POST = "POST"
|
||||
PUT = "PUT"
|
||||
DELETE = "DELETE"
|
||||
PATCH = "PATCH"
|
||||
HEAD = "HEAD"
|
||||
OPTIONS = "OPTIONS"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidationRule:
|
||||
"""验证规则数据模型"""
|
||||
type: str # status_code, json_path, contains, regex, schema
|
||||
expected: Any
|
||||
json_path: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestCase:
|
||||
"""测试用例数据模型"""
|
||||
id: str # 用例唯一标识
|
||||
name: str # 用例名称
|
||||
description: str # 用例描述
|
||||
module: str # 所属模块
|
||||
endpoint: str # API端点
|
||||
method: HTTPMethod # HTTP方法
|
||||
headers: Dict[str, str] # 请求头
|
||||
params: Optional[Dict[str, Any]] = None # URL参数
|
||||
body: Optional[Dict[str, Any]] = None # 请求体
|
||||
auth_required: bool = True # 是否需要认证
|
||||
dependencies: List[str] = None # 依赖的用例ID
|
||||
timeout: int = 5000 # 超时时间(毫秒)
|
||||
retry_count: int = 0 # 重试次数
|
||||
validations: List[Dict] = None # 验证规则
|
||||
setup: Optional[Dict] = None # 前置操作
|
||||
teardown: Optional[Dict] = None # 后置操作
|
||||
tags: List[str] = None # 标签
|
||||
priority: int = 0 # 优先级
|
||||
enabled: bool = True # 是否启用
|
||||
|
||||
def __post_init__(self):
|
||||
if self.dependencies is None:
|
||||
object.__setattr__(self, "dependencies", [])
|
||||
if self.validations is None:
|
||||
object.__setattr__(self, "validations", [])
|
||||
if self.tags is None:
|
||||
object.__setattr__(self, "tags", [])
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerformanceMetrics:
|
||||
"""性能指标数据模型"""
|
||||
response_time: int # 响应时间(毫秒)
|
||||
request_size: int # 请求大小(字节)
|
||||
response_size: int # 响应大小(字节)
|
||||
timestamp: datetime # 时间戳
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"response_time": self.response_time,
|
||||
"request_size": self.request_size,
|
||||
"response_size": self.response_size,
|
||||
"timestamp": self.timestamp.isoformat()
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
"""测试结果数据模型"""
|
||||
test_case: TestCase # 测试用例
|
||||
passed: bool # 是否通过
|
||||
status_code: int # HTTP状态码
|
||||
response_body: Any # 响应体
|
||||
response_headers: Dict[str, str] # 响应头
|
||||
error_message: Optional[str] = None # 错误消息
|
||||
performance: Optional[PerformanceMetrics] = None # 性能指标
|
||||
execution_time: float = 0.0 # 执行时间(秒)
|
||||
retry_count: int = 0 # 重试次数
|
||||
timestamp: datetime = None # 执行时间戳
|
||||
|
||||
def __post_init__(self):
|
||||
if self.timestamp is None:
|
||||
self.timestamp = datetime.now()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"test_case_id": self.test_case.id,
|
||||
"test_case_name": self.test_case.name,
|
||||
"passed": self.passed,
|
||||
"status_code": self.status_code,
|
||||
"response_body": self.response_body,
|
||||
"response_headers": self.response_headers,
|
||||
"error_message": self.error_message,
|
||||
"performance": self.performance.to_dict() if self.performance else None,
|
||||
"execution_time": self.execution_time,
|
||||
"retry_count": self.retry_count,
|
||||
"timestamp": self.timestamp.isoformat()
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestSuiteResult:
|
||||
"""测试套件结果数据模型"""
|
||||
suite_name: str # 套件名称
|
||||
total: int # 总数
|
||||
passed: int # 通过数
|
||||
failed: int # 失败数
|
||||
skipped: int # 跳过数
|
||||
results: List[TestResult] # 测试结果列表
|
||||
start_time: datetime # 开始时间
|
||||
end_time: Optional[datetime] = None # 结束时间
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
"""执行时长(秒)"""
|
||||
if self.end_time:
|
||||
return (self.end_time - self.start_time).total_seconds()
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def pass_rate(self) -> float:
|
||||
"""通过率"""
|
||||
if self.total == 0:
|
||||
return 0.0
|
||||
return (self.passed / self.total) * 100
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"suite_name": self.suite_name,
|
||||
"total": self.total,
|
||||
"passed": self.passed,
|
||||
"failed": self.failed,
|
||||
"skipped": self.skipped,
|
||||
"pass_rate": self.pass_rate,
|
||||
"duration": self.duration,
|
||||
"start_time": self.start_time.isoformat(),
|
||||
"end_time": self.end_time.isoformat() if self.end_time else None,
|
||||
"results": [result.to_dict() for result in self.results]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""测试编排器模块"""
|
||||
|
||||
from apitest.orchestrator.test_orchestrator import TestOrchestrator
|
||||
|
||||
__all__ = ["TestOrchestrator"]
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
"""测试编排器模块"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
from apitest.models.test_models import TestCase, TestSuiteResult, HTTPMethod
|
||||
from apitest.client.api_client import APIClient
|
||||
from apitest.client.auth_manager import AuthManager
|
||||
from apitest.core.test_engine import TestEngine
|
||||
from apitest.core.validation_engine import ValidationEngine
|
||||
from apitest.report.report_manager import ReportManager
|
||||
from apitest.config.config_manager import ConfigManager
|
||||
from apitest.config.logger_manager import LoggerManager
|
||||
from apitest.models.exceptions import TestRunException
|
||||
|
||||
|
||||
class TestOrchestrator:
|
||||
"""测试编排器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_manager: Optional[ConfigManager] = None,
|
||||
logger_manager: Optional[LoggerManager] = None,
|
||||
logger=None
|
||||
):
|
||||
"""初始化测试编排器
|
||||
|
||||
Args:
|
||||
config_manager: 配置管理器
|
||||
logger_manager: 日志管理器
|
||||
logger: 日志记录器
|
||||
"""
|
||||
self.config_manager = config_manager or ConfigManager()
|
||||
self.logger_manager = logger_manager or LoggerManager(self.config_manager)
|
||||
self.logger = logger or self.logger_manager.get_logger(__name__)
|
||||
|
||||
self.api_client = APIClient(
|
||||
base_url=self.config_manager.get_base_url(),
|
||||
timeout=self.config_manager.get_timeout(),
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
self.auth_manager = AuthManager(
|
||||
base_url=self.config_manager.get_base_url(),
|
||||
credentials={},
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
self.validation_engine = ValidationEngine(logger=self.logger)
|
||||
|
||||
self.test_engine = TestEngine(
|
||||
api_client=self.api_client,
|
||||
auth_manager=self.auth_manager,
|
||||
validation_engine=self.validation_engine,
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
self.report_manager = ReportManager(logger=self.logger)
|
||||
|
||||
def load_test_cases(self, file_path: Path) -> List[TestCase]:
|
||||
"""加载测试用例
|
||||
|
||||
Args:
|
||||
file_path: 测试用例文件路径
|
||||
|
||||
Returns:
|
||||
测试用例列表
|
||||
|
||||
Raises:
|
||||
TestRunException: 加载失败
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
|
||||
if not file_path.exists():
|
||||
raise TestRunException(f"测试用例文件不存在: {file_path}")
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
test_cases = []
|
||||
for item in data:
|
||||
method_str = item.get("method", "GET")
|
||||
try:
|
||||
method = HTTPMethod(method_str)
|
||||
except ValueError:
|
||||
method = HTTPMethod.GET
|
||||
|
||||
test_case = TestCase(
|
||||
id=item.get("id", ""),
|
||||
name=item.get("name", ""),
|
||||
description=item.get("description", ""),
|
||||
module=item.get("module", ""),
|
||||
endpoint=item.get("endpoint", ""),
|
||||
method=method,
|
||||
headers=item.get("headers", {}),
|
||||
params=item.get("params"),
|
||||
body=item.get("body"),
|
||||
dependencies=item.get("dependencies", []),
|
||||
tags=item.get("tags", []),
|
||||
priority=item.get("priority", 0),
|
||||
enabled=item.get("enabled", True),
|
||||
timeout=item.get("timeout"),
|
||||
validations=item.get("validations", [])
|
||||
)
|
||||
test_cases.append(test_case)
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(f"成功加载 {len(test_cases)} 个测试用例")
|
||||
|
||||
return test_cases
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"加载测试用例失败: {str(e)}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise TestRunException(error_msg) from e
|
||||
|
||||
def run_test_suite(
|
||||
self,
|
||||
test_cases: List[TestCase],
|
||||
stop_on_failure: bool = False,
|
||||
generate_report: bool = True,
|
||||
report_format: str = "html",
|
||||
report_path: Optional[Path] = None
|
||||
) -> TestSuiteResult:
|
||||
"""运行测试套件
|
||||
|
||||
Args:
|
||||
test_cases: 测试用例列表
|
||||
stop_on_failure: 是否在失败时停止
|
||||
generate_report: 是否生成报告
|
||||
report_format: 报告格式 (html/json)
|
||||
report_path: 报告输出路径
|
||||
|
||||
Returns:
|
||||
测试套件结果
|
||||
"""
|
||||
try:
|
||||
if self.logger:
|
||||
self.logger.info("=" * 50)
|
||||
self.logger.info("开始执行测试套件")
|
||||
self.logger.info("=" * 50)
|
||||
|
||||
result = self.test_engine.execute_test_suite(
|
||||
test_cases,
|
||||
stop_on_failure=stop_on_failure
|
||||
)
|
||||
|
||||
if generate_report:
|
||||
self._generate_report(result, report_format, report_path)
|
||||
|
||||
if self.logger:
|
||||
self.logger.info("=" * 50)
|
||||
self.logger.info("测试套件执行完成")
|
||||
self.logger.info(f"总计: {result.total}, 通过: {result.passed}, 失败: {result.failed}, 跳过: {result.skipped}")
|
||||
self.logger.info(f"通过率: {result.pass_rate:.2f}%")
|
||||
self.logger.info(f"执行时长: {result.duration:.2f}秒")
|
||||
self.logger.info("=" * 50)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"运行测试套件失败: {str(e)}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise TestRunException(error_msg) from e
|
||||
|
||||
def run_test_suite_by_filter(
|
||||
self,
|
||||
test_cases: List[TestCase],
|
||||
module_filter: Optional[str] = None,
|
||||
tag_filter: Optional[List[str]] = None,
|
||||
priority_filter: Optional[int] = None,
|
||||
stop_on_failure: bool = False,
|
||||
generate_report: bool = True,
|
||||
report_format: str = "html",
|
||||
report_path: Optional[Path] = None
|
||||
) -> TestSuiteResult:
|
||||
"""按过滤条件运行测试套件
|
||||
|
||||
Args:
|
||||
test_cases: 测试用例列表
|
||||
module_filter: 模块过滤
|
||||
tag_filter: 标签过滤
|
||||
priority_filter: 优先级过滤
|
||||
stop_on_failure: 是否在失败时停止
|
||||
generate_report: 是否生成报告
|
||||
report_format: 报告格式 (html/json)
|
||||
report_path: 报告输出路径
|
||||
|
||||
Returns:
|
||||
测试套件结果
|
||||
"""
|
||||
try:
|
||||
if self.logger:
|
||||
self.logger.info("按过滤条件执行测试用例")
|
||||
if module_filter:
|
||||
self.logger.info(f" 模块过滤: {module_filter}")
|
||||
if tag_filter:
|
||||
self.logger.info(f" 标签过滤: {tag_filter}")
|
||||
if priority_filter is not None:
|
||||
self.logger.info(f" 优先级过滤: {priority_filter}")
|
||||
|
||||
result = self.test_engine.execute_test_cases_by_filter(
|
||||
test_cases,
|
||||
module_filter=module_filter,
|
||||
tag_filter=tag_filter,
|
||||
priority_filter=priority_filter
|
||||
)
|
||||
|
||||
if generate_report:
|
||||
self._generate_report(result, report_format, report_path)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"按过滤条件运行测试套件失败: {str(e)}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise TestRunException(error_msg) from e
|
||||
|
||||
def _generate_report(
|
||||
self,
|
||||
test_suite_result: TestSuiteResult,
|
||||
report_format: str,
|
||||
report_path: Optional[Path]
|
||||
):
|
||||
"""生成测试报告
|
||||
|
||||
Args:
|
||||
test_suite_result: 测试套件结果
|
||||
report_format: 报告格式
|
||||
report_path: 报告输出路径
|
||||
"""
|
||||
try:
|
||||
if report_path is None:
|
||||
report_path = Path(f"reports/test_report_{test_suite_result.suite_name}_{test_suite_result.start_time.strftime('%Y%m%d_%H%M%S')}.{report_format}")
|
||||
|
||||
if report_format == "html":
|
||||
self.report_manager.generate_html_report(
|
||||
test_suite_result,
|
||||
report_path,
|
||||
title="API测试报告"
|
||||
)
|
||||
elif report_format == "json":
|
||||
self.report_manager.generate_json_report(
|
||||
test_suite_result,
|
||||
report_path
|
||||
)
|
||||
else:
|
||||
if self.logger:
|
||||
self.logger.warning(f"不支持的报告格式: {report_format}")
|
||||
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.error(f"生成测试报告失败: {str(e)}")
|
||||
|
||||
def set_base_url(self, base_url: str):
|
||||
"""设置基础URL
|
||||
|
||||
Args:
|
||||
base_url: 基础URL
|
||||
"""
|
||||
self.api_client.base_url = base_url
|
||||
if self.logger:
|
||||
self.logger.info(f"基础URL已更新: {base_url}")
|
||||
|
||||
def set_auth_token(self, token: str):
|
||||
"""设置认证令牌
|
||||
|
||||
Args:
|
||||
token: 认证令牌
|
||||
"""
|
||||
self.auth_manager.set_token(token)
|
||||
if self.logger:
|
||||
self.logger.info("认证令牌已设置")
|
||||
@@ -0,0 +1,5 @@
|
||||
"""报告模块"""
|
||||
|
||||
from apitest.report.report_manager import ReportManager
|
||||
|
||||
__all__ = ["ReportManager"]
|
||||
@@ -0,0 +1,343 @@
|
||||
"""报告管理器模块"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from apitest.models.test_models import TestSuiteResult
|
||||
from apitest.models.exceptions import ReportException
|
||||
|
||||
|
||||
class ReportManager:
|
||||
"""报告管理器"""
|
||||
|
||||
def __init__(self, logger=None):
|
||||
"""初始化报告管理器
|
||||
|
||||
Args:
|
||||
logger: 日志记录器
|
||||
"""
|
||||
self.logger = logger
|
||||
|
||||
def generate_html_report(
|
||||
self,
|
||||
test_suite_result: TestSuiteResult,
|
||||
output_path: Path,
|
||||
title: str = "API测试报告"
|
||||
) -> str:
|
||||
"""生成HTML格式的测试报告
|
||||
|
||||
Args:
|
||||
test_suite_result: 测试套件结果
|
||||
output_path: 输出文件路径
|
||||
title: 报告标题
|
||||
|
||||
Returns:
|
||||
生成的报告文件路径
|
||||
|
||||
Raises:
|
||||
ReportException: 报告生成失败
|
||||
"""
|
||||
try:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
html_content = self._generate_html_content(
|
||||
test_suite_result,
|
||||
title
|
||||
)
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(html_content)
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(f"HTML报告已生成: {output_path}")
|
||||
|
||||
return str(output_path)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"生成HTML报告失败: {str(e)}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise ReportException(error_msg) from e
|
||||
|
||||
def generate_json_report(
|
||||
self,
|
||||
test_suite_result: TestSuiteResult,
|
||||
output_path: Path
|
||||
) -> str:
|
||||
"""生成JSON格式的测试报告
|
||||
|
||||
Args:
|
||||
test_suite_result: 测试套件结果
|
||||
output_path: 输出文件路径
|
||||
|
||||
Returns:
|
||||
生成的报告文件路径
|
||||
|
||||
Raises:
|
||||
ReportException: 报告生成失败
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
report_data = {
|
||||
"suite_name": test_suite_result.suite_name,
|
||||
"total": test_suite_result.total,
|
||||
"passed": test_suite_result.passed,
|
||||
"failed": test_suite_result.failed,
|
||||
"skipped": test_suite_result.skipped,
|
||||
"pass_rate": test_suite_result.pass_rate,
|
||||
"duration": test_suite_result.duration,
|
||||
"start_time": test_suite_result.start_time.isoformat(),
|
||||
"end_time": test_suite_result.end_time.isoformat() if test_suite_result.end_time else None,
|
||||
"results": [result.to_dict() for result in test_suite_result.results]
|
||||
}
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(report_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(f"JSON报告已生成: {output_path}")
|
||||
|
||||
return str(output_path)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"生成JSON报告失败: {str(e)}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise ReportException(error_msg) from e
|
||||
|
||||
def _generate_html_content(
|
||||
self,
|
||||
test_suite_result: TestSuiteResult,
|
||||
title: str
|
||||
) -> str:
|
||||
"""生成HTML内容
|
||||
|
||||
Args:
|
||||
test_suite_result: 测试套件结果
|
||||
title: 报告标题
|
||||
|
||||
Returns:
|
||||
HTML内容
|
||||
"""
|
||||
pass_rate = test_suite_result.pass_rate
|
||||
duration = test_suite_result.duration
|
||||
|
||||
pass_color = "#28a745" if pass_rate >= 80 else "#ffc107" if pass_rate >= 60 else "#dc3545"
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
h1 {{
|
||||
color: #333;
|
||||
border-bottom: 3px solid #007bff;
|
||||
padding-bottom: 10px;
|
||||
}}
|
||||
.summary {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 30px 0;
|
||||
}}
|
||||
.summary-card {{
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
border: 1px solid #dee2e6;
|
||||
}}
|
||||
.summary-card h3 {{
|
||||
margin: 0 0 10px 0;
|
||||
color: #6c757d;
|
||||
font-size: 14px;
|
||||
}}
|
||||
.summary-card .value {{
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}}
|
||||
.summary-card .value.passed {{
|
||||
color: #28a745;
|
||||
}}
|
||||
.summary-card .value.failed {{
|
||||
color: #dc3545;
|
||||
}}
|
||||
.summary-card .value.skipped {{
|
||||
color: #ffc107;
|
||||
}}
|
||||
.progress-bar {{
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.progress-fill {{
|
||||
height: 100%;
|
||||
background-color: {pass_color};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
transition: width 0.3s ease;
|
||||
}}
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
th, td {{
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}}
|
||||
th {{
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}}
|
||||
tr:hover {{
|
||||
background-color: #f8f9fa;
|
||||
}}
|
||||
.status-pass {{
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}}
|
||||
.status-fail {{
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
}}
|
||||
.badge {{
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-right: 4px;
|
||||
}}
|
||||
.badge-info {{
|
||||
background-color: #17a2b8;
|
||||
color: white;
|
||||
}}
|
||||
.badge-warning {{
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}}
|
||||
.badge-danger {{
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}}
|
||||
.error-message {{
|
||||
color: #dc3545;
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
}}
|
||||
.timestamp {{
|
||||
color: #6c757d;
|
||||
font-size: 12px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>{title}</h1>
|
||||
<p class="timestamp">测试套件: {test_suite_result.suite_name}</p>
|
||||
<p class="timestamp">生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<h3>总用例数</h3>
|
||||
<div class="value">{test_suite_result.total}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>通过</h3>
|
||||
<div class="value passed">{test_suite_result.passed}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>失败</h3>
|
||||
<div class="value failed">{test_suite_result.failed}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>跳过</h3>
|
||||
<div class="value skipped">{test_suite_result.skipped}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>通过率</h3>
|
||||
<div class="value">{pass_rate:.1f}%</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>执行时长</h3>
|
||||
<div class="value">{duration:.2f}s</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {pass_rate}%">
|
||||
{pass_rate:.1f}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>测试结果详情</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用例ID</th>
|
||||
<th>用例名称</th>
|
||||
<th>模块</th>
|
||||
<th>状态</th>
|
||||
<th>状态码</th>
|
||||
<th>响应时间</th>
|
||||
<th>错误信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
"""
|
||||
|
||||
for result in test_suite_result.results:
|
||||
status_class = "status-pass" if result.passed else "status-fail"
|
||||
status_text = "通过" if result.passed else "失败"
|
||||
error_msg = result.error_message if result.error_message else ""
|
||||
response_time = f"{result.performance.response_time / 1000:.3f}s" if result.performance else "N/A"
|
||||
|
||||
html += f"""
|
||||
<tr>
|
||||
<td>{result.test_case.id}</td>
|
||||
<td>{result.test_case.name}</td>
|
||||
<td>{result.test_case.module}</td>
|
||||
<td class="{status_class}">{status_text}</td>
|
||||
<td>{result.status_code}</td>
|
||||
<td>{response_time}</td>
|
||||
<td class="error-message">{error_msg}</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
html += """
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return html
|
||||
Reference in New Issue
Block a user