基於 Pytest 框架的自動化測試開發實踐 (萬字長文入門篇)
Pytest 是 Python 的一種易用、高效和靈活的單元測試框架,可以支援單元測試和功能測試。本文不以介紹 Pytest 工具本身為目的,而是以一個實際的 API 測試專案為例,將 Pytest 的功能應用到實際的測試工程實踐中,教大家將 Pytest 用起來。
在開始本文之前,我想跟大家澄清兩個概念,一個是測試框架一個是測試工具。很多人容易把他們搞混了,測試框架是諸如 Unittest、Pytest、TestNG 這類,而測試工具指的則是 Selenium、Appium、Jmeter 這類。
測試框架的作用是,幫助我們管理測試用例、執行測試用例、引數化、斷言、生成測試報告等基礎性工作,讓我們將精力用在測試用例的編寫上。好的測試框架應該具有很高的擴充套件性,支援二次開發,並能夠支援多種型別的自動化測試。
測試工具的作用是為了完成某一型別的測試,比如 Selenium 用於對 WEB UI 進行自動化測試,Appium 用來對 APP 進行自動化測試,Jmeter 可以用來進行 API 自動化測試和效能測試。另外,Java 語言中 OkHttp 庫,Python 語言中的 requests 庫,這些 HTTP 的 client 也可以看做是一種 API 測試工具。
澄清了這兩個概念,說一下本文的目的。其實網上已經有很多教程,包括官方文件,都是以介紹 Pytest 的功能為出發點,羅列了各種功能的使用方法,大家看完之後會感覺都明白了,但是還是不知道如何與實際專案相結合,真正落地用起來。本文不以介紹 Pytest 工具本身為目的,而是以一個實際的 API 測試專案為例,透過單元測試框架 Pytest 和 Python 的 Requests 庫相結合,將 Pytest 功能應用到實際的測試工程實踐中,教大家將 Pytest 用起來。
請相信我,使用 Pytest 會讓你的測試工作非常高效。
01 — Pytest 核心功能
在開始使用 Pytest 之前,先來了解一下 Pytest 的核心功能,根據官方網站介紹,它具有如下功能和特點:
- 非常容易上手,入門簡單,文件豐富,文件中有很多例項可以參考。
- 能夠支援簡單的單元測試和複雜的功能測試。
- 支援引數化。
- 能夠執行全部測試用例,也可以挑選部分測試用例執行,並能重複執行失敗的用例。
- 支援併發執行,還能執行由 nose, unittest 編寫的測試用例。
- 方便、簡單的斷言方式。
- 能夠生成標準的 Junit XML 格式的測試結果。
- 具有很多第三方外掛,並且可以自定義擴充套件。
- 方便的和持續整合工具整合。
Pytest 的安裝方法與安裝其他的 python 軟體無異,直接使用 pip 安裝即可。
$ pip install -U pytest
安裝完成後,可以透過下面方式驗證是否安裝成功:
$ py.test --help
如果能夠輸出幫助資訊,則表示安裝成功了。
接下來,透過開發一個 API 自動化測試專案,詳細介紹以上這些功能是如何使用的。
02 — 建立測試專案
先建立一個測試專案目錄 api_pytest,為這個專案建立虛擬環境。關於虛擬環境的建立,可以參考這篇文章《利用 pyenv 和 pipenv 管理多個相互獨立的 Python 虛擬開發環境》。這裡我們直接介紹如何使用,執行下面兩條命令:
$ mkdir api_pytest
$ pipenv --python 3.7.7
這樣,專案目錄和虛擬環境就建立完成了。
接著,安裝依賴包,第一個是要安裝 pytest,另外本文是以 API 自動化測試為例,因此還要安裝一下 HTTP 的 client 包 requests。
$ pipenv install pytest requests
現在我們建立一個 data 目錄,用來存放測試資料,一個 tests 目錄,用來存放測試指令碼,一個 config 目錄,用來存放配置檔案,一個 utils 目錄從來存放工具。
$ mkdir data
$ mkdir tests
$ mkdir config
$ mkdir utils
現在,專案的目錄結構應該是如下這樣:
$ tree
.
├── Pipfile
├── Pipfile.lock
├── config
├── data
├── tests
└── utils
4 directories, 2 files
至此測試專案就建立完成了。接著編寫測試用例。
03 — 編寫測試用例
在這部分,我們以測試豆瓣電影列表 API 和電影詳情 API 為例,編寫測試用例。
這兩個 API 資訊如下:
| 介面 | 示例 |
| -- |--|
| 電影列表 | http://api.douban.com/v2/movie/in_theaters?apikey=0df993c66c0c636e29ecbb5344252a4a&start=0&count=10|
| 電影詳情 |https://api.douban.com/v2/movie/subject/30261964?apikey=0df993c66c0c636e29ecbb5344252a4a |
我們先寫電影列表 API 的自動化測試用例,設定 3 個校驗點:
- 驗證請求中的 start 與響應中的 start 一致。
- 驗證請求中的 count 與響應中的 count 一致。
- 驗證響應中的 title 是"正在上映的電影 - 上海"。
在 tests 目錄裡面,建立個 test_in_theaters.py 檔案,裡面編寫測試用例,內容如下:
import requests
class TestInTheaters(object):
def test_in_theaters(self):
host = "http://api.douban.com"
path = "/v2/movie/in_theaters"
params = {"apikey": "0df993c66c0c636e29ecbb5344252a4a",
"start": 0,
"count": 10
}
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36"
}
r = requests.request("GET", url=host + path, headers=headers, params=params)
response = r.json()
assert response["count"] == params["count"]
assert response["start"] == params["start"]
assert response["title"] == "正在上映的電影-上海", "實際的標題是:{}".format(response["title"])
你可能會問,這就是測試用例了?這就是基於 Pytest 的測試用例了嗎?答案是肯定的。基於 Pytest 編寫自動化測試用例,與編寫平常的 Python 程式碼沒有任何區別,唯一的區別在於檔名、函式名或者方法名要以 test_開頭或者_test 結尾,類名以 Test 開頭。
Pytest 會在 test_*.py 或者 *test.py 檔案中,尋找 class 外邊的 test開頭的函式,或者 Test 開頭的 class 裡面的 test_開頭的方法,將這些函式和方法作為測試用例來管理。可以透過下面的命令,檢視 Pytest 收集到哪些測試用例:
$ py.test --collect-only
====================================================== test session starts =======================================================
platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: /Users/chunming.liu/learn/api_pytest
collected 1 item
<Module tests/test_in_theaters.py>
<Class TestInTheaters>
<Function test_in_theaters>
===================================================== no tests ran in 0.10s ======================================================
從結果中看到,一共有一條測試用例,測試用例位於 tests/test_in_theaters.py 這個 module 裡面 TestInTheaters 這個類中的 test_in_theaters 這個方法。
在 Pytest 中斷言使用的是 Python 自帶的 assert 語句,非常簡單。
04 — 執行測試用例
下面來執行這個測試:
$ py.test tests/
====================================================== test session starts =======================================================
platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: /Users/chunming.liu/learn/api_pytest
collected 1 item
tests/test_in_theaters.py . [100%]
======================================================= 1 passed in 0.61s ========================================================
(api_pytest) MBC02X21W4G8WN:api_pytest chunming.liu$ py.test tests/
====================================================== test session starts =======================================================
platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: /Users/chunming.liu/learn/api_pytest
collected 1 item
tests/test_in_theaters.py F [100%]
============================================================ FAILURES ============================================================
________________________________________________ TestInTheaters.test_in_theaters _________________________________________________
self = <test_in_theaters.TestInTheaters object at 0x110eee9d0>
def test_in_theaters(self):
host = "http://api.douban.com"
path = "/v2/movie/in_theaters"
params = {"apikey": "0df993c66c0c636e29ecbb5344252a4a",
"start": 0,
"count": 10
}
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36"
}
r = requests.request("GET", url=host + path, headers=headers, params=params)
response = r.json()
assert response["count"] == params["count"]
assert response["start"] == params["start"]
assert response["total"] == len(response["subjects"])
> assert response["title"] == "正在上映的電影-上海", "實際的標題是:{}".format(response["title"])
E AssertionError: 實際的標題是:正在上映的電影-北京
E assert '正在上映的電影-北京' == '正在上映的電影-上海'
E - 正在上映的電影-上海
E ? ^^
E + 正在上映的電影-北京
E ? ^^
tests/test_in_theaters.py:20: AssertionError
==================================================== short test summary info =====================================================
FAILED tests/test_in_theaters.py::TestInTheaters::test_in_theaters - AssertionError: 實際的標題是正在上映的電影-北京
======================================================= 1 failed in 0.96s ========================================================
這個命令執行時,會在 tests/目錄裡面尋找測試用例。執行測試的時候,如果不指定測試用例所在目錄,Pytest 會在當前的目錄下,按照前面介紹的規則尋找測試用例並執行。
透過上面的測試輸出,我們可以看到該測試過程中,一共收集到了一個測試用例,測試結果是失敗的(標記為 F),並且在 FAILURES 部分輸出了詳細的錯誤資訊,透過這些資訊,我們可以分析測試失敗的原因。上面測試用例的失敗原因是在斷言 title 的時候出錯了,預期的 title 是 “正在上映的電影 - 上海”,但是實際是 “正在上映的電影 - 北京”,預期和實際的對比非常直觀。
執行測試用例的方法還有很多種,都是在 py.test 後面新增不同的引數即可,我在下面羅列了一下:
$ py.test # run all tests below current dir
$ py.test test_module.py # run tests in module
$ py.test somepath # run all tests below somepath
$ py.test -k stringexpr # only run tests with names that match the
# the "string expression", e.g. "MyClass and not method"
# will select TestMyClass.test_something
# but not TestMyClass.test_method_simple
$ py.test test_module.py::test_func # only run tests that match the "node ID",
# e.g "test_mod.py::test_func" will select
# only test_func in test_mod.py
上面這些用法,透過註釋很容易理解。在測試執行過程中,這些方法都有機會被用到,最好掌握一下。
05
—
資料與指令碼分離
03 小節的測試用例,將測試資料和測試程式碼放到了同一個 py 檔案中,而且是同一個測試方法中,產生了緊耦合,會導致修改測試資料或測試程式碼時,可能會相互影響,不利於測試資料和測試指令碼的維護。比如,為測試用例新增幾組新的測試資料,除了準備測試資料外,還要修改測試程式碼,降低了測試程式碼的可維護性。
另外介面測試往往是資料驅動的測試,測試資料和測試程式碼放到一起也不方便藉助 Pytest 做引數化。
將測試程式碼和測試資料分離已經是測試領域中的共識了。在 data/目錄下建立一個用於存放測試資料的 Yaml 檔案 test_in_theaters.yaml,內容如下:
---
tests:
- case: 驗證響應中start和count與請求中的引數一致
http:
method: GET
path: /v2/movie/in_theaters
headers:
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36
params:
apikey: 0df993c66c0c636e29ecbb5344252a4a
start: 0
count: 10
expected:
response:
title: 正在上映的電影-上海
count: 10
start: 0
熟悉 Yaml 格式的同學,應該很容易看懂上面測試資料檔案的內容。這個測試資料檔案中,有一個陣列 tests,裡面包含的是一條完整的測試資料。一個完整的測試資料由三部分組成:
- case,表示測試用例名稱。
- http,表示請求物件。
- expected,表示預期結果。
http 這個請求物件包含了被測介面的所有引數,包括請求方法、請求路徑、請求頭、請求引數。
expected 表示預期結果,上面的測試資料中,只列出了對請求響應的預期值,實際測試中,還可以列出對資料庫的預期值。
測試指令碼也要做相應的改造,需要讀取 test_in_theaters.yaml 檔案獲取請求資料和預期結果,然後透過 requests 發出請求。修改後的測試程式碼如下:
import requests
import yaml
def get_test_data(test_data_path):
case = [] # 儲存測試用例名稱
http = [] # 儲存請求物件
expected = [] # 儲存預期結果
with open(test_data_path) as f:
dat = yaml.load(f.read(), Loader=yaml.SafeLoader)
test = dat['tests']
for td in test:
case.append(td.get('case', ''))
http.append(td.get('http', {}))
expected.append(td.get('expected', {}))
parameters = zip(case, http, expected)
return case, parameters
cases, parameters = get_test_data("/Users/chunming.liu/learn/api_pytest/data/test_in_theaters.yaml")
list_params=list(parameters)
class TestInTheaters(object):
def test_in_theaters(self):
host = "http://api.douban.com"
r = requests.request(list_params[0][1]["method"],
url=host + list_params[0][1]["path"],
headers=list_params[0][1]["headers"],
params=list_params[0][1]["params"])
response = r.json()
assert response["count"] == list_params[0][2]['response']["count"]
assert response["start"] == list_params[0][2]['response']["start"]
assert response["total"] == len(response["subjects"])
assert response["title"] == list_params[0][2]['response']["title"], "實際的標題是:{}".format(response["title"])
注意,讀取 Yaml 檔案,需要安裝 PyYAML 包。
測試指令碼中定義了一個讀取測試資料的函式 get_test_data,透過這個函式從測試資料檔案 test_in_theaters.yaml 中讀取到了測試用例名稱 case,請求物件 http 和預期結果 expected。這三部分分別是一個列表,透過 zip 將他們壓縮到一起。
測試方法 test_in_theaters 並沒有太大變化,只是傳送請求所使用的測試資料不是寫死的,而是來自於測試資料檔案了。
通常情況下,讀取測試資料的函式不會定義在測試用例檔案中,而是會放到 utils 包中,比如放到 utils/commonlib.py 中。至此,整個專案的目錄結構應該是如下所示:
$ tree
.
├── Pipfile
├── Pipfile.lock
├── config
├── data
│ └── test_in_theaters.yaml
├── tests
│ └── test_in_theaters.py
└── utils
└── commlib.py
這樣,我們修改測試指令碼,就修改 test_in_theaters.py,變更測試資料,就修改 test_in_theaters.yaml。但是目前看,感覺好像並沒有真正看到測試資料和指令碼分離的厲害之處,或者更加有價值的地方,那麼我們接著往下看。
06 — 引數化
上面我們將測試資料和測試指令碼相分離,如果要為測試用例新增更多的測試資料,往 tests 陣列中新增更多的同樣格式的測試資料即可。這個過程叫作引數化。
引數化的意思是對同一個介面,使用多種不同的輸入對其進行測試,以驗證是否每一組輸入引數都能得到預期結果。Pytest 提供了 pytest.mark.paramtrize 這種方式來進行引數化,我們先看下官方網站提供的介紹 pytest.mark.paramtrize 用法的例子:
# content of tests/test_time.py
import pytest
from datetime import datetime, timedelta
testdata = [
(datetime(2001, 12, 12), datetime(2001, 12, 11), timedelta(1)),
(datetime(2001, 12, 11), datetime(2001, 12, 12), timedelta(-1)),
]
@user1ize("a,b,expected", testdata)
def test_timedistance_v0(a, b, expected):
diff = a - b
assert diff == expected
執行上面的指令碼將會得到下面的輸出,測試方法 test_timedistance_v0 被執行了兩遍,第一遍執行用的測試資料是 testdata 列表中的第一個元組,第二遍執行時用的測試資料是 testdata 列表中的第二個元組。這就是引數化的效果,同一個指令碼可以使用不同的輸入引數執行測試。
============================= test session starts ==============================
platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1 -- /Users/chunming.liu/.local/share/virtualenvs/api_pytest-wCozfXSU/bin/python
cachedir: .pytest_cache
rootdir: /Users/chunming.liu/learn/api_pytest/tests
collecting ... collected 2 items
test_time.py::test_timedistance_v0[a0-b0-expected0] PASSED [ 50%]
test_time.py::test_timedistance_v0[a1-b1-expected1] PASSED [100%]
============================== 2 passed in 0.02s ===============================
照貓畫虎,對我們自己的測試專案中的測試指令碼進行如下修改。
import pytest
import requests
from utils.commlib import get_test_data
cases, list_params = get_test_data("/Users/chunming.liu/learn/api_pytest/data/test_in_theaters.yaml")
class TestInTheaters(object):
@user2ize("case,http,expected", list(list_params), ids=cases)
def test_in_theaters(self, case, http, expected):
host = "http://api.douban.com"
r = requests.request(http["method"],
url=host + http["path"],
headers=http["headers"],
params=http["params"])
response = r.json()
assert response["count"] == expected['response']["count"]
assert response["start"] == expected['response']["start"]
assert response["title"] == expected['response']["title"], "實際的標題是:{}".format(response["title"])
在測試方法上面新增了一個裝飾器@pytest.mark.parametrize,裝飾器會自動對 list(list_params) 解包並賦值給裝飾器的第一引數。裝飾器的第一個引數中逗號分隔的變數可以作為測試方法的引數,在測試方法內就可以直接獲取這些變數的值,利用這些值發起請求和進行斷言。裝飾器還有一個引數叫 ids,這個值作為測試用例的名稱將列印到測試結果中。
在執行修改後的測試指令碼前,我們在測試資料檔案再增加一組測試資料,現在測試資料檔案中,包含了兩組測試資料:
---
tests:
- case: 驗證響應中start和count與請求中的引數一致
http:
method: GET
path: /v2/movie/in_theaters
headers:
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36
params:
apikey: 0df993c66c0c636e29ecbb5344252a4a
start: 0
count: 10
expected:
response:
title: 正在上映的電影-上海
count: 10
start: 0
- case: 驗證響應中title是"正在上映的電影-北京"
http:
method: GET
path: /v2/movie/in_theaters
headers:
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36
params:
apikey: 0df993c66c0c636e29ecbb5344252a4a
start: 1
count: 5
expected:
response:
title: 正在上映的電影-北京
count: 5
start: 1
現在我們執行一下測試指令碼,看看效果:
$ export PYTHONPATH=/Users/chunming.liu/learn/api_pytest
$ py.test tests/test_in_theaters.py
====================================================== test session starts =======================================================
platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: /Users/chunming.liu/learn/api_pytest, inifile: pytest.ini
collected 2 items
tests/test_in_theaters.py F. [100%]
============================================================ FAILURES ============================================================
___________________________________ TestInTheaters.test_in_theaters[驗證響應中start和count與請求中的引數一致] ___________________________________
self = <test_in_theaters.TestInTheaters object at 0x102659510>, case = '驗證響應中start和count與請求中的引數一致'
http = {'headers': {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chr...T', 'params': {'apikey': '0df993c66c0c636e29ecbb5344252a4a', 'count': 10, 'start': 0}, 'path': '/v2/movie/in_theaters'}
expected = {'response': {'count': 10, 'start': 0, 'title': '正在上映的電影-上海'}}
@user4ize("case,http,expected", list(list_params), ids=cases)
def test_in_theaters(self, case, http, expected):
host = "http://api.douban.com"
r = requests.request(http["method"],
url=host + http["path"],
headers=http["headers"],
params=http["params"])
response = r.json()
assert response["count"] == expected['response']["count"]
assert response["start"] == expected['response']["start"]
> assert response["title"] == expected['response']["title"], "實際的標題是:{}".format(response["title"])
E AssertionError: 實際的標題是:正在上映的電影-北京
E assert '正在上映的電影-北京' == '正在上映的電影-上海'
E - 正在上映的電影-上海
E ? ^^
E + 正在上映的電影-北京
E ? ^^
tests/test_in_theaters.py:20: AssertionError
==================================================== short test summary info =====================================================
FAILED tests/test_in_theaters.py::TestInTheaters::test_in_theaters[\u9a8c\u8bc1\u54cd\u5e94\u4e2dstart\u548ccount\u4e0e\u8bf7\u6c42\u4e2d\u7684\u53c2\u6570\u4e00\u81f4]
================================================== 1 failed, 1 passed in 0.69s ===================================================
從結果看,Pytest 收集到了 2 個 items,測試指令碼執行了兩遍,第一遍執行用第一組測試資料,結果是失敗 (F),第二遍執行用第二組測試資料,結果是透過 (.)。執行完成後的 summary info 部分,看到了一些 Unicode 編碼,這裡其實是 ids 的內容,因為是中文,所以預設這裡顯示 Unicode 編碼。為了顯示中文,需要在測試專案的根目錄下建立一個 Pytest 的配置檔案 pytest.ini,在其中新增如下程式碼:
[pytest]
disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True
再次執行測試指令碼,在測試結果的 summary_info 部分,則會顯示正確中文內容了。
FAILED tests/test_in_theaters.py::TestInTheaters::test_in_theaters[驗證響應中start和count與請求中的引數一致] - AssertionError: ...
按照這種引數化的方法,如果想修改或者新增測試資料,只需要修改測試資料檔案即可。
現在,自動化測試專案的目錄結構應該是如下這樣:
$ tree
.
├── Pipfile
├── Pipfile.lock
├── config
├── data
│ └── test_in_theaters.yaml
├── pytest.ini
├── tests
│ ├── test_in_theaters.py
│ └── test_time.py
└── utils
└── commlib.py
4 directories, 7 files
07 — 測試配置管理
06 小節的自動化測試程式碼中,host 是寫在測試指令碼中的,這種硬編碼方式顯然是不合適的。這個 host 在不同的測試指令碼都會用到,應該放到一個公共的地方來維護。如果需要對其進行修改,那麼只需要修改一個地方就可以了。根據我的實踐經驗,將其放到 config 資料夾中,是比較好的。
除了 host 外,其他與測試環境相關的配置資訊也可以放到 config 資料夾中,比如資料庫資訊、kafka 連線資訊等,以及與測試環境相關的基礎測試資料,比如測試賬號。很多時候,我們會有不同的測試環境,比如 dev 環境、test 環境、stg 環境、prod 環境等。我們可以在 config 資料夾下面建立子目錄來區分不同的測試環境。因此 config 資料夾,應該是類似這樣的結構:
├── config
│ ├── prod
│ │ └── config.yaml
│ └── test
│ └── config.yaml
在 config.yaml 中存放不同環境的配置資訊,以前面的例子為例,應該是這樣:
host:
douban: http://api.douban.com
將測試配置資訊從指令碼中拆分出來,就需要有一種機制將其讀取到,才能在測試指令碼中使用。Pytest 提供了 fixture 機制,透過它可以在測試執行前執行一些操作,在這裡我們利用 fixture 提前讀取到配置資訊。我們先對官方文件上的例子稍加修改,來介紹 fixture 的使用。請看下面的程式碼:
import pytest
@pytest.fixture
def smtp_connection():
import smtplib
connection = smtplib.SMTP_SSL("smtp.163.com", 465, timeout=5)
yield connection
print("teardown smtp")
connection.close()
def test_ehlo(smtp_connection):
response, msg = smtp_connection.ehlo()
assert response == 250
assert 0
這段程式碼中,smtp_connection 被裝飾器@pytest.fixture裝飾,表明它是一個 fixture 函式。這個函式的功能是連線 163 郵箱伺服器,返回一個連線物件。當 test_ehlo 的最後一次測試執行完成後,執行 print("teardown smtp") 和 connection.close() 斷開 smtp 連線。
fixture 函式名可以作為測試方法 test_ehlo 的引數,在測試方法內部,使用 fixture 函式名這個變數,就相當於是在使用 fixture 函式的返回值。
回到我們讀取測試配置資訊的需求上,在自動化測試專案 tests/目錄中建立一個檔案 conftest.py,定義一個 fixture 函式 env:
@pytest.fixture(scope="session")
def env(request):
config_path = os.path.join(request.config.rootdir,
"config",
"test",
"config.yaml")
with open(config_path) as f:
env_config = yaml.load(f.read(), Loader=yaml.SafeLoader)
return env_config
conftest.py 檔案是一個 plugin 檔案,裡面可以實現 Pytest 提供的 Hook 函式或者自定義的 fixture 函式,這些函式只在 conftest.py 所在目錄及其子目錄中生效。scope="session"表示這個 fixture 函式的作用域是 session 級別的,在整個測試活動中開始前執行,並且只會被執行一次。除了 session 級別的 fixture 函式,還有 function 級別、class 級別等。
env 函式中有一個引數 request,其實 request 也是一個 fixture 函式。在這裡用到了它的 request.config.rootdir 屬性,這個屬性表示的是 pytest.ini 這個配置檔案所在的目錄,因為我們的測試專案中 pytest.ini 處於專案的根目錄,所以 config_path 的完整路徑就是:
/Users/chunming.liu/learn/api_pytest/config/test/config.yaml
將 env 作為引數傳入測試方法 test_in_theaters,將測試方法內的 host 改為 env["host"]["douban"]:
class TestInTheaters(object):
@user8ize("case,http,expected", list(list_params), ids=cases)
def test_in_theaters(self, env, case, http, expected):
r = requests.request(http["method"],
url=env["host"]["douban"] + http["path"],
headers=http["headers"],
params=http["params"])
response = r.json()
這樣就達到了測試配置檔案與測試指令碼相互分離的效果,如果需要修改 host,只需要修改配置檔案即可,測試指令碼檔案就不用修改了。修改完成後執行測試的方法不變。
上面的 env 函式實現中,有點點小缺憾,就是讀取的配置檔案是固定的,讀取的都是 test 環境的配置資訊,我們希望在執行測試時,透過命令列選項,可指定讀取哪個環境的配置,以便在不同的測試環境下開展測試。Pytest 提供了一個叫作 pytest_addoption 的 Hook 函式,可以接受命令列選項的引數,寫法如下:
def pytest_addoption(parser):
parser.addoption("--env",
action="store",
dest="environment",
default="test",
help="environment: test or prod")
pytest_addoption 的含義是,接收命令列選項--env 選項的值,存到 environment 變數中,如果不指定命令列選項,environment 變數預設值是 test。將上面程式碼也放入 conftest.py 中,並修改 env 函式,將 os.path.join 中的"test"替換為 request.config.getoption("environment"),這樣就可以透過命令列選項來控制讀取的配置檔案了。比如執行 test 環境的測試,可以指定--env test:
$ py.test --env test tests/test_in_theaters.py
如果不想每次都在命令列上指定--env,還可以將其放入 pyest.ini 中:
[pytest]
addopts = --env prod
命令列上的引數會覆蓋 pyest.ini 裡面的引數。
08 — 測試的準備與收尾
很多時候,我們需要在測試用例執行前做資料庫連線的準備,做測試資料的準備,測試執行後斷開資料庫連線,清理測試髒資料這些工作。透過 07 小節大家對於透過 env 這個 fixture 函式,如何在測試開始前的開展準備工作有所瞭解,本小節將介紹更多內容。
@pytest.fixture函式的 scope 可能的取值有 function,class,module,package 或 session。他們的具體含義如下:
- function,表示 fixture 函式在測試方法執行前和執行後執行一次。
- class,表示 fixture 函式在測試類執行前和執行後執行一次。
- module,表示 fixture 函式在測試指令碼執行前和執行後執行一次。
- package,表示 fixture 函式在測試包(資料夾)中第一個測試用例執行前和最後一個測試用例執行後執行一次。
- session,表示所有測試的最開始和測試結束後執行一次。
通常,資料庫連線和斷開、測試配置檔案的讀取等工作,是需要放到 session 級別的 fixture 函式中,因為這些操作針對整個測試活動只需要做一次。而針對測試資料的準備,通常是 function 級別或者 class 級別的,因為測試資料針對不同的測試方法或者測試類往往都不相同。
在 TestInTheaters 測試類中,模擬一個準備和清理測試資料的 fixture 函式 preparation,scope 設定為 function:
@pytest.fixture(scope="function")
def preparation(self):
print("在資料庫中準備測試資料")
test_data = "在資料庫中準備測試資料"
yield test_data
print("清理測試資料")
在測試方法中,將 preparation 作為引數,透過下面的命令執行測試:
$ pipenv py.test -s -q --tb=no tests/test_in_theaters.py
====================================================== test session starts =======================================================
platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: /Users/chunming.liu/learn/api_pytest, inifile: pytest.ini
collected 2 items
tests/test_in_theaters.py 在資料庫中準備測試資料
F清理測試資料
在資料庫中準備測試資料
.清理測試資料
==================================================== short test summary info =====================================================
FAILED tests/test_in_theaters.py::TestInTheaters::test_in_theaters[驗證響應中start和count與請求中的引數一致] - AssertionError: ...
================================================== 1 failed, 1 passed in 0.81s ===================================================
透過輸出可以看到在每一條測試用例執行前後,各執行了一次 “在資料庫中準備測試資料” 和 “清理測試資料”。如果 scope 的值改為 class,執行測試用例的輸出資訊將是下面這樣:
tests/test_in_theaters.py 在資料庫中準備測試資料
F.清理測試資料
在測試類執行前後各執行一次 “在資料庫中準備測試資料” 和 “清理測試資料”。
09 — 標記與分組
透過 pytest.mark 可以給測試用例打上標記,常見的應用場景是:針對某些還未實現的功能,將測試用例主動跳過不執行。或者在某些條件下,測試用例跳過不執行。還有可以主動將測試用例標記為失敗等等。針對三個場景,pytest 提供了內建的標籤,我們透過具體程式碼來看一下:
import sys
import pytest
class TestMarks(object):
@pytest.mark.skip(reason="not implementation")
def test_the_unknown(self):
"""
跳過不執行,因為被測邏輯還沒有被實現
"""
assert 0
@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
def test_skipif(self):
"""
低於python3.7版本不執行這條測試用例
:return:
"""
assert 1
@pytest.mark.xfail
def test_xfail(self):
"""
Indicate that you expect it to fail
這條用例失敗時,測試結果被標記為xfail(expected to fail),並且不列印錯誤資訊。
這條用例執行成功時,測試結果被標記為xpassed(unexpectedly passing)
"""
assert 0
@pytest.mark.xfail(run=False)
def test_xfail_not_run(self):
"""
run=False表示這條用例不用執行
"""
assert 0
下面來執行這個測試:
$ py.test -s -q --tb=no tests/test_marks.py
====================================================== test session starts =======================================================
platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: /Users/chunming.liu/learn/api_pytest, inifile: pytest.ini
collected 4 items
tests/test_marks.py s.xx
============================================ 1 passed, 1 skipped, 2 xfailed in 0.06s =============================================
從結果中可以看到,第一條測試用例 skipped 了,第二條測試用例 passed 了,第三條和第四條測試用例 xfailed 了。
除了內建的標籤,還可以自定義標籤並加到測試方法上:
@pytest.mark.slow
def test_slow(self):
"""
自定義標籤
"""
assert 0
這樣就可以透過-m 過濾或者反過濾,比如只執行被標記為 slow 的測試用例:
$ py.test -s -q --tb=no -m "slow" tests/test_marks.py
$ py.test -s -q --tb=no -m "not slow" tests/test_marks.py
對於自定義標籤,為了避免出現 PytestUnknownMarkWarning,最好在 pytest.ini 中註冊一下:
[pytest]
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
10 — 併發執行
如果自動化測試用例數量成千上萬,那麼併發執行它們是個很好的主意,可以加快整體測試用例的執行時間。
pyest 有一個外掛 pytest-xdist 可以做到併發執行,安裝之後,執行測試用例透過執行-n 引數可以指定併發度,透過 auto 引數自動匹配 CPU 數量作為併發度。併發執行本文的所有測試用例:
$ py.test -s -q --tb=no -n auto tests/
====================================================== test session starts =======================================================
platform darwin -- Python 3.7.7, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: /Users/chunming.liu/learn/api_pytest, inifile: pytest.ini
plugins: xdist-1.31.0, forked-1.1.3
gw0 [10] / gw1 [10] / gw2 [10] / gw3 [10] / gw4 [10] / gw5 [10] / gw6 [10] / gw7 [10]
s.FxxF..F.
==================================================== short test summary info =====================================================
FAILED tests/test_marks.py::TestMarks::test_slow - assert 0
FAILED tests/test_smtpsimple.py::test_ehlo - assert 0
FAILED tests/test_in_theaters.py::TestInTheaters::test_in_theaters[驗證響應中start和count與請求中的引數一致] - AssertionError: ...
======================================= 3 failed, 4 passed, 1 skipped, 2 xfailed in 1.91s ========================================
可以非常直觀的感受到,併發執行比順序執行快得多。但是併發執行需要注意的是,不同的測試用例之間不要有測試資料的相互干擾,最好不同的測試用例使用不同的測試資料。
這裡提一下,pytest 生態中,有很多第三方外掛很好用,更多的外掛可以在這裡https://pypi.org/search/?q=pytest-檢視和搜尋,當然我們也可以開發自己的外掛。
11 — 測試報告
Pytest 可以方便的生成測試報告,透過指定--junitxml 引數可以生成 XML 格式的測試報告,junitxml 是一種非常通用的標準的測試報告格式,可以用來與持續整合工具等很多工具整合:
$ py.test -s -q --junitxml=./report.xml tests/
現在應用更加廣泛的測試報告是 Allure,可以方便的與 Pytest 整合,大家可以參考我的另外一篇公眾號文章《用 Pytest+Allure 生成漂亮的 HTML 圖形化測試報告》。
12 — 總結
本文章以實際專案出發,介紹瞭如何編寫測試用例、如何引數化、如何進行測試配置管理、如何進行測試的準備和清理,如何進行併發測試並生成報告。根據本文的介紹,你能夠逐步搭建起一套完整的測試專案。
本文並沒有對 Pytest 的細節和比較高階的內容做充分介紹,以後再進行專題介紹,這篇文章主要目的是讓大家能夠將 Pytest 用起來。更高階的內容,公眾號後續文章還將繼續對其進行介紹。至此,我們的自動化測試專案完整目錄結構如下:
$ tree
.
├── Pipfile
├── Pipfile.lock
├── config
│ ├── prod
│ │ └── config.yaml
│ └── test
│ └── config.yaml
├── data
│ └── test_in_theaters.yaml
├── pytest.ini
├── tests
│ ├── conftest.py
│ ├── test_in_theaters.py
│ ├── test_marks.py
│ ├── test_smtpsimple.py
│ └── test_time.py
└── utils
└── commlib.py
6 directories, 12 files
參考資料
[1] https://docs.pytest.org/en/latest/
[2] https://www.guru99.com/pytest-tutorial.html
相關文章
- 基於Pytest豆瓣自動化測試【1】
- Python自動化測試框架-pytestPython框架
- Python測試框架pytest入門基礎Python框架
- 測試開發之自動化篇-自動化測試框架設計框架
- 基於postman的api自動化測試實踐PostmanAPI
- 基於 Pytest+Requests+Allure 實現介面自動化測試
- 基於AI的移動端自動化測試框架的設計與實踐AI框架
- pytest_BDD + allure 自動化測試框架框架
- 前端開發:基於cypress的自動化實踐前端
- python自動化測試框架pytest和unittest區別!!!Python框架
- 【自動化測試入門】自動化測試思維
- Python自動化測試框架有哪些?Python入門!Python框架
- 基於Selenium+Python的web自動化測試框架PythonWeb框架
- 一種基於 cypress 的 UI 自動化測試框架UI框架
- PHP 開發入門自動化測試歷程(三)PHP
- PHP 開發入門自動化測試歷程(一)PHP
- PHP 開發入門自動化測試歷程(二)PHP
- 淺談自動化測試框架開發框架
- 【入門必備】超實用的五種python自動化測試框架!Python框架
- 一篇文章帶你瞭解Python常用自動化測試框架——PytestPython框架
- 基於 MVP 的 Android 元件化開發框架實踐MVPAndroid元件化框架
- 【必看】Python自動化測試框架,Python入門知識!Python框架
- 自動化測試的最佳實踐
- 前端自動化測試入門前端
- API自動化測試實踐API
- 介面自動化測試世界裡的“身份證”—測試工具Jmeter實踐篇JMeter
- 自動化測試工具Cucumber的簡單介紹,入門篇!
- python+pytest介面自動化(1)-介面測試基礎Python
- Jest前端自動化測試入門前端
- 自動化測試框架選型和落地實踐路徑框架
- 基於 Htte 的 API 自動化測試API
- HTTP框架Hertz實踐入門:效能測試指南HTTP框架
- 測試開發之自動化篇-Appium指令碼開發APP指令碼
- UI自動化測試工程實踐UI
- 前端自動化混沌測試實踐前端
- 自動化測試實踐總結
- 自動化測試框架框架
- Docker與自動化測試及其測試實踐Docker