从零搭建 pytest + yaml 接口自动化测试框架

0 阅读6分钟

在接口自动化测试中,数据驱动 和 可维护性 是两大核心诉求。pytest 作为 Python 最强大的测试框架之一,结合 YAML 格式的测试数据,可以实现测试代码与数据的完美分离,让测试用例的编写和维护变得异常简单。

本文将手把手带你搭建一套完整的 pytest + yaml 接口自动化测试框架,包含分层设计、核心代码实现以及一个完整的登录用例示例。


一、框架分层设计

一个好的自动化测试框架应该有清晰的分层结构,便于扩展和维护。我们的框架分为四层:

1. 配置层

  • config.yaml:存放系统配置,如环境地址、超时时间、日志级别等。
  • conftest.py:pytest 的全局配置文件,提供所有 fixture(测试夹具)。

2. 核心层

  • ApiClient:封装 HTTP 请求,统一处理日志、异常和响应解析。
  • Assertions:封装常用断言方法,并提供详细的错误信息。

3. 测试数据层

  • 所有测试用例数据以 YAML 格式存放在 testdata/ 目录下,实现数据与代码分离。

4. 测试用例层

  • test_api.py(或按模块划分的测试文件):作为测试执行入口,通过 pytest 参数化加载 YAML 数据并执行。

image.png


二、详细代码实现

1. 配置文件 config.yaml

yaml

# 环境配置
base_url: "https://test.example.com"
timeout: 20          # 超时时间(秒)
log_level: "INFO"

# 其他配置
retry_count: 1       # 失败重试次数

2. 全局 fixture 文件 conftest.py

python

import pytest
import yaml
import os
from base.api_client import ApiClient   # 假设 ApiClient 放在 base 包下

@pytest.fixture(scope="session")
def config():
    """读取全局配置文件"""
    with open(os.path.join(os.path.dirname(__file__), "config.yaml"), "r", encoding="utf-8") as f:
        return yaml.safe_load(f)

@pytest.fixture(scope="session")
def test_data_loader():
    """返回一个函数,用于加载指定 YAML 文件中的测试数据"""
    def _load_data(file_name):
        file_path = os.path.join(os.path.dirname(__file__), "testdata", file_name)
        with open(file_path, "r", encoding="utf-8") as f:
            return yaml.safe_load(f)
    return _load_data

@pytest.fixture(scope="session")
def api_client(config):
    """提供 API 客户端实例,并在测试结束后自动关闭"""
    client = ApiClient(config["base_url"])
    yield client
    client.close()

@pytest.fixture(scope="session")
def shared_data():
    """用于在用例间共享数据(如登录后保存的 token)"""
    return {}

3. HTTP 客户端封装 api_client.py

python

import requests
from requests.exceptions import RequestException
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

class ApiClient:
    """API 请求客户端"""

    def __init__(self, base_url):
        self.base_url = base_url
        self.session = requests.Session()

    def request(self, method, url, **kwargs):
        """通用请求方法"""
        # 拼接完整 URL
        full_url = f"{self.base_url}{url}" if url.startswith("/") else f"{self.base_url}/{url}"

        try:
            logger.info(f"发送请求: {method} {full_url}")
            logger.info(f"请求参数: {kwargs}")

            response = self.session.request(method, full_url, **kwargs)
            response.raise_for_status()  # 如果状态码不是 2xx,会抛出 HTTPError

            logger.info(f"响应状态码: {response.status_code}")
            logger.info(f"响应内容: {response.text}")

            # 统一返回格式
            return {
                "status_code": response.status_code,
                "headers": dict(response.headers),
                "body": response.json() if response.headers.get("Content-Type") and "application/json" in response.headers["Content-Type"] else response.text
            }
        except RequestException as e:
            logger.error(f"请求发生错误: {str(e)}")
            raise

    def get(self, url, **kwargs):
        return self.request("GET", url, **kwargs)

    def post(self, url, **kwargs):
        return self.request("POST", url, **kwargs)

    def put(self, url, **kwargs):
        return self.request("PUT", url, **kwargs)

    def delete(self, url, **kwargs):
        return self.request("DELETE", url, **kwargs)

    def close(self):
        self.session.close()

4. 断言封装 assertions.py

python

import allure

class CustomAssertionError(AssertionError):
    """自定义断言异常,便于区分断言失败与其他异常"""
    pass

class Assertions:
    """常用断言静态方法"""

    @staticmethod
    def assert_equal(actual, expected, message):
        if actual != expected:
            raise CustomAssertionError(f"{message},实际值: {actual},预期值: {expected}")

    @staticmethod
    def assert_not_equal(actual, expected, message):
        if actual == expected:
            raise CustomAssertionError(f"{message},实际值: {actual},不应等于预期值: {expected}")

    @staticmethod
    def assert_in(actual, expected_container, message):
        if actual not in expected_container:
            raise CustomAssertionError(f"{message},实际值: {actual},不在预期集合: {expected_container}")

    @staticmethod
    def assert_status_code(actual, expected, message):
        if actual != expected:
            raise CustomAssertionError(f"{message},实际状态码: {actual},预期状态码: {expected}")


