在接口自动化测试中,数据驱动 和 可维护性 是两大核心诉求。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 数据并执行。
二、详细代码实现
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. 如何添加新的接口测试?
- 在
testdata/下新建一个 YAML 文件(如get_user_info.yaml),按照约定格式编写请求和断言。 - 新建一个测试函数(或复用已有测试函数),通过
@pytest.mark.parametrize指定该 YAML 文件。 - 如果新接口需要依赖之前接口的数据(如 token),可以直接从
shared_datafixture 中获取。
3. 扩展建议
- 环境切换:可以在
config.yaml中定义多套环境(dev/test/prod),通过命令行参数选择。 - 请求前置处理:在
ApiClient中添加钩子,自动为请求添加公共头(如 token)。 - 数据清理:使用 pytest 的
setup/teardown机制完成测试数据准备与清理。 - 日志收集:可接入 ELK 或其它日志系统,方便问题定位。
四、总结
通过上述步骤,我们搭建了一个 简洁、可扩展 的 pytest + yaml 接口自动化测试框架。它的优点在于:
- 数据驱动:所有测试数据集中在 YAML 文件中,非技术人员也能轻松维护。
- 分层清晰:配置、核心、数据、用例各层职责单一,易于修改和扩展。
- 断言灵活:通过
execute_assertions支持多种断言类型,且错误信息友好。 - 与 Allure 集成:自动附加断言失败详情,报告直观。
希望本文能帮助你在接口自动化测试的道路上更进一步。如果你有更好的实现思路或问题,欢迎在评论区交流讨论!