目录
组件架构

功能流程

项目结构
ftfw/
├── requirements.txt
├── ftfw/
│ ├── __init__.py
│ ├── cli.py # 命令行入口
│ ├── executor.py # 测试执行器
│ ├── reporter.py # 报告生成
│ └── utils/
│ ├── http.py
│ └── validator.py
ft/
└── server_test/
├── testconfig.yaml
├── Test_0001.yaml
└── Test_0002.yaml
核心代码
cli.py
# -*- coding: utf-8 -*-
import argparse
import logging
import time
from pathlib import Path
from .executor import TestExecutor
from .reporter import ConsoleReporter
def configure_logging():
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
def main():
configure_logging()
parser = argparse.ArgumentParser(description="Microservice Test Framework")
parser.add_argument(
"testsuites",
nargs="*",
help="Name(s) of test suite directories"
)
args = parser.parse_args()
logging.debug(f"Received testsuites: {args.testsuites}")
base_path = Path("./ft")
# 如果没传参数,自动扫描所有测试文件夹
if not args.testsuites:
tests_to_run = [p.name for p in base_path.iterdir() if p.is_dir()]
logging.info(f"No testsuite specified, auto-discovered: {tests_to_run}")
else:
tests_to_run = args.testsuites
overall_results = {
"passed": {},
"failed": {},
"start_time": time.strftime("%Y-%m-%d %H:%M:%S"),
"duration": 0
}
start_time = time.time()
try:
for suite_name in tests_to_run:
suite_path = base_path / suite_name
logging.info(f"Begin run testsuite: {suite_path}")
executor = TestExecutor(suite_path)
if not executor.service_check():
logging.error(f"Service check failed for {suite_name}, skipping")
overall_results["failed"][suite_name] = "Service check failed"
continue
for case in executor.config["testcase"]:
logging.debug(f"Execute testcase {case} in suite {suite_name}")
success, details = executor.execute_testcase(case)
ConsoleReporter.print_case_result(case, success, details)
if success:
overall_results["passed"][f"{suite_name}/{case}"] = details
else:
overall_results["failed"][f"{suite_name}/{case}"] = "\n".join(details)
overall_results["duration"] = time.time() - start_time
ConsoleReporter.generate_summary(overall_results)
return 0 if not overall_results["failed"] else 1
except Exception as e:
logging.error(f"Critical error occurred: {str(e)}")
return 2
if __name__ == "__main__":
raise SystemExit(main())
executor.py # 测试执行器
# -*- coding: utf-8 -*-
import yaml
import logging
from pathlib import Path
from typing import Dict, List, Tuple
from .utils.http import HttpClient
from .utils.validator import ResponseValidator
class TestExecutor:
def __init__(self, suite_path: Path):
self.suite_path = suite_path
self.http = HttpClient()
self.logger = logging.getLogger("executor")
self.config = self._load_config()
self._validate_paths()
def _load_config(self) -> Dict:
"""加载测试套件配置"""
config_file = self.suite_path / "testconfig.yaml"
with open(config_file) as f:
config = yaml.safe_load(f)
if config["testsuit_name"] != self.suite_path.name:
self.logger.error(f"Test suite name mismatch, please check testsuit_name and files")
raise ValueError("Test suite name mismatch")
return config
def _validate_paths(self):
"""验证测试用例文件存在"""
missing = [f for f in self.config["testcase"]
if not (self.suite_path / f"{f}.yaml").exists()]
if missing:
raise FileNotFoundError(f"Missing test cases: {missing}")
def service_check(self) -> bool:
"""服务健康检查"""
services = {
"Front": self.config["Front"],
"Backend": self.config["back_end"]
}
all_healthy = True
for name, service in services.items():
url = f"http://{service}"
if self.http.check_service_health(url):
self.logger.info(f"✅ {name} service healthy")
else:
self.logger.error(f"❌ {name} service unavailable")
all_healthy = False
return all_healthy
def execute_testcase(self, case_name: str) -> Tuple[bool, List[str]]:
"""执行单个测试用例"""
case_path = self.suite_path / f"{case_name}.yaml"
with open(case_path) as f:
case = yaml.safe_load(f)
self.logger.debug(f"case description: {case['description']}")
try:
# 发送请求
resp = self.http.send_request(
method=case["request"]["method"],
url=f"https://{self.config['back_end']}{case['request']['path']}",
headers=case["request"].get("headers"),
body=case["request"].get("body")
)
except RuntimeError as e:
return False, [str(e)]
self.logger.debug(f"get response as below code: {resp.status_code},data:{resp.content}")
# 结果验证
failures = []
status_msg = ResponseValidator.status_code_validate(
case["expected"]["status_code"],
resp.status_code
)
if status_msg:
failures.append(status_msg)
if "data" in case["expected"]:
try:
data_diff = ResponseValidator.validate(
case["expected"]["data"],
resp.json()
)
failures.extend(data_diff)
except ValueError:
failures.append("Invalid JSON response")
return len(failures) == 0, failures
reporter.py
# -*- coding: utf-8 -*-
import argparse
import logging
import time
from pathlib import Path
from .executor import TestExecutor
from .reporter import ConsoleReporter
def configure_logging():
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
def main():
configure_logging()
parser = argparse.ArgumentParser(description="Microservice Test Framework")
parser.add_argument(
"testsuites",
nargs="*",
help="Name(s) of test suite directories"
)
args = parser.parse_args()
logging.debug(f"Received testsuites: {args.testsuites}")
base_path = Path("./ft")
# 如果没传参数,自动扫描所有测试文件夹
if not args.testsuites:
tests_to_run = [p.name for p in base_path.iterdir() if p.is_dir()]
logging.info(f"No testsuite specified, auto-discovered: {tests_to_run}")
else:
tests_to_run = args.testsuites
overall_results = {
"passed": {},
"failed": {},
"start_time": time.strftime("%Y-%m-%d %H:%M:%S"),
"duration": 0
}
start_time = time.time()
try:
for suite_name in tests_to_run:
suite_path = base_path / suite_name
logging.info(f"Begin run testsuite: {suite_path}")
executor = TestExecutor(suite_path)
if not executor.service_check():
logging.error(f"Service check failed for {suite_name}, skipping")
overall_results["failed"][suite_name] = "Service check failed"
continue
for case in executor.config["testcase"]:
logging.debug(f"Execute testcase {case} in suite {suite_name}")
success, details = executor.execute_testcase(case)
ConsoleReporter.print_case_result(case, success, details)
if success:
overall_results["passed"][f"{suite_name}/{case}"] = details
else:
overall_results["failed"][f"{suite_name}/{case}"] = "\n".join(details)
overall_results["duration"] = time.time() - start_time
ConsoleReporter.generate_summary(overall_results)
return 0 if not overall_results["failed"] else 1
except Exception as e:
logging.error(f"Critical error occurred: {str(e)}")
return 2
if __name__ == "__main__":
raise SystemExit(main())
http.py
# -*- coding: utf-8 -*-
import requests
import logging
from typing import Dict, Any
from requests.exceptions import RequestException
from tenacity import retry, stop_after_attempt, wait_fixed
class HttpClient:
def __init__(self, base_timeout: int = 10):
self.session = requests.Session()
self.base_timeout = base_timeout
self.logger = logging.getLogger("myhttp")
@retry(stop=stop_after_attempt(3), wait=wait_fixed(1))
def send_request(
self,
method: str,
url: str,
headers: Dict[str, str] = None,
body: Dict[str, Any] = None
) -> requests.Response:
"""带重试机制的请求发送"""
try:
self.logger.debug(f"Sending {method.upper()} request to {url} with headers={headers} and body={body}")
return self.session.request(
method=method.upper(),
url=url,
headers=headers or {},
json=body,
timeout=self.base_timeout
)
except RequestException as e:
raise RuntimeError(f"Request failed: {str(e)}") from e
def check_service_health(self, endpoint: str) -> bool:
"""服务健康检查"""
try:
resp = self.send_request("GET", f"{endpoint}")
return resp.status_code in [200,201,404] #为了跳过heath step加入404,正常heathlcheck应该2xx的状态码
except RuntimeError:
return False
validator.py
from typing import Any, Dict, List
from deepdiff import DeepDiff
import logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("ResponseValidator")
class ResponseValidator:
@staticmethod
def validate(
expected: Dict[str, Any],
actual: Dict[str, Any]
) -> List[str]:
"""深度验证数据结构差异"""
logger.debug(f"expected: {expected}")
logger.debug(f"actual: {actual}")
diff = DeepDiff(
expected,
actual,
ignore_order=True,
exclude_paths=["root['timestamp']"]
)
if not diff:
return []
diff_dict = diff.to_dict()
# 将每个差异项转换成字符串
diff_str_list = []
for path, change in diff_dict.items():
for key, value in change.items():
diff_str_list.append(f"{key}: {value}")
logger.debug(f"Differences found: {diff_str_list}")
return diff_str_list
#return list(diff.to_dict().values()) if diff else []
@staticmethod
def status_code_validate(expected: int, actual: int) -> str:
"""状态码验证"""
if expected != actual:
msg = f"Expected status {expected}, got {actual}"
logger.error(msg)
return f"Expected status {expected}, got {actual}"
logger.debug("Status codes match.")
return ""
testconfig.yaml
testsuit_name: server_test
Front: catfact.ninja/fact/1
back_end: catfact.ninja/fact/1
testcase:
- Test_0001
- Test_0002
Test_0001.yaml
description: "check"
request:
method: GET
path: "/"
headers:
Content-Type: application/json
body:
product_id: "A123"
quantity: 2
expected:
status_code: 404
data:
message: 'Not Found'
code: 404
测试结果
python -m ftfw.cli server_test
2025-05-04 03:55:10,929 - root - DEBUG - Received testsuites: ['server_test']
2025-05-04 03:55:10,929 - root - INFO - Begin run testsuite: ft\server_test
2025-05-04 03:55:10,929 - myhttp - DEBUG - Sending GET request to http://catfact.ninja/fact/1 with headers=None and body=None
2025-05-04 03:55:10,947 - urllib3.connectionpool - DEBUG - Starting new HTTP connection (1): catfact.ninja:80
2025-05-04 03:55:11,630 - urllib3.connectionpool - DEBUG - http://catfact.ninja:80 "GET /fact/1 HTTP/11" 301 None
2025-05-04 03:55:11,637 - urllib3.connectionpool - DEBUG - Starting new HTTPS connection (1): catfact.ninja:443
2025-05-04 03:55:12,240 - urllib3.connectionpool - DEBUG - https://catfact.ninja:443 "GET /fact/1 HTTP/11" 404 None
2025-05-04 03:55:12,252 - executor - INFO - ✅ Front service healthy
2025-05-04 03:55:12,252 - myhttp - DEBUG - Sending GET request to http://catfact.ninja/fact/1 with headers=None and body=None
2025-05-04 03:55:12,487 - urllib3.connectionpool - DEBUG - http://catfact.ninja:80 "GET /fact/1 HTTP/11" 301 None
2025-05-04 03:55:12,712 - urllib3.connectionpool - DEBUG - https://catfact.ninja:443 "GET /fact/1 HTTP/11" 404 None
2025-05-04 03:55:12,712 - executor - INFO - ✅ Backend service healthy
2025-05-04 03:55:12,712 - root - DEBUG - Execute testcase Test_0001 in suite server_test
2025-05-04 03:55:12,737 - executor - DEBUG - case description: check
2025-05-04 03:55:12,737 - myhttp - DEBUG - Sending GET request to https://catfact.ninja/fact/1/ with headers={'Content-Type': 'application/json'} and body={'product_id': 'A123', 'quantity': 2}
2025-05-04 03:55:12,966 - urllib3.connectionpool - DEBUG - https://catfact.ninja:443 "GET /fact/1/ HTTP/11" 404 None
2025-05-04 03:55:12,966 - executor - DEBUG - get response as below code: 404,data:b'{"message":"Not Found","code":404}'
2025-05-04 03:55:12,967 - ResponseValidator - DEBUG - Status codes match.
2025-05-04 03:55:12,968 - ResponseValidator - DEBUG - expected: {'message': 'Not Found', 'code': 404}
2025-05-04 03:55:12,968 - ResponseValidator - DEBUG - actual: {'message': 'Not Found', 'code': 404}
2025-05-04 03:55:12,968 - report - DEBUG - ▶ Test_0001: PASS
2025-05-04 03:55:12,968 - root - DEBUG - Execute testcase Test_0002 in suite server_test
2025-05-04 03:55:12,975 - executor - DEBUG - case description: check2
2025-05-04 03:55:12,976 - myhttp - DEBUG - Sending GET request to https://catfact.ninja/fact/1/ with headers={'Content-Type': 'application/json'} and body={'product_id': 'A123', 'quantity': 2}
2025-05-04 03:55:13,226 - urllib3.connectionpool - DEBUG - https://catfact.ninja:443 "GET /fact/1/ HTTP/11" 404 None
2025-05-04 03:55:13,226 - executor - DEBUG - get response as below code: 404,data:b'{"message":"Not Found","code":404}'
2025-05-04 03:55:13,227 - ResponseValidator - DEBUG - Status codes match.
2025-05-04 03:55:13,227 - ResponseValidator - DEBUG - expected: {'length': 110}
2025-05-04 03:55:13,227 - ResponseValidator - DEBUG - actual: {'message': 'Not Found', 'code': 404}
2025-05-04 03:55:13,228 - ResponseValidator - DEBUG - Differences found: ["root: {'new_value': {'message': 'Not Found', 'code': 404}, 'old_value': {'length': 110}}"]
2025-05-04 03:55:13,228 - report - DEBUG - ▶ Test_0002: FAILLED
2025-05-04 03:55:13,228 - report - DEBUG - ✖ Issues:
2025-05-04 03:55:13,228 - report - DEBUG - - root: {'new_value': {'message': 'Not Found', 'code': 404}, 'old_value': {'length': 110}}
2025-05-04 03:55:13,229 - report - DEBUG - Test suit result:
2025-05-04 03:55:13,229 - report - DEBUG - 🕒 Start Time: 2025-05-04 03:55:10
2025-05-04 03:55:13,229 - report - DEBUG - ⏱️ Duration: 2.30s
2025-05-04 03:55:13,230 - report - DEBUG - ✅ Passed: 1
2025-05-04 03:55:13,230 - report - DEBUG - ❌ Failed: 1
2025-05-04 03:55:13,230 - report - DEBUG - Test suit FAILED cases list:
▸ server_test/Test_0002 FAILED
注解:
如果python -m ftfw.cli后面不写测试文件夹,或过滤当前目下所有问价夹进行测试,在heathl check的那一步 resp.status_code in [200,201,404] #为了跳过heath step加入404,正常heathlcheck应该2xx的状态码。
重要的第三方库
1. PyYAML
用途:解析 YAML 格式的配置文件(如 testconfig.yaml)和测试用例文件。测试套件配置加载:
在 TestRunner 类的 _load_config 方法中,用于加载 testconfig.yaml以及用例解析:
import yaml
with open(config_file) as f:
config = yaml.safe_load(f)
2. requests
用途:发送 HTTP 请求到被测试的微服务(如 client 和 server)。服务健康检查:在 HttpClient 类的 check_service_health 方法中,发送探活请求:resp = self.send_request("GET", f"{endpoint}/health");测试请求发送
3. deepdiff
用途:深度比较预期结果与实际响应的差异,支持复杂数据结构(如嵌套 JSON)。
4. tenacity
用途:为 HTTP 请求添加重试机制,提升网络波动的容错性。HTTP 请求重试。
5. coloredlogs
用途:美化控制台日志输出,提升可读性(如不同颜色区分日志级别)。coloredlogs.install(level=logging.INFO)
6. colored
用途:在终端输出彩色文本,突出显示测试结果(如红色标记失败用例)。
from colored import fg, attr
print(f"{fg('green')}✅ PASSED{attr('reset')}")
print(f"{fg('red')}❌ FAILED{attr('reset')}")
依赖汇总 (requirements.txt)
PyYAML==6.0.1 # YAML解析
requests==2.31.0 # HTTP请求
deepdiff==6.7.1 # 数据对比
tenacity==8.2.3 # 重试机制
coloredlogs==15.0.1 # 日志美化
colored==1.4.4 # 终端颜色
331



被折叠的 条评论
为什么被折叠?



