从 0 到 1 搭建 Python 接口自动化测试框架(博客系统实战)

一、前言:为什么要做接口自动化?

在做博客系统的功能测试时,我发现每次回归都要重复执行几十次登录、发布、编辑、删除的操作,效率极低还容易漏测。而且接口的底层逻辑问题,单靠 UI 测试很难发现。

于是我决定用 Python + Pytest 搭建一套接口自动化框架,目标很明确:

  • 覆盖核心业务接口(登录、博客增删改查)
  • 支持数据驱动,减少重复代码
  • 生成可视化测试报告
  • 日志完整,方便定位问题
  • 结构清晰,方便后续维护和扩展

二、框架整体架构设计

1. 技术选型

  • 语言 / 框架:Python + Pytest(测试框架)
  • 核心模块
    • requests: 发送 HTTP 请求
    • PyYAML: 读取 / 写入测试数据和配置
    • jsonschema: 响应数据格式校验
    • allure-pytest:生成可视化测试报告
    • logging: 日志记录
  • 开发工具:PyCharm

2. 项目目录结构


三、核心模块封装(工具类实现)

1. 请求封装:统一处理 GET/POST 请求

最底层的封装get/post请求 utils/request_util.py

python 复制代码
import requests
from utils.logger_util import Logger

class Request:
    log = Logger.getlog()

    def get(self, url, **kwargs):
        self.log.info(f"【GET请求】{url}")
        self.log.info(f"请求参数:{kwargs}")
        resp = requests.get(url=url,** kwargs)
        self.log.info(f"响应状态码:{resp.status_code}")
        try:
            res_data = resp.json()
        except:
            res_data = resp.text
        self.log.info(f"响应内容:{res_data}")
        return resp

    def post(self, url,** kwargs):
        self.log.info(f"【POST请求】{url}")
        self.log.info(f"请求参数:{kwargs}")
        resp = requests.post(url=url,** kwargs)
        self.log.info(f"响应状态码:{resp.status_code}")
        try:
            res_data = resp.json()
        except:
            res_data = resp.text
        self.log.info(f"响应内容:{res_data}")
        return resp
复制代码
http_client.py对get/post请求的二次封装:
python 复制代码
from utils.request_util import Request

class ApiClient:
    def __init__(self, base_url, global_headers=None):
        self.base_url = base_url
        self.global_headers = global_headers or {}
        self.token = None  # 👈 专门存 token
        self.req = Request()

    # 核心:不管什么头,都自动加上 token
    def _get_req_headers(self, kwargs):
        # 1. 拿到用户传的 headers 或者全局 headers
        if "headers" in kwargs:
            headers = kwargs.pop("headers")
            # 在这里直接返回headers,测试未登录时不加token
            return headers
        else:
            headers = dict(self.global_headers)

        # 2.只要有 token,就强制加进去(永远不丢)
        if self.token:
            headers["user_token_header"] = self.token

        # print(f"头文件中的内容为{headers}")
        return headers

    # 设置 token(登录后调用)
    def set_token(self, token):
        self.token = token

    # ------------------------------
    # 所有请求方法(不变)
    # ------------------------------
    def get(self, uri, **kwargs):
        url = f"{self.base_url}/{uri.lstrip('/')}"
        headers = self._get_req_headers(kwargs)
        return self.req.get(url, headers=headers, **kwargs)

    def post(self, uri, **kwargs):
        url = f"{self.base_url}/{uri.lstrip('/')}"
        headers = self._get_req_headers(kwargs)
        return self.req.post(url, headers=headers,**kwargs)

    def close(self):
        pass

2. YAML 工具类:读写测试数据

utils/yaml_util.py

python 复制代码
import os
import yaml

# 往yaml文件中写入数据(追加模式)
def write_yaml(filename, data):
    file_path = os.path.join(os.getcwd(), "data", filename)
    with open(file_path, mode="a+", encoding="utf-8") as f:
        yaml.safe_dump(data, stream=f, allow_unicode=True)

# 读取yaml文件中的数据(按key读取)
def read_yaml(filename, key):
    file_path = os.path.join(os.getcwd(), "data", filename)
    with open(file_path, mode="r", encoding="utf-8") as f:
        data = yaml.safe_load(f)
        return data[key]