def execute_assertions(response, case_name, assertions):
    """
    根据 YAML 中的断言配置执行一系列断言
    :param response: 接口返回的统一字典(含 status_code/headers/body)
    :param case_name: 测试用例名称(用于错误信息)
    :param assertions: 断言列表,每个元素为字典,如 {"type": "equal", "actual": "body.token", "expected": "xxx"}
    """
    for assertion in assertions:
        assert_type = assertion["type"]
        actual_path = assertion["actual"]       # 支持点号路径,如 "body.data.token"
        expected_value = assertion["expected"]

        try:
            # 根据路径从 response 中提取实际值
            actual_value = response
            for key in actual_path.split('.'):
                if isinstance(actual_value, dict) and key in actual_value:
                    actual_value = actual_value[key]
                else:
                    actual_value = None
                    break

            # 根据断言类型执行
            if assert_type == "equal":
                Assertions.assert_equal(actual_value, expected_value,
                                        f"{case_name} - {actual_path} 与预期值不相等")
            elif assert_type == "not_equal":
                Assertions.assert_not_equal(actual_value, expected_value,
                                            f"{case_name} - {actual_path} 不应等于预期值")
            elif assert_type == "in":
                Assertions.assert_in(actual_value, expected_value,
                                     f"{case_name} - {actual_path} 不在预期集合中")
            elif assert_type == "status_code":
                Assertions.assert_status_code(actual_value, expected_value,
                                              f"{case_name} - 状态码不匹配")
            else:
                raise ValueError(f"{case_name} - 不支持的断言类型: {assert_type}")

        except CustomAssertionError as e:
            # 断言失败时附加详细信息到 Allure 报告
            allure.attach(
                name=f"断言失败详情 - {case_name}",
                body=f"断言类型: {assert_type}\n"
                     f"断言路径: {actual_path}\n"
                     f"实际值: {actual_value}\n"
                     f"预期值: {expected_value}\n"
                     f"错误信息: {str(e)}",
                attachment_type=allure.attachment_type.TEXT
            )
            raise
        except Exception as e:
            allure.attach(
                name=f"用例执行异常 - {case_name}",
                body=f"错误类型: {type(e).__name__}\n"
                     f"断言配置: {assertion}\n"
                     f"错误信息: {str(e)}",
                attachment_type=allure.attachment_type.TEXT
            )
            raise

5. 测试数据文件示例 testdata/login.yaml

yaml

test_cases:
  - name: "正常登录"
    request:
      method: "POST"
      url: "/login"
      headers:
        Content-Type: "application/json;charset=UTF-8"
        User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
      json:
        username: "testuser"
        password: "123456"
    assertions:
      - type: "status_code"
        actual: "status_code"
        expected: 200
      - type: "not_equal"
        actual: "body.token"
        expected: ""           # 期望 token 不为空
      - type: "equal"
        actual: "body.code"
        expected: 0

6. 测试用例示例 test_login.py

python

import pytest
from assertions import execute_assertions

@pytest.mark.parametrize("yaml_file", ["login.yaml"])
def test_login(api_client, test_data_loader, shared_data, yaml_file):
    """从 YAML 文件加载登录用例并执行"""
    test_data = test_data_loader(yaml_file)
    for case in test_data["test_cases"]:
        case_name = case.get("name")
        print(f"\n执行测试用例: {case_name}")

        # 提取请求信息
        method = case["request"]["method"].lower()
        url = case["request"]["url"]
        json_data = case["request"].get("json", {})
        headers = case["request"].get("headers", {})

        # 发送请求
        response = getattr(api_client, method)(
            url=url,
            json=json_data,
            headers=headers
        )

        # 如果登录成功,提取 token 存入共享数据(供后续用例使用)
        if response["status_code"] == 200 and "body" in response:
            token = response["body"].get("token")
            if token:
                shared_data["token"] = token
                print(f"获取到 token: {token}")

        # 执行 YAML 中定义的断言
        execute_assertions(response=response,
                           case_name=case_name,
                           assertions=case.get("assertions", []))

三、框架运行与扩展

1. 运行测试

在项目根目录下执行:

bash

pytest -v test_login.py

如果需要生成 Allure 报告:

bash

pytest --alluredir=./allure-results
allure serve ./allure-results

2. 如何添加新的接口测试?

  1. 在 testdata/ 下新建一个 YAML 文件(如 get_user_info.yaml),按照约定格式编写请求和断言。
  2. 新建一个测试函数(或复用已有测试函数),通过 @pytest.mark.parametrize 指定该 YAML 文件。
  3. 如果新接口需要依赖之前接口的数据(如 token),可以直接从 shared_data fixture 中获取。

3. 扩展建议

  • 环境切换:可以在 config.yaml 中定义多套环境(dev/test/prod),通过命令行参数选择。
  • 请求前置处理:在 ApiClient 中添加钩子,自动为请求添加公共头(如 token)。
  • 数据清理:使用 pytest 的 setup/teardown 机制完成测试数据准备与清理。
  • 日志收集:可接入 ELK 或其它日志系统,方便问题定位。

四、总结

通过上述步骤,我们搭建了一个 简洁、可扩展 的 pytest + yaml 接口自动化测试框架。它的优点在于:

  • 数据驱动:所有测试数据集中在 YAML 文件中,非技术人员也能轻松维护。
  • 分层清晰:配置、核心、数据、用例各层职责单一,易于修改和扩展。
  • 断言灵活:通过 execute_assertions 支持多种断言类型,且错误信息友好。
  • 与 Allure 集成:自动附加断言失败详情,报告直观。

希望本文能帮助你在接口自动化测试的道路上更进一步。如果你有更好的实现思路或问题,欢迎在评论区交流讨论!