feat(admin): 添加用户管理相关文件

添加用户管理视图、API和状态管理文件
This commit is contained in:
张翔
2026-03-28 14:37:29 +08:00
commit 08ea5fbe98
1643 changed files with 255646 additions and 0 deletions
@@ -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,182 @@
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:
"""获取超时时间(毫秒)"""
timeout = self.get("target.timeout", 5000)
# 处理环境变量占位符未被解析的情况
if isinstance(timeout, str):
try:
return int(timeout)
except (ValueError, TypeError):
return 5000
return timeout
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.orchestrator.test_orchestrator import TestOrchestrator
from apitest.report.report_manager import ReportManager
from apitest.config.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"]
@@ -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