# 清空yaml文件内容
def clear_yaml(filename):
    file_path = os.path.join(os.getcwd(), "data", filename)
    with open(file_path, mode="w", encoding="utf-8") as f:
        f.truncate()

存储的yanl数据文件位置

3. 日志工具类:按天分割日志,区分 INFO/ERROR

utils/logger_util.py

python 复制代码
import logging
import os
import time

class InfoFilter(logging.Filter):
    def filter(self, record):
        return record.levelno == logging.INFO

class ErrorFilter(logging.Filter):
    def filter(self, record):
        return record.levelno == logging.ERROR

class Logger:
    logger = None

    @classmethod
    def getlog(cls):
        if cls.logger is not None:
            return cls.logger

        # 1. 创建 logger
        cls.logger = logging.getLogger("api-auto")
        cls.logger.setLevel(logging.DEBUG)
        cls.logger.propagate = False  # 防止日志重复打印

        # 2. 日志目录
        log_path = "./logs/"
        os.makedirs(log_path, exist_ok=True)

        # 3. 按天生成文件名
        today = time.strftime("%Y-%m-%d")
        all_file = os.path.join(log_path, f"{today}.log")
        info_file = os.path.join(log_path, f"{today}_info.log")
        err_file = os.path.join(log_path, f"{today}_error.log")

        # 4. 定义格式(你写的非常棒)
        formatter = logging.Formatter(
            "%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s"
        )

        # 5. 处理器1:全部日志
        all_handler = logging.FileHandler(all_file, encoding="utf-8")
        all_handler.setFormatter(formatter)

        # 6. 处理器2:仅 info
        info_handler = logging.FileHandler(info_file, encoding="utf-8")
        info_handler.addFilter(InfoFilter())
        info_handler.setFormatter(formatter)

        # 7. 处理器3:仅 error
        err_handler = logging.FileHandler(err_file, encoding="utf-8")
        err_handler.addFilter(ErrorFilter())
        err_handler.setFormatter(formatter)

        # 8. 添加到 logger
        cls.logger.addHandler(all_handler)
        cls.logger.addHandler(info_handler)
        cls.logger.addHandler(err_handler)

        return cls.logger

日志文件存储在logs文件下


四、测试用例编写(登录接口实战)

以登录接口为例

1:整体归到登录模块并设置执行顺序优先运行

2:定义接口地址和通用响应格式的 JSON 校验规则

3:利用参数化批量编写各类登录失败场景,包含账号密码错误、账号或密码为空等多种异常情况:

4:以表单形式发送请求后,通过 JSON Schema 校验返回结构,并断言响应状态码和错误提示与预期一致;

5:编写登录成功用例,使用两组正确账号密码发起请求,通过专属的 JSON Schema 严格校验成功响应的字段内容与 JWT 令牌格式,确认登录成功后,将获取到的令牌设置到请求客户端并写入 YAML 文件保存,供后续接口调用使用。

cases/test_login.py

python 复制代码
import allure
import pytest
from jsonschema.validators import validate

from utils.yaml_util import write_yaml, clear_yaml


