找回密码
 立即注册
首页 业界区 业界 将分散的Pytest测试脚本统一接入测试平台:FastAPI改造 ...

将分散的Pytest测试脚本统一接入测试平台:FastAPI改造方案详解

益竹月 2026-1-25 15:30:04
在上一篇文章《Pytest 测试用例自动生成:接口自动化进阶实践》中,我们已经解决了“如何高效编写和维护接口自动化用例”的问题。
然而,随着业务的发展和团队规模的扩大,很多公司会选择开发自己的测试平台,以实现更高效、更统一的管理。
企业接口自动化通常会经历如下过程:

  • 初期:测试人员本地用 Pytest 写脚本(即脚本项目)
  • 中期:接入 Jenkins,定时跑一跑
  • 后期:公司开始建设统一测试平台
一. 为什么需要将脚本项目接入测试平台

1. 当前面临的挑战


  • 用例复用难:已有的大量 Pytest 测试用例无法直接在平台上运行,重新编写成本高。
  • 管理复杂:多个脚本项目分散管理,执行效率低、维护困难。
  • 报告分散:测试报告散落在各个项目中,难以统一查看和分析。
因此,需要考虑在【不重写、不侵入、不破坏】现有 Pytest 脚本项目的前提下,让它具备“测试平台可接入能力”
2. 两种可选方案

我们考虑以下两种方案将脚本项目接入测试平台:

  • 方案一:将脚本项目直接集成到测试平台目录中。
  • 方案二:使用 FastAPI 改造脚本项目,提供接口供平台调用。
为什么选择方案二?

  • 脚本项目经常需要修改和更新,若集成到平台中,每次修改都需要重新发布平台代码,繁琐且易出错。
  • FastAPI 轻量、高性能,适合快速构建RESTful接口,实现脚本与平台的解耦。
二. 使用 FastAPI 框架改造脚本项目

1. 核心思路

我们的目标是:不改动原有Pytest脚本逻辑,仅通过"封装+接口"的方式,让脚本项目具备平台接入能力
实现原则:

  • 不重写:保持原有 testcases/、utils/ 目录结构不变
  • 不侵入:原有脚本文件无需修改任何代码
  • 不解耦:仅新增API层,不改变原有执行逻辑
2. 目录改造

原有脚本项目核心目录保留不变,改造后新增「API 层、配置层」,确保原有脚本无需修改即可复用。

  • 改造前目录(即脚本项目原始目录)示例如下:
    1. SUPER-API-AUTO-TEST/          # 接口自动化测试项目根目录
    2. ├── auth.py                    # 鉴权相关
    3. ├── case_generator.py          # 用例生成逻辑
    4. ├── config.yaml                # 项目配置
    5. ├── conftest.py                # Pytest夹具
    6. ├── runner.py                  # 用例执行入口
    7. ├── reports/                   # 测试报告目录
    8. ├── logs/                      # 日志目录
    9. ├── testcases/                 # 测试用例脚本(Python)
    10. ├── testcases_data/            # 测试用例数据(YAML)
    11. └── utils/                     # 通用工具类
    复制代码
  • Fastapi 改造后目录结构示例如下:
    1. FASTAPI-SUPER-API-AUTO-TEST/  # 基于FastAPI的改造后目录
    2. ├── auth.py                    
    3. ├── case_generator.py         
    4. ├── conftest.py               
    5. ├── main.py                   # FastAPI应用入口(核心新增)
    6. ├── pytest.ini               
    7. ├── runner.py                  
    8. ├── api/                      # API层(核心新增)
    9. │   ├── testcase_route.py     # 接口路由定义(URL、请求参数)
    10. │   ├── testcase_service.py   # 接口业务逻辑(与原有脚本交互)
    11. │   └── __init__.py           
    12. ├── configs/                  # 配置目录(拆分原有config.yaml)
    13. │   └── config.yaml           
    14. ├── logs/                     
    15. ├── reports/                  
    16. ├── testcases/                # 完全复用原有目录
    17. ├── testcases_data/           # 完全复用原有目录
    18. └── utils/                    # 完全复用原有目录
    复制代码
改造后,新增了 Fastapi 路由层 api/、配置层 configs/,以及 FastAPI 应用入口 main.py。
三. 核心接口设计与实现

1. 接口设计示例

