Pytest实战API测试框架
功能规划
- 数据库断言 pymysql -> 封装
- 环境清理 数据库操作 -> Fixtures
- 并发执行 pytest-xdist 多进程并行
- 复合断言 pytest-check
- 用例重跑 pytest-rerunfailures
- 环境切换 pytest-base-url
- 数据分离 pyyaml
- 配置分离 pytest.ini
- 报告生成 pytest-html, allure-pytest
- 用例等级 pytest-level
- 限制用例超时时间 pytest-timeout
- 发送报告邮件 通过自定Fixture及Hooks实现
安装相应的包
pip安装时可以通过-i https://pypi.doubanio.com/simple/
,指定使用豆瓣的源, 下载稍微快一点
pip install requests
pip install pymysql
pip install pyyaml
pip install pytest
pip install pytest-xdist
pip install pytest-check
pip install pytest-rerunfailures
pip intsall pytest-base-url
pip intstall pytest-html
pip install pytest-level
pip install pytest-timeout
导出依赖到requirements.txt中
pip freeze > requirments.txt
结构规划
分层结构
分层设计模式: 每一层为上层提供服务
用例层(测试用例)
|
Fixtures辅助层(全局的数据、数据库操作对象和业务流等)
|
utils实用方法层(数据库操作, 数据文件操作,发送邮件方法等等)
静态目录
- data: 存放数据
- reports: 存放报告
目录结构
longteng17/
- data/
- data.yaml: 数据文件
- reports/: 报告目录
- test_cases/: 用例目录
- pytest.ini: pytest配置
- api_test/: 接口用例目录
- conftest.py: 集中管理Fixtures方法
- web_test/: web用例目录
- app_test/: app用例目录
- utils/: 辅助方法
- data.py: 封装读取数据文件方法
- db.py: 封装数据库操作方法
- api.py: 封装api请求方法
- notify.py: 封装发送邮件等通知方法
- conftest.py: 用来放置通用的Fixtures和Hooks方法
- pytest.ini: Pytest运行配置
规划conftest.py的位置,要确保项目跟目录被导入到环境变量路径(sys.path)中去。
conftest.py及用例的导入机制为:
- 如果在包(同级有init.py)内,则导入最上层包(最外一个包含init.py)的父目录。
- 如果所在目录没有init.py,直接导入conftest.py父目录。
数据文件的选择
- 无结构
- txt: 分行, 无结构的文本数据
- 表格型
- csv: 表格型, 适合大量同一类型的数据
- excel: 表格型, 构造数据方便, 文件较大,解析较慢
- 树形
- json: 可以存储多层数据, 格式严格,不支持备注
- yaml: 兼容json, 灵活,可以存储多层数据
- xml: 可以存储多层, 文件格式教繁琐
- 配置型
- .ini/.properties/.conf: 只能存储1-2层数据, 适合配置文件
由于用例数据常常需要多层级的数据结构,这里选择yaml文件作为本项目的数据文件,示例格式如下:
test_case1:
a: 1
b: 2
数据第一层以用例名标识某条用例所使用的数据,这里约定要和用例名称完全一致,方便后面使用Fixture方法自动向用例分配数据。
标记规划
标记: mark, 也称作标签, 用来跨目录分类用例方便灵活的选择执行。
- 按类型: api, web, app
- 标记有bug: bug
- 标记异常流程: negative
也可以根据自己的需求,按模块、按是否有破坏性等来标记用例。
utils实用方法层
数据文件操作: data.py
首先需要安装pyyaml, 安装方法:pip install pyyaml
读取yaml文件数据的方法为:
- 打开文件 with open(..) as f:
- 加载数据 data=yaml.safe_load(f)
yaml.safe_load()和yaml.load()的区别:
由于yaml文件也支持任意的Python对象
从文件中直接加载注入Python是极不安全的, safe_load()会屏蔽Python对象类型,只解析加载字典/列表/字符串/数字等级别类型数据
示例如下:
import yaml
def load_yaml_data(file_path):
with open(file_path, encoding='utf-8') as f:
data = yaml.safe_load(f)
print("加载yaml文件: {file_path} 数据为: {data}")
return data
为了示例简单,这里没有对文件不存在、文件格式非yaml等异常做处理。异常处理统一放到Fixture层进行。
假如项目要支持多种数据文件, 可以使用类来处理。
数据库操作: db.py
这里使用pymysql, 安装方法pip install pymysql
敏感数据处理
数据库配置分离
数据库密码等敏感数据,直接放在代码或配置文件中,会有暴露风险,用户敏感数据我们可以放到环境变量中,然后从环境变量中读取出来。
注意:部署项目时,应记得在服务器上配置相应的环境变量,才能运行。
Windows在环境变量中添加变量MYSQL_PWD,值为相应用户的数据库密码,也可以将数据库地址,用户等信息也配置到环境变量中。
Linux/Mac用户可以通过在/.bashrc或/.bash_profile或/etc/profile中添加
export MYSQL_PWD=数据库密码
然后source相应的文件使之生效,如source ~/.bashrc
。
Python中使用os.getenv('MYSQL_PWD')
便可以拿到相应环境变量的值。
注意:如果使用PyCharm,设置完环境变量后,要重启PyCharm才能读取到新的环境变量值。
我们使用字典来存储整个数据库的配置,然后通过字典拆包传递给数据库连接方法。
import os
import pymysql
DB_CONF = {
'host': '数据库地址',
'port': 3306,
'user': 'test',
'password': os.getenv('MYSQL_PWD'),
'db': 'longtengserver',
'charset': 'utf8'
}
conn = pymysql.connect(**DB_CONF)
封装数据库操作方法
数据常见的操作方法有查询,执行修改语句和关闭连接等。对应一种对象的多个方法,我们使用类来封装。
同时为避免查询语句和执行语句的串扰,我们在建立连接时使用autocommit=True来确保每条语句执行后都立即提交,完整代码如下。
import os
import pymysql
DB_CONF = {
'host': '数据库地址',
'port': 3306,
'user': 'test',
'password': os.getenv('MYSQL_PWD'),
'db': 'longtengserver',
'charset': 'utf8'
}
class DB(object):
def __init__(self, db_conf=DB_CONF)
self.conn = pymysql.connect(**db_conf, autocommit=True)
self.cur = self.conn.cursor(pymysql.cursors.DictCursor)
def query(self, sql):
self.cur.execute(sql)
data = self.cur.fetchall()
print(f'查询sql: {sql} 查询结果: {data}')
return data
def change_db(self, sql):
result = self.cur.execute(sql)
print(f'执行sql: {sql} 影响行数: {result}')
def close(self):
print('关闭数据库连接')
self.cur.close()
self.conn.close()
其中如果查询中包含中文,要根据数据库指定响应的charset,这里的charset值为utf8不能写成utf-8。
self.conn.cursor(pymysql.cursors.DictCursor)这里使用了字典格式的游标,返回的查询结果会包含响应的表字段名,结果更清晰。
由于所有sql语句都是单条自动提交,不支持事务,因此在change_db时,不需要再作事务异常回滚的操作,对于数据库操作异常,统一在Fixture层简单处理。
封装常用数据库操作
# db.py
...
class FuelCardDB(DB):
def del_card(self, card_number):
print(f'删除加油卡: {card_number}')
sql = f'DELETE FROM cardinfo WHERE cardNumber="{card_number}"'
self.change_db(sql)
def check_card(self, card_number):
print(f'查询加油卡: {card_number}')
sql = f'SELECT id FROM cardinfo WHERE cardNumber="{card_number}"'
res = self.query(sql)
return True if res else False
def add_card(self, card_number):
print(f'添加加油卡: {card_number}')
sql = f'INSERT INTO cardinfo (cardNumber) VALUES ({card_number})'
self.change_db(sql)
发送邮件通知: notify.py
使用Python发送邮件
发送邮件一般要通过SMTP协议发送。首先要在你的邮箱设置中开启SMTP服务,清楚SMTP服务器地址、端口号已经是否必须使用安全加密传输SSL等。
使用Python发送邮件分3步:
- 组装邮件内容MIMEText
- 组装邮件头: From、To及Subject
- 登录SMTP服务器发送邮件
- 组装邮件内容MIMEText
from email.mime.text import MIMEText
import smtplib
body = 'Hi, all\n附件中是测试报告, 如有问题请指出'
body2 = '测试报告
以下为测试报告内容
'
# msg = MIMEText(content, 'plain', 'utf-8')
msg = MIMEText(content2, 'html', 'utf-8')
使用MIMEText组装Email消息数据对象,正文支持纯文本plain和html两种格式。
- 组装邮件头: From、To及Subject
...
msg['From'] = '[email protected]'
msg['To'] = '[email protected]'
msg['Subject'] = '接口测试报告'
msg['From']中也可以声明收件人名称,格式为:
msg['From'] = '<韩志超> [email protected]'
msg['To']中也可以写多个收件人,写到一个字符串中使用英文逗号隔开:
msg['To'] = '[email protected],[email protected]'
注意邮件头的From、To只是一种声明,并不一定是实际的发件人和收件人,比如From写A邮箱,实际发送时,使用B邮箱的SMTP发送,便会形成代发邮件(B代表A发送)。
- 登录SMTP服务器发送邮件
...
smtp = smtplib.SMTP('邮箱SMTP地址')
# smtp = smtplib.SMTP_SSL('邮箱SMTP地址')
smtp.login('发件人邮箱', '密码')
smtp.sendmail('发件人邮箱', '收件人邮箱', msg.as_string())
这里登录SMTP和SMTP_SSL要看邮箱服务商支持哪种,连接时也可以指定端口号,如:
smtp = smtplib.SMTP_SSL('邮箱SMTP地址', 465)
登录时的密码根据邮箱的支持可以是授权码或登录密码(一般如QQ邮箱采用授权码,不支持使用登录密码登录SMTP)。
sendmail发送邮件时,使用的发件人邮箱和收件人邮箱是实践的发件人和收件人,可以和邮件头中的不一致。但是发件人邮箱必须和登录SMTP的邮箱一致。
sendmail每次只能给一个收件人发送邮件,当有多个收件人是,可以使用多次sendmail方法,示例如下:
receivers = ['[email protected]', '[email protected]']
for person in receivers:
smtp.sendmail('发件人邮箱', person, msg.as_string())
msg.as_string()是将msg消息对象序列化为字符串后发送。
发送带附件的邮件
由于邮件正文会过滤掉大部分的样式和JavaScript,因此直接将html报告读取出来,放到邮件正文中往往没有任何格式。这时,我们可以通过附件来发送测试报告。
邮件附件一般采用二进制流格式(application/octet-stream),正文则采用文本格式。要混合两种格式我们需要使用MIMEMultipart这种混合的MIME格式,一般步骤为:
- 建立一个MIMEMultipart消息对象
- 添加MIMEText格式的正文
- 添加MIMEText格式的附件(打开附件,按Base64编码转为MIMEText格式)
- 添加邮件头信息
- 发送邮件
示例代码如下:
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import smtplib
# 1. 建立一个MIMEMultipart消息对象
msg = MIMEMultipart()
# 2. 添加邮件正文
body = MIMEText('hi, all\n附件中是测试报告,请查收', 'plain', 'utf-8')
msg.attach(body)
# 3. 添加附件
att = MIMEText(open('report.html', 'rb').read(), 'base64', 'utf-8')
att['Content-Type'] = 'application/octet-stream'
att["Content-Disposition"] = 'attachment; filename=report.html'
msg.attach(att1)
# 4. 添加邮件头信息
...
# 5. 发送邮件
...
使用消息对象msg的attach方法来添加MIMEText格式的邮件正文和附件。
构造附件MIMEText对象时,要使用rb模式打开文件,使用base64格式编码,同时要声明附件的内容类型Content-Type以及显示排列Content-Dispositon,这里的attachment; filename=report.html
,attachment代表附件图标,filename代表显示的文件名,这里表示图标在左,文件名在右,显示为report.html。
添加邮件头信息和发送邮件同发送普通邮件一致。
发送邮件方法封装
同样,我们可以将敏感信息邮箱密码配置到环境变量中去,这里变量名设置为SMTP_PWD。
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import smtplib
SMTP_HOST = '邮箱SMTP地址'
SMTP_USER = '发件人邮箱'
SMTP_PWD = os.getenv('SMTP_PWD')
def send_email(self, body, subject, receivers, file_path):
msg = MIMEMultipart()
msg.attach(MIMEText(body, 'html', 'utf-8'))
att1 = MIMEText(open(file_path, 'rb').read(), 'base64', 'utf-8')
att1['Content-Type'] = 'application/octet-stream'
att1["Content-Disposition"] = f'attachment; filename={file_name}'
msg.attach(att1)
msg['From'] = SMTP_USER
msg['To'] = ','.join(receivers)
msg['Subject'] = subject
smtp = smtplib.SMTP_SSL(SMTP_HOST)
smtp.login(SMTP_USER, SMTP_PWD)
for person in receivers:
print(f'发送邮件给: {person}')
smtp.sendmail(SMTP_USER, person, msg.as_string())
print('邮件发送成功')
同样,为了示例简单,这里并没有对SMTP连接、登录、发送邮件做异常处理,读者可以进行相应的补充。
请求方法封装:api.py
requests本身已经提供了很好的方法,特别是通用的请求方法requests.request()。这里的封装只是简单加了base_url组装、默认的超时时间和打印信息。
import requests
TIMEOUT = 30
class Api(object):
def __init__(self, base_url=None):
self.session = requests.session()
self.base_url = base_url
def request(self, method, url, **kwargs):
url = self.base_url + url if self.base_url else url
kwargs['timeout'] = kwargs.get('timeout', TIMEOUT)
res = self.session.request(method, url, **kwargs)
print(f"发送请求: {method} {url} {kwargs} 响应数据: {res.text}")
return res
def get(self, url, **kwargs):
return self.request('get', url, **kwargs)
def post(self, url, **kwargs):
return self.request('post', url, **kwargs)
这里,Api实例化时如果传递了base_url参数,则所有的url都会拼接上base_url。
kwargs['timeout'] = kwargs.get('timeout', TIMEOUT)
,设置默认的超时时间设置为30s。
Fixtures方法层
import pytest
from utils.data import Data
from utils.db import FuelCardDB
from utils.api import Api
@pytest.fixture(scope='session')
def data(request):
basedir = request.config.rootdir
try:
data_file_path = os.path.join(basedir, 'data', 'api_data.yaml')
data = Data().load_yaml(data_file_path)
except Exception as ex:
pytest.skip(str(ex))
else:
return data
@pytest.fixture(scope='session')
def db():
try:
db = FuelCardDB()
except Exception as ex:
pytest.skip(str(ex))
else:
yield db
db.close()
@pytest.fixture(scope='session')
def api(base_url):
api = Api(base_url)
return api
这里对,utils实用方法层的异常进行简单的skip处理,即当数据连接或数据文件有问题时,所有引用该Fixture的用例都会自动跳过。
在api这个Fixtures中我们引入了base_url,它来自于插件pytest-base-url,可以在运行时通过命令行选项--base-url或pytest.ini中的配置项base_url来指定。
[pytest]
...
base_url=http://....:8080
按用例名分发用例
Fixture方法通过用例参数,注入到用例中使用。Fixture方法中可以拿到用例所在的模块,模块变量,用例方法对象等数据,这些数据都封装在Fixture方法的上下文参数request中。
原有的data这个Fixture方法为用例返回了数据文件中的所有数据,但是一般用例只需要当前用例的数据即可。我们在数据文件中第一层使用和用例方法名同名的项来区分各个用例的数据。如:
# api_data.yaml
test_add_fuel_card_normal:
data_source_id: bHRz
cardNumber: hzc_00001
...
下面的示例演示了根据用例方法名分配数据的Fixture方法:
# conftest.py
...
@pytest.fixture
def case_data(request, data):
case_name = request.function.__name__
return data.get(case_name)
request是用例请求Fixture方法的上下文参数,里面包含了config对象、各种Pytest运行的上下文信息,可以通过Python的自省方法print(request.__dict__)
查看request对象中所有的属性。
- request.function为调用Fixture的函数方法对象,如果是用例直接调用的Fixture,这里便是用例函数对象,通过函数对象的name属性获取到函数名。
- 通过request.module拿到用例所在模块,进而根据模块中某些属性作相应动态配置。
- 通过request.config可以拿到pytest运行时的运行参数、配置参数值等信息。
这样,用例中引入的case_data参数就只是该用例的数据。
用例层
一条完整的用例应包含以下步骤:
- 环境检查或数据准备
- 业务操作
- 不止一条断言语句(包括数据库断言)
- 环境清理
另外一般用例还应加上指定的标记。
import pytest
@pytest.mark.level(1)
@pytest.mark.api
def test_add_fuel_card_normal(api, db, case_data):
"""正常添加加油卡"""
url = '/gasStation/process'
data_source_id, card_number = case_data.get('data_source_id'), case_data.get('card_number')
# 环境检查
if db.check_card(card_number):
pytest.skip(f'卡号: {card_number} 已存在')
json_data = {"dataSourceId": data_source_id, "methodId": "00A",
"CardInfo": {"cardNumber": card_number}}
res_dict = api.post(url, json=json_data).json()
# 响应断言
assert 200 == res_dict.get("code"))
assert "添加卡成功" == res_dict.get("msg")
assert res_dict.get('success') is True
# 数据库断言
assert db.check_card(card_number) is True
# 环境清理
db.del_card(card_number)
使用复合断言:pytest-check
使用assert断言时,当某一条断言失败后,该条用例即视为失败,后面的断言不会再进行判断。有时我们需要每一次可以检查所有的检查点,输出所有断言失败项。此时我们可以使用pytest-check插件进行复合断言。
安装方法pip install pytest-check。
所谓复合断言即,当某条断言失败后仍继续检查下面的断言,最后汇总所有失败项。
pytest-check使用方法
import pytest_check as check
...
check.equal(200, es_dict.get("code"))
check.equal("添加卡成功",res_dict.get("msg"))
check.is_true(res_dict.get('success'))
check.is_true(db.check_card(card_number))
除此外常用的还有:
- check.is_false():断言值为False
- check.is_none(): 断言值为None
- check.is_not_none():断言值不为None
标记用例跳过和预期失败
如果某些用例暂时环境不满足无法运行可以标记为skip, 也可以使用skipif()判断条件跳过。 对于已知Bug,尚未完成的功能也可以标记为xfail(预期失败)。
使用方法如下:
import os
import pytest
@pytest.mark.skip(reason="暂时无法运行该用例")
def test_a():
pass
@pytest.mark.skipif(os.getenv("MYSQL_PWD") is None, reason="缺失环境变量MYSQL_PWD配置")
def test_b():
pass
@pytest.mark.xfail(reason='尚未解决的已知Bug')
def test_c():
pass
test_b首先对环境变量做了检查,如果没有配置MYSQL_PWD这个环境变量,则会跳过该用例。
test_c为期望失败,这时如果用例正常通过则视为异常的xpass状态,失败则为视为正常的xfail状态,在--strict严格模式下,xfail视为用例通过,xpass视为用例失败。
这里标记运行时分别使用-r/-x/-X显示skip、xfail、xpass的原因说明:
pytest -rsxX
这里的-s可以在命令行上显示用例中print的一些信息。
另外,也可以在Fixture方法或用例中,使用pytest.skip("跳过原因"), pytest.xfail("期望失败原因")来根据条件表用例跳过和期望失败。
标记skip和xfail属于一种临时隔离策略,等问题修复后,应及时去掉该标记。
运行控制
切换环境
运行时通过传入--base-url
来切换环境:
pytest --base-url=http://服务地址:端口号
失败用例重跑
默认Pytest支持-lf参数来重跑上次失败的用例。但如果我们想要本次用例失败后自动重跑的话,可以使用pytest-rerunfailures插件。
安装方法pip install pytest-rerunfailures。
运行时使用
pytest --reruns 3 --reruns-delay 1
来指定失败用例延迟1s后自动重跑,最多重跑3次。
对应已知的不稳定用例,我们可以通过flasky标记,来使之失败时自动重跑,示例如下。
import pytest
@pytest.mark.flaky(reruns=3, reruns_delay=1)
def test_example():
import random
assert random.choice([True, False])
按用例等级运行
使用pytest-level可以对用例标记等级,安装方法: pip install pyest-level
使用方法:
@pytest.mark.level(1)
def test_basic_math():
assert 1 + 1 == 2
@pytest.mark.level(2)
def test_intermediate_math():
assert 10 / 2 == 5
@pytest.mark.level(3)
def test_complicated_math():
assert 10 ** 3 == 1000
运行时通过--level来选择运行的等级。
pytest --level 2
以上只会运行level1和level2的用例(数字越大,优先级约低)
限制用例执行时间
使用插件pytest-timeout可以限制用例的最大运行时间。
安装方法:pip install pytest-timeout
使用方式为
pytest --timeout=30
或配置到pytest.ini中
...
timeout=30
用例并行
使用pytest-xdist可以开启多个进程运行用例。
安装方法:pip install pytest-xdist
使用方式
pytest -n 4
将所有用例分配到4个进程运行。
完整的项目配置文件
pytest.ini
[pytest]
miniversion = 5.0.0
addopts = --strict --html=report_{}.html --self-contained-html
base_url = http://115.28.108.130:8080
testpaths = test_cases/
markers =
api: api test case
web: web test case
app: app test case
negative: abnormal test case
email_subject = Test Report
email_receivers = [email protected],[email protected]
email_body = Hi,all\n, Please check the attachment for the Test Report.
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
timeout = 10
timeout_func_only = true