@allure.feature("登录模块")
@pytest.mark.order(order=1)
class TestLogin:
    url = "/user/login"
    login_schema = {
        "type": "object",
        "required": ["code", "errMsg", "data"],
        "additionalProperties": False,  # 不允许多出字段
        "properties": {
            "code": {
                "type": "string",
            },
            "errMsg": {
                "type": "string"
            },
            "data": {
                "type": ["string", "null"],
            }
        }
    }

    # 异常登录
    @allure.story("登录失败 - 错误用户名密码")
    @pytest.mark.parametrize("login", [
        # 错误的账号,错误的密码
        {
            "username": "zhangsa",
            "password": "12345",
            "errMsg": "用户不存在"
        },
        # 错误的账号,正确的密码
        {
            "username": "lis",
            "password": "123456",
            "errMsg": "用户不存在"
        },
        # 正确的账号,错误的密码
        {
            "username": "lisi",
            "password": "12345",
            "errMsg": "密码错误"
        },
        # 不存在的账号
        {
            "username": "",
            "password": "123456",
            "errMsg": "账号或密码不能为空",
        },
        # 密码为空
        {
            "username": "lis",
            "password": "",
            "errMsg": "账号或密码不能为空",
        },
        # 账号为空
        {
            "username": "",
            "password": "",
            "errMsg": "账号或密码不能为空",
        },
    ])
    def test_login_fail(self, api_client,login):
        temp_header = {"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}
        resp = (api_client.post(
            self.url,
            data={"username": login["username"], "password": login["password"]},
            headers=temp_header
        ))
        resp_json = resp.json()
        # 断言
        validate(instance=resp_json, schema=self.login_schema)
        assert resp_json["code"] == "FAIL"
        assert resp_json["errMsg"] == login["errMsg"]


    # 正常登录
    @allure.story("登录成功 - 正确用户名密码")
    @pytest.mark.parametrize("login", [
        {
            "username": "zhangsan",
            "password": "123456",
        },
        {
            "username": "lisi",
            "password": "123456",
        }
    ])
    def test_login_success(self, api_client,login):
        temp_header = {"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"}
        resp = (api_client.post(
            self.url,
            data={"username": login["username"], "password": login["password"]},
            headers=temp_header
        ))
        login_success_schema = {
            "type": "object",
            "required": ["code", "errMsg", "data"],
            "additionalProperties": False,  # 不允许多余字段
            "properties": {
                "code": {
                    "type": "string",
                    "const": "SUCCESS"
                },
                "errMsg": {
                    "type": "string",
                    "const": ""
                },
                "data": {
                    "type": "string",
                    # JWT正则:三段base64url字符,点分割
                    "pattern": r"^[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+$"
                }
            }
        }
        resp_json = resp.json()
        # 断言
        validate(instance=resp_json, schema=login_success_schema)
        assert resp_json["code"] == "SUCCESS"
        api_client.set_token(resp_json["data"])
        token = {
            "user_token_header": resp_json["data"]
        }
        clear_yaml("data.yml")
        write_yaml("data.yml", token)

列表页接口

整体归到列表模块并设置执行顺序第二运行

先定义接口地址和通用响应格式的 JSON 校验规则

接着利用参数化批量编写各类获取列表失败的异常场景,包含未登录、无效 token、空 token 等多种异常情况,以 GET 形式发送请求后,通过 JSON Schema 校验返回结构,并断言响应状态码和错误提示与预期一致;

同时编写正常获取列表的成功用例,分别使用 GET、POST 两种请求方式发起请求,通过专属的 JSON Schema 严格校验成功响应的字段内容与数组数据格式

确认获取成功后,将返回列表中的第一条博客 ID 提取并写入 YAML 文件保存,供后续详情接口调用使用。

python 复制代码
import allure
import pytest
from jsonschema.validators import validate

from utils.yaml_util import write_yaml


@allure.feature("列表模块")
@pytest.mark.order(order=2)
class TestList:
    url = "/blog/getList"
    json_schema = {
      "type": "object",
      "required": ["code", "errMsg", "data"],
        "additionalProperties": False,
      "properties": {
        "code": { "type": "string" },
        "errMsg": { "type": "string" },
        "data": {
          "type": "array",
            "minItems": 1,
          "items": {
            "type": "object",
            "required": [
              "id", "title", "content", "userId",
              "deleteFlag", "createTime", "updateTime", "loginUser"
            ],
              "additionalProperties": False,
            "properties": {
              "id": { "type": "integer" },
              "title": { "type": "string" },
              "content": { "type": "string" },
              "userId": { "type": "integer" },
              "deleteFlag": { "type": "integer" },
              "createTime": { "type": "string" },
              "updateTime": { "type": "string" },
              "loginUser": { "type": "boolean" }
            }
          }
        }
      }
    }
    # 正常登录状态下get访问
    @allure.story("get获取列表成功")
    def test_list_get(self, api_client):
        resp = api_client.get(self.url)
        resp_json = resp.json()
        # 断言
        validate(instance=resp_json, schema=self.json_schema)
        assert resp_json["code"] == "SUCCESS"

        # 提取有效的blogID信息存储在yaml文件中,在下一个test_03_detail.py文件中使用
        blogId={
            "blogId": resp_json["data"][0]["id"],
        }
        write_yaml("data.yml", blogId)


    # 正常登录状态下post访问
    @allure.story("post获取列表")
    def test_list_post(self, api_client):
        resp = api_client.post(self.url)
        resp_json = resp.json()
        # 断言
        validate(instance=resp_json, schema=self.json_schema)
        assert resp_json["code"] == "SUCCESS"


    # 未登录状态下get访问
    @allure.story("登录状态下get访问获取列表")
    def test_list_unauthorized_post(self, api_client):
        temp_header = {"user_token_header": ""}
        resp = api_client.get(self.url,headers=temp_header)
        # 断言
        assert resp.status_code == 401

五、执行用例与测试报告

1. 安装依赖

先把项目需要的依赖写入requirements.txt

python 复制代码
allure-pytest==2.13.5
allure-python-commons==2.13.5
attrs==26.1.0
certifi==2026.5.20
charset-normalizer==3.4.7
colorama==0.4.6
coverage==7.14.1
exceptiongroup==1.3.1
idna==3.18
iniconfig==2.3.0
Jinja2==3.1.6
jsonschema==4.23.0
jsonschema-specifications==2025.9.1
MarkupSafe==3.0.3
packaging==26.2
pluggy==1.6.0
pytest==8.3.2
pytest-cov==5.0.0
pytest-html==4.1.1
pytest-metadata==3.1.1
pytest-order==1.3.0
pytest-rerunfailures==16.3
python-dotenv==1.1.1
PyYAML==6.0.1
referencing==0.37.0
requests==2.31.0
rpds-py==0.30.0
tomli==2.4.1
typing_extensions==4.15.0
urllib3==2.7.0
复制代码
pip install -r requirements.txt

2. 配置pytest.ini并执行测试用例

python 复制代码
[pytest]
python_files = test_*.py
python_functions = test_*
addopts = -vs --alluredir allure-results
python 复制代码
pytest

3. 生成 Allure 报告 与 静态报告

python 复制代码
allure serve allure-results
allure generate .\allure-results\ -o .\allure-reports\ --clean      

生成的报告可以清晰看到用例执行结果、通过率、失败用例详情,还能按功能模块分组查看,非常适合测试结果分析。


六、踩坑记录与优化方向

1. 踩过的坑

一是 YAML 文件写入乱序与重复数据问题,最初使用 mode="a" 追加写入时,会出现字段乱序和数据重复的情况,影响后续接口读取,后来改为先清空文件再写入,并使用 safe_dump 严格控制格式,保证了 YAML 数据结构稳定、不重复、不乱序;

二是 用例执行顺序问题,由于新增、列表、详情等接口都依赖登录返回的 token,必须保证登录用例优先执行,我通过 pytest-order 插件指定用例执行顺序,确保了业务流程的依赖关系;

三是 明确了 requests 中 json、data、params 三种传参方式的用法与区别,其中 json 用于传递 JSON 格式请求体,对应 Content-Type 为 application/json,data 用于传递表单格式数据,对应 form 表单提交,params 用于在 URL 后拼接查询参数,我根据接口实际要求选择正确传参方式,确保请求能被后端正常解析。

2. 后续优化方向

  • 增加配置文件管理不同环境(测试 / 预发 / 生产)的 host 和端口
  • 封装公共的断言方法,减少重复代码
  • 增加接口依赖处理,比如登录 token 自动传递给后续接口
  • 接入 CI/CD,每次代码提交自动执行接口测试
  • 增加更多业务接口用例,覆盖博客发布、编辑、删除、点赞等场景

附:项目地址

https://gitee.com/biteeeeeee/blog-api-test.git

博客登陆页

相关推荐
肖永威2 小时前
Python多业务并行计算框架插件化演进:从硬编码到动态注册
python·插件化·并行计算·动态注册
yz_aiks2 小时前
Linux Jar包配置Systemd自启动实战:从排查到配置全流程
linux·python·jar·自启动·systemd
threelab3 小时前
Three.js 物理模拟着色器 | 三维可视化 / AI 提示词
开发语言·前端·javascript·人工智能·3d·着色器
武器大师723 小时前
lv_binding_js 代码解读
开发语言·javascript·ecmascript
不知名的老吴3 小时前
线程的生命周期之线程“插队“
java·开发语言·python
kaikaile19954 小时前
数字全息图处理系统(C# 实现)
开发语言·c#
xsc6996754 小时前
从零搭建大模型与智能体平台 - 完整技术详解
python
秋95 小时前
Go语言(Golang)开发工程师全景解析:岗位职责·语言优势与使用场景·各城市薪资·发展前景·高考志愿填报(2026版)
开发语言·golang·高考
无风听海5 小时前
多租户系统中的 OIDC:Discovery 端点与联合登录的深度实践
后端·python·flask