我们设计了以下关键接口供测试平台调用:
① 获取项目与模块信息

  • GET /api_test/testcases/projects:获取所有项目列表
  • GET /api_test/testcases/modules:获取指定项目下的模块列表
② 获取用例列表

  • GET /api_test/testcases/list:支持按项目/模块筛选测试用例
③ 生成测试用例

  • POST /api_test/testcases/generate:根据YAML用例文件生成Python测试脚本
④ 执行测试任务

  • POST /api_test/testcases/run:后台执行指定测试用例,支持环境选择、报告类型、回调通知等
⑤ 获取测试报告

  • GET /api_test/reports/get_by_task:根据任务ID获取报告访问地址
2. 代码示例

testcase_service.py
  1. # @author:  xiaoqq
  2. import os, re
  3. from datetime import datetime
  4. from typing import List, Optional, Dict
  5. from pathlib import Path
  6. TESTCASE_ROOT = "testcases"
  7. def get_abs_root_path(root_path: str) -> Path:
  8.     """
  9.     使用当前文件相对路径构造 testcases/ 的绝对路径
  10.     :param root_path: 目录名
  11.     :return:
  12.     """
  13.     base_dir = Path(__file__).resolve().parent  # 当前文件所在目录
  14.     abs_root_path = (base_dir.parent / root_path).resolve()
  15.     return abs_root_path
  16. def get_all_testcases(project: Optional[str] = None,
  17.                   module: Optional[str] = None,
  18.                   root_path: str = TESTCASE_ROOT) -> List[Dict]:
  19.     """
  20.     获取所有测试用例(支持通过 project/module 筛选)
  21.     返回字段包括 filename(无后缀)、path(绝对路径字符串)、Allure 元信息等
  22.     """
  23.     abs_root_path = get_abs_root_path(root_path)
  24.     if not abs_root_path.exists():
  25.        return []
  26.    
  27.     # 路径校验
  28.     if module and not project:
  29.        raise ValueError("传入 module 前必须先传入 project")
  30.    
  31.     # 构造起始目录路径
  32.     search_path = abs_root_path
  33.     if project:
  34.        search_path = search_path / project
  35.     if module:
  36.        search_path = search_path / module
  37.    
  38.     if not search_path.exists():
  39.        return []
  40.    
  41.     testcases = []
  42.    
  43.     for dirpath, _, filenames in os.walk(search_path):
  44.        for file in filenames:
  45.           if file.startswith("test_") and file.endswith(".py"):
  46.              full_path = os.path.join(dirpath, file)
  47.              rel_path = os.path.relpath(full_path, abs_root_path)  # 相对路径,如 merchant/device/test_xxx.py
  48.              path_parts = Path(rel_path).parts  # 使用 pathlib 安全拆解路径
  49.             
  50.              if len(path_parts ) < 2:
  51.                 continue  # 至少要有 project/filename 结构
  52.             
  53.              _project = path_parts [0]
  54.              _filename = path_parts [-1]
  55.              _module = path_parts [1] if len(path_parts ) > 2 else None  # module 可选
  56.             
  57.              # 按传参过滤
  58.              if project and _project != project:
  59.                 continue
  60.              if module and _module != module:
  61.                 continue
  62.             
  63.              filename = os.path.splitext(_filename)[0]  # 去掉 .py 后缀
  64.              last_modified = datetime.fromtimestamp(os.path.getmtime(full_path)).isoformat()
  65.             
  66.              # 提取用例元信息
  67.              try:
  68.                 case_name, epic, feature, story = extract_case_info(full_path)
  69.              except Exception as e:
  70.                 case_name, epic, feature, story = None, None, None, None
  71.                
  72.              # 拼接最终 path 字段为 TESTCASE_ROOT/... 形式
  73.              full_case_path = str(Path(root_path) / rel_path).replace("\", "/")
  74.             
  75.              # 构造 external_id:project|module|filename|path
  76.              external_id = f"{_project}|{_module or 'nomodule'}|{filename}|{full_case_path}"
  77.             
  78.              testcases.append({
  79.                 "project": _project,
  80.                 "module": _module,  # None 表示无 module 层级
  81.                 "file": _filename,
  82.                 "filename": filename,
  83.                 "path": full_case_path,
  84.                 "last_modified": last_modified,
  85.                 "case_name": case_name or filename,
  86.                 "allure_epic": epic,
  87.                 "allure_feature": feature,
  88.                 "allure_story": story,
  89.                 "external_id": external_id # 加入唯一标识
  90.              })
  91.    
  92.     return testcases
  93. def extract_case_info(file_path):
  94.     """
  95.     解析测试用例文件,获取相应信息
  96.     :param file_path:
  97.     :return:
  98.     """
  99.     with open(file_path, 'r', encoding='utf-8') as file:
  100.        content = file.read()
  101.       
  102.        case_name_match = re.search(
  103.           r'def setup_class.*?\(.*?\):.*?log\.info\(\'========== 开始执行测试用例:(.+?) ==========\'',
  104.           content, re.DOTALL
  105.        )
  106.        case_name = case_name_match.group(1).strip() if case_name_match else \
  107.        os.path.splitext(os.path.basename(file_path))[0]
  108.       
  109.        allure_epic_match = re.search(r'@allure\.epic\(\'(.+?)\'\)', content)
  110.        allure_feature_match = re.search(r'@allure\.feature\(\'(.+?)\'\)', content)
  111.        allure_story_match = re.search(r'@allure\.story\(\'(.+?)\'\)', content)
  112.       
  113.        allure_epic = allure_epic_match.group(1).strip() if allure_epic_match else None
  114.        allure_feature = allure_feature_match.group(1).strip() if allure_feature_match else None
  115.        allure_story = allure_story_match.group(1).strip() if allure_story_match else None
  116.       
  117.        return case_name, allure_epic, allure_feature, allure_story
  118. def get_all_projects(root_path: str = TESTCASE_ROOT) -> List[Dict[str, str]]:
  119.     """
  120.     获取 testcases/ 下所有项目名、相对路径及创建时间(倒序排序)
  121.     """
  122.     abs_root_path = get_abs_root_path(root_path)
  123.     if not abs_root_path.exists():
  124.        return []
  125.    
  126.     projects = []
  127.     for d in abs_root_path.iterdir():
  128.        if d.is_dir():
  129.           created_time = datetime.fromtimestamp(d.stat().st_ctime)
  130.           projects.append({
  131.              "name": d.name,
  132.              "path": str(Path(root_path) / d.name).replace("\", "/"),
  133.              "created_time": created_time.isoformat()
  134.           })
  135.    
  136.     # 按创建时间倒序
  137.     return sorted(projects, key=lambda x: x["created_time"], reverse=True)
  138. def get_all_projects_and_modules(
  139.     project: Optional[str] = None,
  140.     root_path: str = TESTCASE_ROOT
  141. ) -> List[Dict]:
  142.     """
  143.     获取所有项目和模块结构(支持指定项目)。包含路径、创建时间,按项目时间倒序。
  144.     """
  145.     abs_root_path = get_abs_root_path(root_path)
  146.     if not abs_root_path.exists():
  147.        return []
  148.    
  149.     result = []
  150.    
  151.     for proj_dir in abs_root_path.iterdir():
  152.        if not proj_dir.is_dir():
  153.           continue
  154.       
  155.        proj_name = proj_dir.name
  156.        if project and proj_name != project:
  157.           continue
  158.       
  159.        proj_created_time = datetime.fromtimestamp(proj_dir.stat().st_ctime)
  160.        modules = []
  161.       
  162.        # 遍历模块目录时需要忽略的子目录
  163.        EXCLUDE_DIRS = {"__pycache__", ".pytest_cache", ".git", ".idea"}
  164.        for mod_dir in proj_dir.iterdir():
  165.           if mod_dir.is_dir() and mod_dir.name not in EXCLUDE_DIRS:
  166.              mod_created_time = datetime.fromtimestamp(mod_dir.stat().st_ctime)
  167.              modules.append({
  168.                 "name": mod_dir.name,
  169.                 "path": str(Path(root_path) / proj_name / mod_dir.name).replace("\", "/"),
  170.                 "created_time": mod_created_time.isoformat()
  171.              })
  172.       
  173.        # 模块也可以排序(如有需求)
  174.        modules.sort(key=lambda x: x["created_time"], reverse=True)
  175.       
  176.        result.append({
  177.           "project": proj_name,
  178.           "path": str(Path(root_path) / proj_name).replace("\", "/"),
  179.           "created_time": proj_created_time.isoformat(),
  180.           "modules": modules
  181.        })
  182.       
  183.        if project:
  184.           break
  185.    
  186.     # 项目排序
  187.     return sorted(result, key=lambda x: x["created_time"], reverse=True)
  188. def generate_testcase(case_yaml_list: list = None):
  189.     """
  190.     生成测试用例
  191.     :return:
  192.     """
  193.     from case_generator import CaseGenerator
  194.     CG = CaseGenerator()
  195.     CG.generate_testcases(project_yaml_list=case_yaml_list)
  196. if __name__ == '__main__':
  197.     # print(get_all_testcases())
  198.     # print(get_all_projects())
  199.     print(get_all_projects_and_modules(project="merchant"))
复制代码
testcase_route.py 示例如下:
  1. # @author:  xiaoqq
  2. from pathlib import Path
  3. from fastapi import APIRouter, BackgroundTasks, Query, Body
  4. from pydantic import BaseModel
  5. from typing import List, Optional
  6. from runner import run_tests
  7. from api.testcase_service import (
  8.         get_all_testcases,
  9.         get_all_projects,
  10.         get_all_projects_and_modules,
  11.         generate_testcase,
  12. )
  13. router = APIRouter()
  14. class TestExecutionRequest(BaseModel):
  15.         testcases: Optional[List[str]] = ['testcases/']  # 默认运行所有目录
  16.         env: Optional[str] = 'pre'
  17.         report_type: Optional[str] = 'pytest-html'
  18.         dingtalk_notify: Optional[bool] = True
  19.         task_id: Optional[str]
  20.         callback_url: Optional[str]
  21.         auth_token: Optional[str] = None  # 新增字段:从平台传入的 token
  22. # 执行测试用例
  23. @router.post("/testcases/run")
  24. def run_testcases(request: TestExecutionRequest, background_tasks: BackgroundTasks):
  25.         try:
  26.                 background_tasks.add_task(
  27.                         run_tests,
  28.                         testcases=request.testcases,
  29.                         env=request.env,
  30.                         report_type=request.report_type,
  31.                         dingtalk_notify=request.dingtalk_notify,
  32.                         task_id=request.task_id,
  33.                         callback_url=request.callback_url,
  34.                         auth_token=request.auth_token,  # 测试平台回调 auth_token
  35.                 )
  36.                 return {
  37.                         "code": 0,
  38.                         "msg": "测试任务已提交后台执行",
  39.                         "task_id": request.task_id
  40.                 }
  41.         except Exception as e:
  42.                 return {"code":1, "msg": f"测试任务失败:{str(e)}"}
  43. # 获取测试用例
  44. @router.get("/testcases/list")
  45. def list_testcases(project: str = Query(None), module: str = Query(None)):
  46.         try:
  47.                 testcases = get_all_testcases(project, module)
  48.                 return {
  49.                         "code": 0,
  50.                         "msg": "success",
  51.                         "testcases": testcases
  52.                 }
  53.         except Exception as e:
  54.                 return {"code": 1, "msg": f"获取测试用例失败:{str(e)}"}
  55. # 获取 testcases/ 中的所有测试项目
  56. @router.get("/testcases/projects")
  57. def list_projects():
  58.         try:
  59.                 projects = get_all_projects()
  60.                 return {"code": 0, "msg": "success", "projects": projects}
  61.         except Exception as e:
  62.                 return {"code": 1, "msg": f"获取测试项目失败:{str(e)}"}
  63.        
  64. # 获取 testcases/ 中的所有测试项目及模块
  65. @router.get("/testcases/modules")
  66. def list_modules(project: str = Query(None)):
  67.         try:
  68.                 modules = get_all_projects_and_modules(project)
  69.                 return {"code": 0, "msg": "success", "modules": modules}
  70.         except Exception as e:
  71.                 return {"code": 1, "msg": f"获取测试项目-模块失败:{str(e)}"}
  72. class GenerateCaseRequest(BaseModel):
  73.         case_yaml_list: Optional[List[str]] = None
  74. # 根据 testcases_data/ 中的测试数据生成测试用例文件
  75. @router.post("/testcases/generate")
  76. def generate_testcase_route(req: GenerateCaseRequest):
  77.         try:
  78.                 generate_testcase(req.case_yaml_list)
  79.                 return {"code": 0, "msg": "success"}
  80.         except Exception as e:
  81.                 return {"code": 1, "msg": f"获取测试项目-模块失败:{str(e)}"}
  82. @router.get("/reports/get_by_task")
  83. def get_report_by_task(
  84.                 task_id: str,
  85.                 report_type: str,
  86.                 created_at: str  # 格式: "20250814"
  87. ):
  88.         """
  89.         根据 task_id + 创建时间 + report_type 获取报告 URL
  90.         """
  91.         if not created_at:
  92.                 return {"code": 1, "msg": "created_at 必填", "url": None}
  93.        
  94.         base_path = Path(__file__).resolve().parent.parent / "reports" / created_at
  95.        
  96.         if report_type == "pytest-html":
  97.                 report_file = base_path / f"report_{task_id}.html"
  98.         elif report_type == "allure":
  99.                 report_file = base_path / f"report_{task_id}_allure/html/index.html"
  100.         else:
  101.                 return {"code": 1, "msg": "未知 report_type", "url": None}
  102.        
  103.         if not report_file.exists():
  104.                 return {"code": 1, "msg": "报告文件不存在", "url": None}
  105.        
  106.         relative_url = str(report_file.relative_to(Path(__file__).resolve().parent.parent)).replace("\", "/")
  107.         return {"code": 0, "msg": "success", "url": f"/{relative_url}"}
复制代码
mian.py
  1. from fastapi import FastAPI
  2. from api import testcase_route
  3. from pathlib import Path
  4. from fastapi.staticfiles import StaticFiles
  5. app = FastAPI(title="接口自动化测试服务")
  6. # 挂载测试用例路由
  7. app.include_router(testcase_route.router, prefix="/api_test", tags=["测试任务"])
  8. # 挂载 reports 目录为静态文件目录
  9. reports_dir = Path(__file__).parent / "reports"
  10. reports_dir.mkdir(exist_ok=True)  # 确保目录存在
  11. app.mount("/reports", StaticFiles(directory=reports_dir), name="reports")
  12. if __name__ == "__main__":
  13.     from utils.log_manager import LogManager
  14.     LogManager.setup_logging()  # 启动时显式初始化日志
  15.    
  16.     import uvicorn
  17.     uvicorn.run(
  18.         "main:app",
  19.         host="0.0.0.0",
  20.         port=8000,
  21.         # reload=True,
  22.         reload_excludes=["testcases/*", "logs/*", "reports/*"]  # 排除这些目录的文件变更
  23.     )
复制代码
四. 测试平台调用

执行mian.py,启动 Fastapi 项目后,便可在测试平台通过调用相关接口来管理该脚本测试项目(平台调用代码不具体提供)。
1. 调用示意图
  1. 测试平台
  2.    │
  3.    │ HTTP 调用
  4.    ▼
  5. FastAPI 测试服务
  6.    │
  7.    │ pytest 执行
  8.    ▼
  9. 测试报告生成
  10.    │
  11.    │ 回调结果
  12.    ▼
  13. 测试平台展示
复制代码
这样,职责边界非常清晰:

  • 测试平台:调度、记录、展示,
  • 改造后的测试服务:执行、产出报告
2. 测试平台界面

平台测试用例列表:
<img alt="image-20260120134215814" loading="lazy">

测试报告列表:
<img alt="image-20260122143715247" loading="lazy">

五. 总结

方案优势总结如下:

  • 解耦与复用:脚本项目独立维护,平台通过接口调用,互不影响
  • 灵活执行:支持按项目、模块、用例筛选执行,适应不同测试场景。
  • 异步处理:长时间任务后台执行,平台可实时获取状态与报告。
  • 报告统一管理:所有报告集中存储,支持在线统一查看。
当然,示例代码还可以进行优化扩展,如加入用户认证机制来保障接口安全等。
当接口自动化发展到一定规模,单机脚本 或 Jenkins Job 都会成为瓶颈,而“脚本服务化 + 平台调度”,几乎是所有成熟团队最终都会走到的一步。
如果你:

  • 正在做接口自动化
  • 或正在参与测试平台建设
  • 或正在被“脚本怎么接平台”折磨
那么,希望这篇文章能少让你走一点弯路。
               
1.png
        
2.png
        本文作者:给你一页白纸        版权申明:本博客所有文章除特殊声明外,均采用BY-NC-SA         许可协议。转载请注明出处!        声援博主:如果觉得这篇文章对您有帮助,请点一下右下角的                “推荐”                图标哦,您的                “推荐”                是我写作的最大动力。您也可以点击下方的        【关注我】        按钮,关注博主不迷路。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册