Pytest 与 YAML 详解
1. 什么是 YAML
1.1 YAML 简介
YAML(YAML Ain’t Markup Language)是一种人类可读的数据序列化标准,常用于配置文件、数据存储和配置文件。它的设计目标是让人类容易阅读和编写。
1.2 YAML 的特点
- 简洁易读:语法简单,不需要复杂的符号
- 层次清晰:使用缩进表示层级关系
- 数据类型丰富:支持字符串、数字、布尔值、列表、字典等
- 注释支持:可以使用
#添加注释 - 跨语言:多种编程语言都支持 YAML
1.3 YAML 在测试中的作用
在 pytest 测试中,YAML 主要用于:
- 存储测试数据:将测试用例数据与测试代码分离
- 配置文件:配置测试环境、数据库连接等
- 接口测试:存储 API 请求和响应数据
- 测试报告:生成测试报告的数据格式
2. YAML 基础语法
2.1 基本数据类型
2.1.1 字符串(String)
# 普通字符串
name: 张三
username: admin
# 包含特殊字符的字符串(使用引号)
message: "Hello, World!"
description: '这是一个测试'
# 多行字符串(使用 | 保留换行)
content: |
这是第一行
这是第二行
这是第三行
# 多行字符串(使用 > 折叠换行)
summary: >
这是一段很长的文本
会被折叠成一行
但保留空格
2.1.2 数字(Number)
# 整数
age: 25
count: 100
# 浮点数
price: 99.99
score: 98.5
# 科学计数法
large_number: 1.23e+10
2.1.3 布尔值(Boolean)
# 布尔值(多种写法)
is_active: true
is_deleted: false
# 也可以写成
enabled: True
disabled: False
# 或者
on: ON
off: OFF
2.1.4 空值(Null)
# 空值
middle_name: null
description: ~
empty_value:
2.2 集合类型
2.2.1 列表(List)
# 行内列表
fruits: [apple, banana, orange]
# 多行列表(使用 -)
fruits:
- apple
- banana
- orange
# 嵌套列表
matrix:
- [1, 2, 3]
- [4, 5, 6]
- [7, 8, 9]
# 混合类型列表
mixed_list:
- 字符串
- 123
- true
- null
2.2.2 字典(Dictionary/Map)
# 行内字典
person: {name: 张三, age: 25, city: 北京}
# 多行字典
person:
name: 张三
age: 25
city: 北京
email: zhangsan@example.com
# 嵌套字典
user:
personal_info:
name: 张三
age: 25
contact_info:
email: zhangsan@example.com
phone: 13800138000
2.3 缩进规则
重要提示:YAML 使用空格进行缩进,不能使用 Tab!
# ✅ 正确:使用空格缩进
user:
name: 张三
address:
city: 北京
street: 中关村大街
# ❌ 错误:使用 Tab 缩进(会导致解析错误)
user:
name: 张三 # Tab 缩进,会报错
缩进规则:
- 同一层级使用相同的缩进
- 通常使用 2 个空格作为一级缩进
- 子元素比父元素多一级缩进
2.4 注释
# 这是单行注释
user:
name: 张三 # 行内注释
age: 25
# 这是多行注释
# 可以写多行
email: zhangsan@example.com
2.5 引用和锚点
# 定义锚点(使用 &)
defaults: &defaults
timeout: 30
retry: 3
# 引用锚点(使用 <<)
test_config:
<<: *defaults
url: https://api.example.com
# 结果等同于:
# test_config:
# timeout: 30
# retry: 3
# url: https://api.example.com
3. 在 Python 中读取 YAML 文件
3.1 安装 PyYAML
在使用 YAML 之前,需要先安装 PyYAML 库:
pip install PyYAML
3.2 基本读取方法
3.2.1 使用 yaml.safe_load()
推荐使用 safe_load(),它只加载基本的 YAML 标签,更安全。
import yaml
# 读取 YAML 文件
with open('config.yaml', 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
print(data)
示例 YAML 文件(config.yaml):
database:
host: localhost
port: 3306
username: root
password: 123456
api:
base_url: https://api.example.com
timeout: 30
读取结果:
{
'database': {
'host': 'localhost',
'port': 3306,
'username': 'root',
'password': '123456'
},
'api': {
'base_url': 'https://api.example.com',
'timeout': 30
}
}
3.2.2 使用 yaml.load()
不推荐使用,因为它可能执行任意 Python 代码,存在安全风险。
import yaml
# 不推荐:可能存在安全风险
with open('config.yaml', 'r', encoding='utf-8') as f:
data = yaml.load(f, Loader=yaml.FullLoader)
3.3 读取多个文档
YAML 文件可以包含多个文档,使用 --- 分隔:
# 第一个文档
---
name: 文档1
value: 100
# 第二个文档
---
name: 文档2
value: 200
读取多个文档:
import yaml
with open('multi_doc.yaml', 'r', encoding='utf-8') as f:
documents = list(yaml.safe_load_all(f))
for doc in documents:
print(doc)
3.4 写入 YAML 文件
import yaml
data = {
'name': '张三',
'age': 25,
'hobbies': ['读书', '游泳', '编程']
}
# 写入 YAML 文件
with open('output.yaml', 'w', encoding='utf-8') as f:
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
生成的 YAML 文件:
age: 25
hobbies:
- 读书
- 游泳
- 编程
name: 张三
参数说明:
allow_unicode=True:允许 Unicode 字符(中文等)default_flow_style=False:使用块样式(多行),而不是流样式(单行)
4. Pytest 中使用 YAML 存储测试数据
4.1 为什么使用 YAML 存储测试数据
4.1.1 数据与代码分离
优点:
- 测试代码更简洁
- 测试数据易于维护
- 非技术人员也可以修改测试数据
- 数据可以复用
对比示例:
❌ 不使用 YAML(数据硬编码):
def test_login():
# 数据硬编码在代码中
test_cases = [
("admin", "admin123", True),
("user", "user123", True),
("invalid", "wrong", False)
]
for username, password, expected in test_cases:
result = login(username, password)
assert result == expected
✅ 使用 YAML(数据分离):
import pytest
import yaml
# 从 YAML 文件读取数据
with open('test_data.yaml', 'r', encoding='utf-8') as f:
test_data = yaml.safe_load(f)
@pytest.mark.parametrize("username, password, expected",
test_data['login_cases'])
def test_login(username, password, expected):
result = login(username, password)
assert result == expected
4.2 基本使用示例
4.2.1 创建 YAML 数据文件
创建 test_data.yaml:
# 登录测试数据
login_cases:
- username: admin
password: admin123
expected: true
description: "管理员登录成功"
- username: user
password: user123
expected: true
description: "普通用户登录成功"
- username: invalid
password: wrong
expected: false
description: "无效用户名密码登录失败"
- username: admin
password: wrong
expected: false
description: "正确用户名错误密码登录失败"
# 用户注册测试数据
register_cases:
- username: newuser1
password: password123
email: newuser1@example.com
expected: true
- username: admin # 已存在的用户名
password: password123
email: admin@example.com
expected: false
4.2.2 在测试中读取 YAML 数据
创建 test_login.py:
import pytest
import yaml
import os
def load_yaml_data(file_name, key=None):
"""
加载 YAML 文件数据
参数:
file_name: YAML 文件名
key: 要获取的键名,如果为 None 则返回全部数据
返回:
字典或列表数据
"""
# 获取当前文件所在目录
current_dir = os.path.dirname(os.path.abspath(__file__))
# 构建文件路径
file_path = os.path.join(current_dir, file_name)
# 读取 YAML 文件
with open(file_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
# 如果指定了 key,返回对应的值
if key:
return data.get(key)
return data
# 加载登录测试数据
login_data = load_yaml_data('test_data.yaml', 'login_cases')
# 使用参数化装饰器
@pytest.mark.parametrize("case", login_data)
def test_login(case):
"""
测试登录功能
参数:
case: 测试用例字典,包含 username, password, expected, description
"""
username = case['username']
password = case['password']
expected = case['expected']
description = case.get('description', '')
print(f"n测试用例:{description}")
print(f"用户名:{username}, 密码:{password}")
# 模拟登录函数(实际项目中替换为真实的登录函数)
result = login(username, password)
# 断言
assert result == expected, f"登录结果不符合预期:期望 {expected},实际 {result}"
def login(username, password):
"""
模拟登录函数
实际项目中应该调用真实的登录接口
"""
# 模拟验证逻辑
valid_users = {
'admin': 'admin123',
'user': 'user123'
}
return valid_users.get(username) == password
if __name__ == '__main__':
pytest.main([__file__, '-v'])
4.2.3 运行测试
pytest test_login.py -v
输出示例:
test_login.py::test_login[case0] PASSED
test_login.py::test_login[case1] PASSED
test_login.py::test_login[case2] PASSED
test_login.py::test_login[case3] PASSED
4.3 使用 @pytest.mark.parametrize 的多种方式
4.3.1 方式一:直接传递字典列表
import pytest
import yaml
# 加载数据
with open('test_data.yaml', 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
login_cases = data['login_cases']
# 参数化:传递整个字典
@pytest.mark.parametrize("case", login_cases)
def test_login(case):
username = case['username']
password = case['password']
expected = case['expected']
# 测试逻辑...
4.3.2 方式二:解构字典字段
import pytest
import yaml
# 加载数据
with open('test_data.yaml', 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
login_cases = data['login_cases']
# 提取字段并转换为元组列表
test_data = [
(case['username'], case['password'], case['expected'])
for case in login_cases
]
# 参数化:传递多个参数
@pytest.mark.parametrize("username, password, expected", test_data)
def test_login(username, password, expected):
result = login(username, password)
assert result == expected
4.3.3 方式三:使用 ids 自定义测试名称
import pytest
import yaml
# 加载数据
with open('test_data.yaml', 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
login_cases = data['login_cases']
# 提取测试 ID(用于显示在测试报告中)
test_ids = [case.get('description', f"case_{i}")
for i, case in enumerate(login_cases)]
@pytest.mark.parametrize("case", login_cases, ids=test_ids)
def test_login(case):
username = case['username']
password = case['password']
expected = case['expected']
result = login(username, password)
assert result == expected
运行结果:
test_login.py::test_login[管理员登录成功] PASSED
test_login.py::test_login[普通用户登录成功] PASSED
test_login.py::test_login[无效用户名密码登录失败] PASSED
test_login.py::test_login[正确用户名错误密码登录失败] PASSED
4.4 复杂数据结构示例
4.4.1 嵌套数据结构
YAML 文件(complex_data.yaml):
# 用户信息测试数据
user_test_cases:
- user_id: 1
personal_info:
name: 张三
age: 25
gender: 男
contact_info:
email: zhangsan@example.com
phone: 13800138000
address:
province: 北京
city: 北京市
district: 海淀区
street: 中关村大街1号
expected:
status_code: 200
message: "创建成功"
- user_id: 2
personal_info:
name: 李四
age: 30
gender: 女
contact_info:
email: lisi@example.com
phone: 13900139000
address:
province: 上海
city: 上海市
district: 浦东新区
street: 陆家嘴环路1000号
expected:
status_code: 200
message: "创建成功"
# API 测试数据
api_test_cases:
- name: "获取用户信息"
method: GET
url: "/api/users/1"
headers:
Authorization: "Bearer token123"
Content-Type: "application/json"
params:
include: ["profile", "contacts"]
expected:
status_code: 200
data:
user_id: 1
name: "张三"
- name: "创建用户"
method: POST
url: "/api/users"
headers:
Authorization: "Bearer token123"
Content-Type: "application/json"
body:
name: "王五"
age: 28
email: "wangwu@example.com"
expected:
status_code: 201
message: "创建成功"
测试代码:
import pytest
import yaml
# 加载复杂数据
with open('complex_data.yaml', 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
@pytest.mark.parametrize("user_case", data['user_test_cases'])
def test_create_user(user_case):
"""测试创建用户"""
user_id = user_case['user_id']
personal_info = user_case['personal_info']
contact_info = user_case['contact_info']
expected = user_case['expected']
print(f"n测试用户 ID: {user_id}")
print(f"姓名: {personal_info['name']}")
print(f"邮箱: {contact_info['email']}")
print(f"地址: {contact_info['address']['street']}")
# 模拟创建用户
result = create_user(personal_info, contact_info)
assert result['status_code'] == expected['status_code']
assert result['message'] == expected['message']
@pytest.mark.parametrize("api_case", data['api_test_cases'])
def test_api_request(api_case):
"""测试 API 请求"""
method = api_case['method']
url = api_case['url']
headers = api_case.get('headers', {})
params = api_case.get('params', {})
body = api_case.get('body', {})
expected = api_case['expected']
print(f"n测试用例: {api_case['name']}")
print(f"方法: {method}, URL: {url}")
# 模拟 API 请求
result = make_api_request(method, url, headers, params, body)
assert result['status_code'] == expected['status_code']
if 'data' in expected:
assert result['data'] == expected['data']
def create_user(personal_info, contact_info):
"""模拟创建用户函数"""
return {
'status_code': 200,
'message': '创建成功'
}
def make_api_request(method, url, headers, params, body):
"""模拟 API 请求函数"""
return {
'status_code': 200,
'data': {'user_id': 1, 'name': '张三'}
}
4.5 使用 Fixture 加载 YAML 数据
4.5.1 在 conftest.py 中定义 Fixture
创建 conftest.py:
import pytest
import yaml
import os
@pytest.fixture(scope='session')
def yaml_data():
"""加载 YAML 测试数据,整个测试会话只加载一次"""
file_path = os.path.join(os.path.dirname(__file__), 'test_data.yaml')
with open(file_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
return data
@pytest.fixture
def login_cases(yaml_data):
"""获取登录测试用例"""
return yaml_data.get('login_cases', [])
@pytest.fixture
def register_cases(yaml_data):
"""获取注册测试用例"""
return yaml_data.get('register_cases', [])
4.5.2 在测试中使用 Fixture
import pytest
def test_login_with_fixture(login_cases):
"""使用 Fixture 获取测试数据"""
for case in login_cases:
username = case['username']
password = case['password']
expected = case['expected']
result = login(username, password)
assert result == expected
@pytest.mark.parametrize("case", pytest.fixture(scope='function')(lambda: []))
def test_login_parametrize(login_cases):
"""结合参数化使用"""
# 注意:这里需要特殊处理,因为 parametrize 不能直接使用 fixture
pass
更好的方式:直接在测试中加载数据
import pytest
import yaml
@pytest.fixture(scope='module')
def test_data():
"""模块级别的 Fixture,加载测试数据"""
with open('test_data.yaml', 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
def test_login(test_data):
"""使用 Fixture 获取数据"""
login_cases = test_data['login_cases']
for case in login_cases:
username = case['username']
password = case['password']
expected = case['expected']
result = login(username, password)
assert result == expected
5. 使用 YAML 配置 Pytest
5.1 pytest.ini 配置文件
虽然 pytest.ini 通常使用 INI 格式,但我们可以使用 YAML 来存储测试配置数据。
5.1.1 创建配置文件(config.yaml)
# Pytest 测试配置
pytest_config:
# 测试路径
testpaths:
- tests
- integration_tests
# 测试文件匹配模式
python_files:
- test_*.py
- *_test.py
# 测试类匹配模式
python_classes:
- Test*
# 测试函数匹配模式
python_functions:
- test_*
# 命令行选项
addopts:
- -v
- --tb=short
- --strict-markers
- --disable-warnings
# 标记定义
markers:
smoke: "冒烟测试"
regression: "回归测试"
api: "API 测试"
ui: "UI 测试"
slow: "慢速测试"
# 日志配置
log_cli: true
log_cli_level: INFO
log_cli_format: "%(asctime)s [%(levelname)s] %(message)s"
log_cli_date_format: "%Y-%m-%d %H:%M:%S"
# 测试环境配置
test_environments:
dev:
base_url: "http://dev.example.com"
database:
host: "localhost"
port: 3306
name: "test_db"
timeout: 30
test:
base_url: "http://test.example.com"
database:
host: "test-db.example.com"
port: 3306
name: "test_db"
timeout: 60
prod:
base_url: "https://api.example.com"
database:
host: "prod-db.example.com"
port: 3306
name: "prod_db"
timeout: 120
5.1.2 在 conftest.py 中加载配置
import pytest
import yaml
import os
def load_config():
"""加载 YAML 配置文件"""
config_path = os.path.join(os.path.dirname(__file__), 'config.yaml')
with open(config_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
@pytest.fixture(scope='session')
def test_config():
"""获取测试配置"""
config = load_config()
return config['pytest_config']
@pytest.fixture(scope='session')
def test_env():
"""获取测试环境配置"""
import os
env = os.getenv('TEST_ENV', 'dev') # 默认使用 dev 环境
config = load_config()
return config['test_environments'][env]
@pytest.fixture(scope='session')
def base_url(test_env):
"""获取基础 URL"""
return test_env['base_url']
@pytest.fixture(scope='session')
def db_config(test_env):
"""获取数据库配置"""
return test_env['database']
5.1.3 在测试中使用配置
import pytest
import requests
def test_api_request(base_url):
"""使用配置的 base_url"""
url = f"{base_url}/api/users/1"
response = requests.get(url)
assert response.status_code == 200
def test_database_connection(db_config):
"""使用配置的数据库信息"""
host = db_config['host']
port = db_config['port']
db_name = db_config['name']
print(f"连接数据库: {host}:{port}/{db_name}")
# 实际项目中连接数据库...
5.2 环境变量配置
# environments.yaml
environments:
dev:
name: "开发环境"
base_url: "http://dev.example.com"
api_key: "dev_api_key_123"
database_url: "mysql://localhost:3306/test_db"
staging:
name: "预发布环境"
base_url: "http://staging.example.com"
api_key: "staging_api_key_456"
database_url: "mysql://staging-db:3306/staging_db"
production:
name: "生产环境"
base_url: "https://api.example.com"
api_key: "${PROD_API_KEY}" # 从环境变量读取
database_url: "${PROD_DB_URL}" # 从环境变量读取
加载并处理环境变量:
import pytest
import yaml
import os
import re
def load_env_config(env_name='dev'):
"""加载环境配置,支持环境变量替换"""
with open('environments.yaml', 'r', encoding='utf-8') as f:
config = yaml.safe_load(f)
env_config = config['environments'][env_name]
# 替换环境变量
def replace_env_vars(value):
if isinstance(value, str):
# 匹配 ${VAR_NAME} 格式
pattern = r'${([^}]+)}'
matches = re.findall(pattern, value)
for var_name in matches:
env_value = os.getenv(var_name)
if env_value:
value = value.replace(f'${{{var_name}}}', env_value)
return value
# 递归处理所有值
def process_dict(d):
if isinstance(d, dict):
return {k: process_dict(v) for k, v in d.items()}
elif isinstance(d, list):
return [process_dict(item) for item in d]
else:
return replace_env_vars(d)
return process_dict(env_config)
@pytest.fixture(scope='session')
def env_config():
"""获取环境配置"""
env = os.getenv('TEST_ENV', 'dev')
return load_env_config(env)
6. 使用 YAML 进行接口测试
6.1 接口测试数据结构设计
6.1.1 API 测试用例 YAML 结构
创建 api_test_cases.yaml:
# API 测试用例配置
api_base_url: "https://api.example.com"
# 测试用例列表
test_cases:
# 测试用例 1:获取用户信息
- name: "获取用户信息 - 成功"
description: "测试获取指定用户信息的接口"
method: GET
endpoint: "/api/users/{user_id}"
path_params:
user_id: 1
query_params:
include: ["profile", "contacts"]
headers:
Authorization: "Bearer {token}"
Content-Type: "application/json"
expected:
status_code: 200
response_time: 1000 # 毫秒
schema:
type: object
properties:
user_id:
type: integer
name:
type: string
email:
type: string
data:
user_id: 1
name: "张三"
validate:
- type: "status_code"
expected: 200
- type: "json_path"
path: "$.user_id"
expected: 1
- type: "response_time"
max: 1000
# 测试用例 2:创建用户
- name: "创建用户 - 成功"
description: "测试创建新用户的接口"
method: POST
endpoint: "/api/users"
headers:
Authorization: "Bearer {token}"
Content-Type: "application/json"
body:
name: "李四"
age: 30
email: "lisi@example.com"
password: "password123"
expected:
status_code: 201
response_time: 2000
data:
user_id: 2
name: "李四"
message: "创建成功"
validate:
- type: "status_code"
expected: 201
- type: "json_path"
path: "$.user_id"
expected_type: "integer"
- type: "contains"
field: "message"
value: "创建成功"
# 测试用例 3:更新用户信息
- name: "更新用户信息 - 成功"
description: "测试更新用户信息的接口"
method: PUT
endpoint: "/api/users/{user_id}"
path_params:
user_id: 1
headers:
Authorization: "Bearer {token}"
Content-Type: "application/json"
body:
name: "张三(已更新)"
age: 26
expected:
status_code: 200
data:
user_id: 1
name: "张三(已更新)"
age: 26
validate:
- type: "status_code"
expected: 200
- type: "json_path"
path: "$.name"
expected: "张三(已更新)"
# 测试用例 4:删除用户
- name: "删除用户 - 成功"
description: "测试删除用户的接口"
method: DELETE
endpoint: "/api/users/{user_id}"
path_params:
user_id: 1
headers:
Authorization: "Bearer {token}"
expected:
status_code: 204
validate:
- type: "status_code"
expected: 204
# 公共配置
common_config:
timeout: 30
retry_times: 3
retry_delay: 1 # 秒
default_headers:
User-Agent: "pytest-api-test/1.0"
Accept: "application/json"
6.2 接口测试框架实现
6.2.1 创建测试工具类
创建 api_test_utils.py:
import requests
import json
import time
from typing import Dict, Any, Optional
import jsonpath
class APITestClient:
"""API 测试客户端"""
def __init__(self, base_url: str, default_headers: Optional[Dict] = None):
"""
初始化 API 测试客户端
参数:
base_url: API 基础 URL
default_headers: 默认请求头
"""
self.base_url = base_url.rstrip('/')
self.session = requests.Session()
if default_headers:
self.session.headers.update(default_headers)
def request(self, method: str, endpoint: str,
path_params: Optional[Dict] = None,
query_params: Optional[Dict] = None,
headers: Optional[Dict] = None,
body: Optional[Dict] = None,
timeout: int = 30) -> requests.Response:
"""
发送 HTTP 请求
参数:
method: HTTP 方法(GET, POST, PUT, DELETE 等)
endpoint: API 端点
path_params: 路径参数(用于替换 URL 中的 {param})
query_params: 查询参数
headers: 请求头
body: 请求体
timeout: 超时时间(秒)
返回:
Response 对象
"""
# 替换路径参数
url = endpoint
if path_params:
for key, value in path_params.items():
url = url.replace(f'{{{key}}}', str(value))
# 构建完整 URL
full_url = f"{self.base_url}{url}"
# 准备请求头
request_headers = {}
if headers:
request_headers.update(headers)
# 发送请求
start_time = time.time()
try:
response = self.session.request(
method=method.upper(),
url=full_url,
params=query_params,
headers=request_headers,
json=body,
timeout=timeout
)
response.elapsed_time = (time.time() - start_time) * 1000 # 转换为毫秒
return response
except requests.exceptions.RequestException as e:
raise Exception(f"请求失败: {e}")
def validate_response(self, response: requests.Response,
validations: list) -> tuple[bool, list]:
"""
验证响应
参数:
response: Response 对象
validations: 验证规则列表
返回:
(是否通过, 错误信息列表)
"""
errors = []
for validation in validations:
v_type = validation.get('type')
if v_type == 'status_code':
expected = validation.get('expected')
if response.status_code != expected:
errors.append(
f"状态码验证失败: 期望 {expected}, 实际 {response.status_code}"
)
elif v_type == 'json_path':
path = validation.get('path')
expected = validation.get('expected')
expected_type = validation.get('expected_type')
try:
json_data = response.json()
actual_value = jsonpath.jsonpath(json_data, path)
if actual_value:
actual_value = actual_value[0]
if expected is not None:
if actual_value != expected:
errors.append(
f"JSON 路径 {path} 验证失败: "
f"期望 {expected}, 实际 {actual_value}"
)
if expected_type:
if not isinstance(actual_value, eval(expected_type)):
errors.append(
f"JSON 路径 {path} 类型验证失败: "
f"期望 {expected_type}, 实际 {type(actual_value).__name__}"
)
else:
errors.append(f"JSON 路径 {path} 不存在")
except json.JSONDecodeError:
errors.append("响应不是有效的 JSON 格式")
elif v_type == 'contains':
field = validation.get('field')
value = validation.get('value')
try:
json_data = response.json()
if field in json_data:
if value not in str(json_data[field]):
errors.append(
f"字段 {field} 不包含值 {value}"
)
else:
errors.append(f"字段 {field} 不存在")
except json.JSONDecodeError:
errors.append("响应不是有效的 JSON 格式")
elif v_type == 'response_time':
max_time = validation.get('max')
if hasattr(response, 'elapsed_time'):
if response.elapsed_time > max_time:
errors.append(
f"响应时间超时: 期望 <= {max_time}ms, "
f"实际 {response.elapsed_time:.2f}ms"
)
return len(errors) == 0, errors
6.2.2 创建测试用例
创建 test_api.py:
import pytest
import yaml
import os
from api_test_utils import APITestClient
def load_api_test_cases():
"""加载 API 测试用例"""
file_path = os.path.join(os.path.dirname(__file__), 'api_test_cases.yaml')
with open(file_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
@pytest.fixture(scope='session')
def api_config():
"""获取 API 配置"""
config = load_api_test_cases()
return config
@pytest.fixture(scope='session')
def api_client(api_config):
"""创建 API 客户端"""
base_url = api_config['api_base_url']
default_headers = api_config.get('common_config', {}).get('default_headers', {})
return APITestClient(base_url, default_headers)
@pytest.fixture(scope='session')
def auth_token():
"""获取认证 Token(示例)"""
# 实际项目中应该调用登录接口获取 token
return "mock_token_12345"
@pytest.mark.parametrize("test_case",
load_api_test_cases()['test_cases'],
ids=lambda case: case['name'])
def test_api(api_client, api_config, auth_token, test_case):
"""
执行 API 测试用例
参数:
api_client: API 客户端
api_config: API 配置
auth_token: 认证 Token
test_case: 测试用例数据
"""
# 替换 Token
headers = test_case.get('headers', {})
if headers:
headers = {k: v.replace('{token}', auth_token)
for k, v in headers.items()}
# 发送请求
response = api_client.request(
method=test_case['method'],
endpoint=test_case['endpoint'],
path_params=test_case.get('path_params'),
query_params=test_case.get('query_params'),
headers=headers,
body=test_case.get('body'),
timeout=api_config.get('common_config', {}).get('timeout', 30)
)
# 验证响应
validations = test_case.get('validate', [])
passed, errors = api_client.validate_response(response, validations)
# 打印响应信息(用于调试)
print(f"n测试用例: {test_case['name']}")
print(f"请求 URL: {response.url}")
print(f"状态码: {response.status_code}")
print(f"响应时间: {response.elapsed_time:.2f}ms")
if response.text:
try:
print(f"响应内容: {response.json()}")
except:
print(f"响应内容: {response.text}")
# 断言
assert passed, f"验证失败:n" + "n".join(errors)
# 验证状态码(如果 expected 中有定义)
if 'expected' in test_case:
expected_status = test_case['expected'].get('status_code')
if expected_status:
assert response.status_code == expected_status,
f"状态码不符合预期: 期望 {expected_status}, 实际 {response.status_code}"
6.3 接口关联测试
6.3.1 接口关联 YAML 配置
创建 api_chain_test.yaml:
# 接口关联测试用例
test_chains:
# 测试链 1:用户注册 -> 登录 -> 获取用户信息
- name: "用户注册登录流程"
description: "测试完整的用户注册和登录流程"
steps:
# 步骤 1:注册用户
- step_name: "注册用户"
method: POST
endpoint: "/api/users/register"
body:
username: "testuser_{timestamp}"
password: "password123"
email: "testuser_{timestamp}@example.com"
extract:
# 提取响应中的数据,供后续步骤使用
user_id: "$.user_id"
username: "$.username"
validate:
- type: "status_code"
expected: 201
# 步骤 2:用户登录
- step_name: "用户登录"
method: POST
endpoint: "/api/users/login"
body:
username: "{username}" # 使用上一步提取的 username
password: "password123"
extract:
token: "$.token"
user_id: "$.user_id"
validate:
- type: "status_code"
expected: 200
# 步骤 3:获取用户信息
- step_name: "获取用户信息"
method: GET
endpoint: "/api/users/{user_id}" # 使用上一步提取的 user_id
headers:
Authorization: "Bearer {token}" # 使用上一步提取的 token
validate:
- type: "status_code"
expected: 200
- type: "json_path"
path: "$.user_id"
expected: "{user_id}" # 验证 user_id 匹配
# 测试链 2:创建订单 -> 支付 -> 查询订单
- name: "订单创建支付流程"
description: "测试订单创建和支付流程"
steps:
- step_name: "创建订单"
method: POST
endpoint: "/api/orders"
headers:
Authorization: "Bearer {token}"
body:
product_id: 1
quantity: 2
price: 99.99
extract:
order_id: "$.order_id"
total_amount: "$.total_amount"
validate:
- type: "status_code"
expected: 201
- step_name: "支付订单"
method: POST
endpoint: "/api/orders/{order_id}/pay"
headers:
Authorization: "Bearer {token}"
body:
payment_method: "credit_card"
amount: "{total_amount}"
extract:
payment_id: "$.payment_id"
payment_status: "$.status"
validate:
- type: "status_code"
expected: 200
- type: "json_path"
path: "$.status"
expected: "paid"
- step_name: "查询订单状态"
method: GET
endpoint: "/api/orders/{order_id}"
headers:
Authorization: "Bearer {token}"
validate:
- type: "status_code"
expected: 200
- type: "json_path"
path: "$.status"
expected: "paid"
6.2.2 接口关联测试实现
创建 test_api_chain.py:
import pytest
import yaml
import os
import re
import time
from api_test_utils import APITestClient
def load_api_chain_tests():
"""加载接口关联测试用例"""
file_path = os.path.join(os.path.dirname(__file__), 'api_chain_test.yaml')
with open(file_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
def extract_json_path(data, path):
"""从 JSON 数据中提取路径值"""
import jsonpath
result = jsonpath.jsonpath(data, path)
return result[0] if result else None
def replace_variables(text, variables):
"""替换文本中的变量"""
pattern = r'{([^}]+)}'
def replace(match):
var_name = match.group(1)
# 特殊处理 timestamp
if var_name == 'timestamp':
return str(int(time.time()))
return str(variables.get(var_name, match.group(0)))
return re.sub(pattern, replace, text)
@pytest.fixture(scope='session')
def api_client():
"""创建 API 客户端"""
base_url = "https://api.example.com"
return APITestClient(base_url)
@pytest.mark.parametrize("test_chain",
load_api_chain_tests()['test_chains'],
ids=lambda chain: chain['name'])
def test_api_chain(api_client, test_chain):
"""
执行接口关联测试
参数:
api_client: API 客户端
test_chain: 测试链数据
"""
# 存储提取的变量
variables = {}
print(f"n{'='*60}")
print(f"测试链: {test_chain['name']}")
print(f"描述: {test_chain['description']}")
print(f"{'='*60}")
# 执行每个步骤
for step in test_chain['steps']:
step_name = step['step_name']
print(f"n执行步骤: {step_name}")
# 替换变量
endpoint = replace_variables(step['endpoint'], variables)
headers = step.get('headers', {})
if headers:
headers = {k: replace_variables(v, variables)
for k, v in headers.items()}
body = step.get('body', {})
if body:
body = {k: replace_variables(str(v), variables)
if isinstance(v, str) else v
for k, v in body.items()}
# 发送请求
response = api_client.request(
method=step['method'],
endpoint=endpoint,
headers=headers,
body=body
)
print(f" 请求 URL: {response.url}")
print(f" 状态码: {response.status_code}")
# 提取数据
extract_rules = step.get('extract', {})
if extract_rules:
try:
response_json = response.json()
for var_name, json_path in extract_rules.items():
value = extract_json_path(response_json, json_path)
variables[var_name] = value
print(f" 提取变量 {var_name} = {value}")
except:
pass
# 验证响应
validations = step.get('validate', [])
# 替换验证规则中的变量
for validation in validations:
if 'expected' in validation:
expected = validation['expected']
if isinstance(expected, str) and expected.startswith('{'):
validation['expected'] = variables.get(
expected.strip('{}'), expected
)
passed, errors = api_client.validate_response(response, validations)
# 断言
assert passed, f"步骤 '{step_name}' 验证失败:n" + "n".join(errors)
print(f" ✓ 步骤 '{step_name}' 执行成功")
print(f"n{'='*60}")
print(f"测试链 '{test_chain['name']}' 全部通过")
print(f"{'='*60}")
7. 高级用法和最佳实践
7.1 数据驱动测试的完整示例
7.1.1 完整的测试数据文件结构
创建 complete_test_data.yaml:
# 完整的测试数据文件示例
# 元数据
metadata:
version: "1.0"
author: "测试团队"
created_date: "2024-01-01"
description: "完整的测试数据文件"
# 环境配置
environments:
dev:
base_url: "http://dev.example.com"
database: "test_db_dev"
test:
base_url: "http://test.example.com"
database: "test_db_test"
# 用户测试数据
users:
admin:
username: "admin"
password: "admin123"
role: "administrator"
permissions:
- "read"
- "write"
- "delete"
normal_user:
username: "user"
password: "user123"
role: "user"
permissions:
- "read"
# 登录测试用例
login_test_cases:
- name: "管理员登录成功"
user_type: "admin"
expected:
status_code: 200
role: "administrator"
has_permissions: ["read", "write", "delete"]
- name: "普通用户登录成功"
user_type: "normal_user"
expected:
status_code: 200
role: "user"
has_permissions: ["read"]
- name: "错误密码登录失败"
username: "admin"
password: "wrong_password"
expected:
status_code: 401
error_message: "用户名或密码错误"
# API 测试数据
api_tests:
get_user:
- user_id: 1
expected_name: "张三"
expected_email: "zhangsan@example.com"
- user_id: 2
expected_name: "李四"
expected_email: "lisi@example.com"
create_user:
- data:
name: "王五"
age: 28
email: "wangwu@example.com"
expected_status: 201
- data:
name: "" # 空名称,应该失败
age: 28
email: "invalid@example.com"
expected_status: 400
expected_error: "名称不能为空"
7.1.2 使用数据驱动测试
import pytest
import yaml
import os
class TestDataManager:
"""测试数据管理器"""
def __init__(self, yaml_file):
"""初始化数据管理器"""
self.yaml_file = yaml_file
self._data = None
self._load_data()
def _load_data(self):
"""加载 YAML 数据"""
file_path = os.path.join(os.path.dirname(__file__), self.yaml_file)
with open(file_path, 'r', encoding='utf-8') as f:
self._data = yaml.safe_load(f)
def get(self, *keys):
"""
获取嵌套的数据
参数:
*keys: 键的路径,例如 get('users', 'admin')
返回:
对应的值
"""
value = self._data
for key in keys:
if isinstance(value, dict):
value = value.get(key)
else:
return None
return value
def get_user(self, user_type):
"""获取用户数据"""
return self.get('users', user_type)
def get_login_cases(self):
"""获取登录测试用例"""
return self.get('login_test_cases') or []
def get_api_tests(self, api_name):
"""获取 API 测试数据"""
return self.get('api_tests', api_name) or []
# 创建全局数据管理器
test_data = TestDataManager('complete_test_data.yaml')
@pytest.fixture(scope='session')
def data_manager():
"""提供数据管理器 Fixture"""
return test_data
def test_login_with_data_manager(data_manager):
"""使用数据管理器进行登录测试"""
login_cases = data_manager.get_login_cases()
for case in login_cases:
# 获取用户数据
if 'user_type' in case:
user = data_manager.get_user(case['user_type'])
username = user['username']
password = user['password']
else:
username = case['username']
password = case['password']
expected = case['expected']
# 执行登录(模拟)
result = login(username, password)
# 验证
assert result['status_code'] == expected['status_code']
if 'role' in expected:
assert result['role'] == expected['role']
def login(username, password):
"""模拟登录函数"""
users_db = {
'admin': {'password': 'admin123', 'role': 'administrator'},
'user': {'password': 'user123', 'role': 'user'}
}
if username in users_db and users_db[username]['password'] == password:
return {
'status_code': 200,
'role': users_db[username]['role']
}
else:
return {
'status_code': 401,
'error_message': '用户名或密码错误'
}
7.2 错误处理和调试
7.2.1 YAML 文件错误处理
import pytest
import yaml
import os
def safe_load_yaml(file_path, default=None):
"""
安全加载 YAML 文件,包含错误处理
参数:
file_path: YAML 文件路径
default: 加载失败时的默认值
返回:
加载的数据或默认值
"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
if data is None:
return default
return data
except FileNotFoundError:
pytest.fail(f"YAML 文件不存在: {file_path}")
except yaml.YAMLError as e:
pytest.fail(f"YAML 文件格式错误: {e}")
except Exception as e:
pytest.fail(f"加载 YAML 文件时发生错误: {e}")
def validate_yaml_structure(data, required_keys):
"""
验证 YAML 数据结构
参数:
data: YAML 数据
required_keys: 必需的键列表
返回:
是否有效
"""
if not isinstance(data, dict):
return False
for key in required_keys:
if key not in data:
return False
return True
# 使用示例
def test_with_error_handling():
"""带错误处理的测试"""
file_path = 'test_data.yaml'
data = safe_load_yaml(file_path, default={})
# 验证数据结构
required_keys = ['login_cases', 'register_cases']
assert validate_yaml_structure(data, required_keys),
"YAML 文件缺少必需的键"
# 使用数据...
7.2.2 调试技巧
import pytest
import yaml
@pytest.fixture(autouse=True)
def debug_yaml_data(request):
"""自动打印 YAML 数据(用于调试)"""
# 只在 verbose 模式下打印
if request.config.getoption("-v"):
# 可以在这里添加调试代码
pass
def test_with_debug():
"""带调试信息的测试"""
with open('test_data.yaml', 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
# 打印数据结构(用于调试)
import json
print("nYAML 数据结构:")
print(json.dumps(data, indent=2, ensure_ascii=False))
# 测试逻辑...
7.3 性能优化
7.3.1 缓存 YAML 数据
import pytest
import yaml
import os
from functools import lru_cache
@lru_cache(maxsize=10)
def load_yaml_cached(file_path):
"""缓存 YAML 文件加载结果"""
with open(file_path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
@pytest.fixture(scope='session')
def cached_test_data():
"""缓存的测试数据"""
file_path = os.path.join(os.path.dirname(__file__), 'test_data.yaml')
return load_yaml_cached(file_path)
7.3.2 按需加载数据
import pytest
import yaml
class LazyYAMLLoader:
"""延迟加载 YAML 数据"""
def __init__(self, file_path):
self.file_path = file_path
self._data = None
@property
def data(self):
"""延迟加载数据"""
if self._data is None:
with open(self.file_path, 'r', encoding='utf-8') as f:
self._data = yaml.safe_load(f)
return self._data
def get(self, key, default=None):
"""获取指定键的数据"""
return self.data.get(key, default)
# 使用示例
loader = LazyYAMLLoader('test_data.yaml')
def test_with_lazy_loading():
"""使用延迟加载"""
# 只有在访问时才加载数据
login_cases = loader.get('login_cases', [])
# 测试逻辑...
8. 常见问题和解决方案
8.1 YAML 文件编码问题
问题:YAML 文件包含中文时出现乱码
解决方案:
# ✅ 正确:指定 UTF-8 编码
with open('test_data.yaml', 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
# ❌ 错误:不指定编码(Windows 上可能出错)
with open('test_data.yaml', 'r') as f:
data = yaml.safe_load(f)
8.2 YAML 缩进问题
问题:YAML 解析错误,提示缩进问题
解决方案:
# ✅ 正确:使用空格缩进(2 个或 4 个空格)
user:
name: 张三
age: 25
# ❌ 错误:使用 Tab 缩进
user:
name: 张三 # Tab 缩进会导致错误
age: 25
检查缩进的方法:
# 在编辑器中显示空格和 Tab
# VSCode: 设置 "editor.renderWhitespace": "all"
# PyCharm: 设置 "Show whitespaces"
8.3 数据类型问题
问题:YAML 中的数字被解析为字符串
解决方案:
# YAML 会自动识别类型
age: 25 # 整数
price: 99.99 # 浮点数
count: "100" # 字符串(使用引号)
# 在 Python 中转换类型
import yaml
data = yaml.safe_load("""
age: 25
count: "100"
""")
age = int(data['age']) # 已经是整数,不需要转换
count = int(data['count']) # 字符串需要转换
8.4 空值处理
问题:YAML 中的 null 值处理
解决方案:
# YAML 中的空值
middle_name: null
description: ~
empty_value:
# 在 Python 中检查
if data.get('middle_name') is None:
print("middle_name 是空值")
8.5 特殊字符处理
问题:YAML 中包含特殊字符(如冒号、引号等)
解决方案:
# 使用引号包裹包含特殊字符的字符串
message: "Hello: World"
description: 'It's a test'
url: "https://example.com?param=value&other=123"
# 多行字符串
content: |
这是多行内容
可以包含: 冒号
可以包含"引号"
可以包含'单引号'
9. 实战项目示例
9.1 完整的测试项目结构
project/
├── tests/
│ ├── conftest.py # Pytest 配置和 Fixture
│ ├── test_data/
│ │ ├── login_data.yaml # 登录测试数据
│ │ ├── user_data.yaml # 用户测试数据
│ │ └── api_data.yaml # API 测试数据
│ ├── test_login.py # 登录测试
│ ├── test_user.py # 用户测试
│ └── test_api.py # API 测试
├── config/
│ ├── config.yaml # 配置文件
│ └── environments.yaml # 环境配置
├── utils/
│ ├── yaml_loader.py # YAML 加载工具
│ └── api_client.py # API 客户端
└── pytest.ini # Pytest 配置
9.2 conftest.py 完整示例
import pytest
import yaml
import os
from pathlib import Path
# 项目根目录
PROJECT_ROOT = Path(__file__).parent.parent
@pytest.fixture(scope='session')
def project_root():
"""返回项目根目录"""
return PROJECT_ROOT
@pytest.fixture(scope='session')
def load_yaml():
"""YAML 加载函数 Fixture"""
def _load(file_path, key=None):
"""加载 YAML 文件"""
full_path = PROJECT_ROOT / file_path
with open(full_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
return data.get(key) if key else data
return _load
@pytest.fixture(scope='session')
def test_config(load_yaml):
"""加载测试配置"""
return load_yaml('config/config.yaml')
@pytest.fixture(scope='session')
def env_config(load_yaml):
"""加载环境配置"""
import os
env = os.getenv('TEST_ENV', 'dev')
config = load_yaml('config/environments.yaml')
return config['environments'][env]
@pytest.fixture(scope='session')
def login_data(load_yaml):
"""加载登录测试数据"""
return load_yaml('tests/test_data/login_data.yaml')
@pytest.fixture(scope='session')
def user_data(load_yaml):
"""加载用户测试数据"""
return load_yaml('tests/test_data/user_data.yaml')
@pytest.fixture(scope='session')
def api_data(load_yaml):
"""加载 API 测试数据"""
return load_yaml('tests/test_data/api_data.yaml')
9.3 测试用例示例
import pytest
def test_login_with_yaml_data(login_data):
"""使用 YAML 数据的登录测试"""
login_cases = login_data['login_cases']
for case in login_cases:
username = case['username']
password = case['password']
expected = case['expected']
# 执行登录
result = perform_login(username, password)
# 验证
assert result == expected
def test_user_operations(user_data):
"""使用 YAML 数据的用户操作测试"""
user_cases = user_data['user_cases']
for case in user_cases:
# 测试逻辑...
pass
def perform_login(username, password):
"""执行登录(示例函数)"""
# 实际项目中应该调用真实的登录接口
return True
10. 总结
10.1 关键要点
-
YAML 语法:
- 使用空格缩进,不要使用 Tab
- 字符串、数字、布尔值、列表、字典的基本用法
- 注释和多行字符串的使用
-
在 Pytest 中使用 YAML:
- 使用
yaml.safe_load()安全加载 YAML 文件 - 使用
@pytest.mark.parametrize进行数据驱动测试 - 使用 Fixture 管理 YAML 数据的加载和共享
- 使用
-
最佳实践:
- 数据与代码分离
- 使用有意义的文件名和数据结构
- 添加错误处理和验证
- 使用缓存优化性能
10.2 学习路径建议
-
初级阶段:
- 学习 YAML 基本语法
- 掌握在 Python 中读取 YAML 文件
- 使用 YAML 存储简单的测试数据
-
中级阶段:
- 使用 YAML 进行数据驱动测试
- 使用 Fixture 管理 YAML 数据
- 处理复杂的数据结构
-
高级阶段:
- 使用 YAML 配置测试环境
- 实现接口关联测试
- 优化性能和错误处理
10.3 参考资料
祝你在使用 Pytest 和 YAML 进行测试时顺利! 🎉