Pytest實戰API測試框架

https://www.jianshu.com/p/40a0b396465c?utm_campaign=hugo&utm_medium=reader_share&utm_content=note&utm_source=weixin-timeline&from=timeline&isappinstalled=0

功能規劃

  1. 數據庫斷言 pymysql -> 封裝
  2. 環境清理 數據庫操做 -> Fixtures
  3. 併發執行 pytest-xdist 多進程並行
  4. 複合斷言 pytest-check
  5. 用例重跑 pytest-rerunfailures
  6. 環境切換 pytest-base-url
  7. 數據分離 pyyaml
  8. 配置分離 pytest.ini
  9. 報告生成 pytest-html, allure-pytest
  10. 用例等級 pytest-level
  11. 限制用例超時時間 pytest-timeout
  12. 發送報告郵件 經過自定Fixture及Hooks實現

安裝相應的包

pip安裝時能夠經過-i https://pypi.doubanio.com/simple/,指定使用豆瓣的源, 下載稍微快一點html

pip install requests pymysql pyyaml pytest pyetst-xdist pytest-check pytest-rerunfailures pytest-base-url pytest-html pytest-level pytest-timeout -i https://pypi.doubanio.com/simple/ 

導出依賴到requirements.txt中python

pip freeze > requirments.txt 

結構規劃

分層結構

分層設計模式: 每一層爲上層提供服務mysql

用例層(測試用例)
  | 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及用例的導入機制爲:web

  1. 若是在包(同級有init.py)內,則導入最上層包(最外一個包含init.py)的父目錄。
  2. 若是所在目錄沒有init.py,直接導入conftest.py父目錄。

數據文件的選擇

  • 無結構
    • txt: 分行, 無結構的文本數據
  • 表格型
    • csv: 表格型, 適合大量同一類型的數據
    • excel: 表格型, 構造數據方便, 文件較大,解析較慢
  • 樹形
    • json: 能夠存儲多層數據, 格式嚴格,不支持備註
    • yaml: 兼容json, 靈活,能夠存儲多層數據
    • xml: 能夠存儲多層, 文件格式教繁瑣
  • 配置型
    • .ini/.properties/.conf: 只能存儲1-2層數據, 適合配置文件

因爲用例數據經常須要多層級的數據結構,這裏選擇yaml文件做爲本項目的數據文件,示例格式以下:sql

test_case1:  a: 1  b: 2 

數據第一層以用例名標識某條用例所使用的數據,這裏約定要和用例名稱徹底一致,方便後面使用Fixture方法自動向用例分配數據。數據庫

標記規劃

標記: mark, 也稱做標籤, 用來跨目錄分類用例方便靈活的選擇執行。json

  • 按類型: api, web, app
  • 標記有bug: bug
  • 標記異常流程: negative

也能夠根據本身的需求,按模塊、按是否有破壞性等來標記用例。flask

utils實用方法層

數據文件操做: data.py

首先須要安裝pyyaml, 安裝方法:pip install pyyaml
讀取yaml文件數據的方法爲:設計模式

  1. 打開文件 with open(..) as f:
  2. 加載數據 data=yaml.safe_load(f)

yaml.safe_load()和yaml.load()的區別:api

因爲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步:

  1. 組裝郵件內容MIMEText
  2. 組裝郵件頭: From、To及Subject
  3. 登陸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格式,通常步驟爲:

  1. 創建一個MIMEMultipart消息對象
  2. 添加MIMEText格式的正文
  3. 添加MIMEText格式的附件(打開附件,按Base64編碼轉爲MIMEText格式)
  4. 添加郵件頭信息
  5. 發送郵件

示例代碼以下:

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參數就只是該用例的數據。

用例層

一條完整的用例應包含如下步驟:

  1. 環境檢查或數據準備
  2. 業務操做
  3. 不止一條斷言語句(包括數據庫斷言)
  4. 環境清理

另一般用例還應加上指定的標記。

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://www.jianshu.com/p/40a0b396465c 來源:簡書 簡書著做權歸做者全部,任何形式的轉載都請聯繫做者得到受權並註明出處。
相關文章
相關標籤/搜索