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 = '<h2>測試報告</h2><p>以下為測試報告內容<p>'
# msg = MIMEText(content, 'plain', 'utf-8')
msg = MIMEText(content2, 'html', 'utf-8')
使用MIMEText組裝Email消息數據對象,正文支持純文本plain和html兩種格式。
- 組裝郵件頭: From、To及Subject
...
msg['From'] = 'zhichao.han@qq.com'
msg['To'] = 'superhin@126.com'
msg['Subject'] = '接口測試報告'
msg['From']中也可以聲明收件人名稱,格式為:
msg['From'] = '<韓志超> zhichao.han@qq.com'
msg['To']中也可以寫多個收件人,寫到一個字符串中使用英文逗號隔開:
msg['To'] = 'superhin@126.com,ivan-me@163.com'
注意郵件頭的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 = ['superhin@163.com', 'zhichao.han@qq.com']
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 = superhin@126.com,hanzhichao@secco.com
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
項目源碼參考:https://github.com/hanzhichao/longteng17,略有不同。
歡迎添加作者微信:superz-han,咨詢討論技術問題。