與最佳實踐)
寫測試代碼這件事我折騰了快十年從最早手寫腳本到unittest再到Pytest最大的感受就一句話測試代碼寫得好不好不取決于你寫了多少用例而取決于整套測試跑起來是否“舒坦”。Pytest就是那種能讓你從“應(yīng)付差事”變成“愿意維護”的框架。這篇東西不是官方文檔的復(fù)述是我自己從unittest痛苦遷移過來的實操記錄覆蓋安裝、工程結(jié)構(gòu)、fixture、參數(shù)化、報告和排坑適合剛接觸自動化測試的新手也適合已經(jīng)在用unittest但想換一套更順手工具的人。很多朋友第一次接觸Pytest都是從“pytest比unittest好用”這句話開始的但到底好在哪里、怎么用才算用得優(yōu)雅很少有文章講透。這篇我盡量用實際場景說話把踩過的坑和驗證過的寫法都攤開來聊。1. 為什么是Pytest先從unittest的痛點說起1.1 unittest讓人最難受的幾個地方如果你是從unittest起步的下面這些場景你應(yīng)該不陌生。首先是那套固定的類繼承測試用例必須寫在unittest.TestCase的子類里方法名必須以test開頭想打破這個套路就得翻官方文檔去折騰。類本身沒問題問題在于它把所有東西都綁死在一套嚴(yán)格的OOP范式里寫簡單用例時感覺冗余寫復(fù)雜用例時又覺得不夠靈活。更難受的是前置條件和清理邏輯。假設(shè)我有一批需要登錄態(tài)的接口測試用unittest寫我得在setUp里做登錄、初始化數(shù)據(jù)、拼接請求頭然后在每個用例里重復(fù)調(diào)用到了tearDown還得記得清理測試數(shù)據(jù)。用例少的時候還好一旦用例超過幾十條setUp和tearDown就開始各種套娃一個類里塞滿了和業(yè)務(wù)驗證無關(guān)的準(zhǔn)備工作。最經(jīng)典的痛點是一個用例可能需要多個不同的前置環(huán)境但unittest的setUp只能寫一套想根據(jù)不同場景切換就得把類拆得稀碎。還有斷言。unittest自帶一整套assertEqual、assertIn、assertTrue這種API問題不在功能而在失敗時的可讀性。我見過太多同事盯著AssertionError: False is not true發(fā)呆根本不知道到底是哪一步出了問題只能靠print大法一條條去追。這個體驗怎么說呢就好比你跟朋友約好了見面地點對方只回復(fù)你一句“不對”你根本不知道是地址錯了還是時間錯了。1.2 Pytest的核心設(shè)計思路Pytest解決這些問題的思路很直接別搞那么多條條框框讓Python自己的語法來干活。測試函數(shù)就是普通函數(shù)只要文件名以test_開頭或_test結(jié)尾、函數(shù)名以test開頭運行pytest命令時就會被自動發(fā)現(xiàn)。不再需要繼承任何基類不用記住幾十個斷言API的名字。斷言這塊更是把“少即是多”做到了極致。Pytest直接復(fù)用Python原生的assert語句比如assert user[name] 張三斷言失敗時它會自動打印出表達式兩邊實際的值。我不用再猜一眼就能看到左邊是李四右邊是張三差異在哪清清楚楚。就這個能力遷移之后我整個排查測試失敗的時間少了一半不止。fixture機制算是Pytest最核心的設(shè)計了。簡單理解fixture就是一個帶裝飾器的函數(shù)負(fù)責(zé)準(zhǔn)備數(shù)據(jù)和環(huán)境測試函數(shù)用參數(shù)名直接聲明需要哪些fixturePytest自動把返回值注入進去。它把unittest的setUp/tearDown拆成了更靈活、更細(xì)粒度的模塊可以單獨定義登錄態(tài)、單獨定義數(shù)據(jù)庫數(shù)據(jù)、單獨定義臨時文件然后隨意組合。1.3 什么場景下我仍然不推薦Pytest不是所有項目都適合無腦上Pytest。有兩個反例我實際遇到過。一個是極簡場景總共就二三十個用例純本地腳本、不接CI、幾乎沒有復(fù)用需求那用unittest也一樣能跑多裝一個依賴反而增加環(huán)境負(fù)擔(dān)。另一個是對測試代碼有極端定制需求的場景比如你要強制所有用例都遵循完全統(tǒng)一的OOP繼承鏈團隊又對夾具注入這套機制非常不熟悉那遷移的陣痛期會比想象中長。還有一個情況要特別提醒Pytest雖然對新手友好但它內(nèi)部其實很靈活同一個需求有七八種寫法。靈活意味著團隊容易寫出風(fēng)格迥異的代碼反而需要你提前定好規(guī)范。我見過一個項目fixture有四種定義方式、兩種命名風(fēng)格跑到最后測試代碼的可讀性比被測代碼還差。工具解決不了人的問題這點后面會細(xì)說。2. 環(huán)境準(zhǔn)備安裝、工程目錄與PyCharm配置2.1 用pip安裝pytest初學(xué)者最容易卡住的反而是環(huán)境的整潔度。我強烈建議你在虛擬環(huán)境里操作別圖省事直接裝到系統(tǒng)Python里。第一步創(chuàng)建虛擬環(huán)境python -m venv venv然后激活它Windows下執(zhí)行venv\Scripts\activatemacOS/Linux下執(zhí)行source venv/bin/activate。接著安裝pip install pytest裝完驗證一下pytest --version能輸出版本號就說明成了。實際項目里我更建議直接用一個requirements.txt把測試相關(guān)依賴鎖住后續(xù)同事拉代碼只需要一條命令就能復(fù)現(xiàn)環(huán)境pip install -r requirements.txt文件內(nèi)容可以寫成這樣pytest7.4.3 pytest-html4.1.0 pytest-xdist3.3.1 pytest-cov4.1.0 requests2.31.0鎖版本號這件事很多人嫌麻煩但測試環(huán)境的穩(wěn)定性恰恰就靠這個。你永遠不知道同事機器上那個“稍新一點的pytest”會不會因為某個行為變化就讓整個套件紅掉。2.2 標(biāo)準(zhǔn)的工程目錄長什么樣Pytest對目錄結(jié)構(gòu)沒有硬性要求但用多了你會發(fā)現(xiàn)一套好用的默認(rèn)約定。我目前比較推薦的工程結(jié)構(gòu)是這樣的project/ ├── src/ │ ├── __init__.py │ ├── api_client.py │ └── models.py ├── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── test_user_api.py │ ├── test_order_flow.py │ └── test_cart.py ├── requirements.txt └── pytest.ini關(guān)鍵點在tests目錄下的conftest.py。這個文件是Pytest的“全局配置中樞”里面定義的fixture可以被tests下所有測試文件直接使用不需要任何import。這一點解決了很多人在unittest里糾纏不清的公共依賴問題——登錄狀態(tài)、數(shù)據(jù)庫連接、臨時目錄統(tǒng)一寫在conftest.py里所有測試文件按需聲明即可。pytest.ini是配置文件我通常這樣寫[pytest] testpaths tests python_files test_*.py python_classes Test* python_functions test_* addopts -v -stestpaths指定了pytest運行時要搜索的測試目錄避免在無關(guān)代碼里浪費時間。addopts里的-v是詳細(xì)輸出-s是讓print內(nèi)容直接顯示。調(diào)試階段這倆我必開不然print的內(nèi)容會被Pytest的捕獲機制吞掉很容易產(chǎn)生“代碼明明執(zhí)行了但啥都沒打印”的錯覺。2.3 PyCharm里怎么配置用PyCharm的話有幾點值得先設(shè)置好。打開Settings找到Project Interpreter確認(rèn)解釋器指向的是剛才那個虛擬環(huán)境里的Python而不是系統(tǒng)自帶的。然后在Tools下面有個Python Integrated Tools把Default test runner設(shè)為pytest。這兩步做完你在測試文件里點右鍵就能直接選Run pytest跑完之后點左側(cè)紅黃綠色的小條就能看到失敗詳情。PyCharm對Pytest的fixture跳轉(zhuǎn)也支持得很好按住Ctrl點函數(shù)參數(shù)里的fixture名能直接跳到定義位置。這點對讀別人寫的測試代碼特別有用不然光靠人肉找fixture定義會瘋掉。項目跑起來之后那個Run窗口里顯示的日志如果太亂可以在pytest.ini里調(diào)整日志級別加上log_cli true和log_cli_level INFO讓關(guān)鍵信息透出來。3. 核心特性實操斷言、fixture與參數(shù)化3.1 斷言就應(yīng)該用原生assertPytest把斷言簡化到接近“說人話”??磧蓚€例子就明白了def test_user_profile(): user get_user_by_id(1001) assert user is not None assert user[nickname] 老王 assert len(user[tags]) 3失敗了Pytest直接告訴你assert user[nickname] 老王 E AssertionError: assert 老李 老王你不需要任何額外解析錯誤信息自帶現(xiàn)場。更絕的是對集合和字典的斷言def test_permission(): perms get_user_permissions(1001) assert {read, write} set(perms)如果失敗它能直接列出你期望的集合里哪些元素不在實際結(jié)果里。這種差距用過就回不去了。斷言異常的手段也要會用比如驗證一個接口在參數(shù)錯誤時拋出ValueErrorimport pytest def test_invalid_param_raises(): with pytest.raises(ValueError, matchid不能為空): create_order(user_idNone)match參數(shù)不是必須的但我建議能寫就寫它能把“確實拋了異常但拋的卻是另一個異常”的情況暴露出來。3.2 fixture測試前置與清理的正確姿勢fixture的核心在“聲明式”這三個字上。比如我要準(zhǔn)備一個帶登錄態(tài)的API客戶端最基礎(chǔ)的寫法是這樣import pytest import requests pytest.fixture def auth_client(): token login(test_user, password) client requests.Session() client.headers.update({Authorization: fBearer {token}}) return client def test_get_orders(auth_client): resp auth_client.get(/api/orders) assert resp.status_code 200測試函數(shù)加一個參數(shù)auth_clientPytest就自動幫你調(diào)好。如果同時需要多個前置直接加多個參數(shù)就行def test_create_order(auth_client, test_db): # auth_client負(fù)責(zé)登錄test_db負(fù)責(zé)準(zhǔn)備數(shù)據(jù)庫記錄 ...test_db是另一個fixture負(fù)責(zé)建表、插入測試數(shù)據(jù)用例跑完還能自動清理。模塊在這里被拆得很干凈互相之間不耦合。fixture的清理邏輯放在yield后面pytest.fixture def temp_file(tmp_path): path tmp_path / data.txt path.write_text(hello, encodingutf-8) yield path # 到這里執(zhí)行清理邏輯 tmp_path.unlink(missing_okTrue)這個yield把“準(zhǔn)備”和“收尾”清晰地分隔開了比unittest的tearDown更直觀。你在use fixture之后寫清理代碼它照樣會在用例結(jié)束時執(zhí)行哪怕用例中途拋異常也不會跳過。fixture還能控制作用域默認(rèn)是function也就是每個用例跑一遍。但有些資源完全不需要來回重復(fù)創(chuàng)建比如數(shù)據(jù)庫連接池、配置文件對象用pytest.fixture(scopemodule)讓整個模塊只創(chuàng)建一次速度能差出好幾倍。涉及到“模塊級只創(chuàng)建一次”的東西我建議順手把權(quán)限檢查也寫在fixture里避免被誤用。不過fixture有個讓很多新手困惑的地方默認(rèn)情況下一個測試函數(shù)如果聲明了fixture參數(shù)就必須依次執(zhí)行完fixture才能進入測試體。如果你確實想讓某個fixture自動生效、又不想在函數(shù)簽名里聲明可以用autouseTruepytest.fixture(autouseTrue) def enable_logging(): logging.basicConfig(levellogging.INFO)這種情況適合全局都要生效的橫切邏輯比如測試數(shù)據(jù)庫事務(wù)回滾。但autouse別濫用否則所有用例都被強制綁定某些依賴別人看代碼時很難一眼看出來為什么這個fixture生效了。3.3 參數(shù)化一份用例跑遍所有場景參數(shù)化是Pytest里最能提升“含金量”的特性。過去用unittest如果想對同一個接口用十組不同參數(shù)做驗證要么寫十個方法要么用循環(huán)包一層。但循環(huán)包一層的后果就是如果第三組數(shù)據(jù)失敗了你只能看到“第3次迭代失敗”具體是哪組參數(shù)、為什么失敗還得自己拼回去。Pytest的pytest.mark.parametrize把數(shù)據(jù)和用例徹底分開import pytest pytest.mark.parametrize( price, discount, expected, [ (100, 0.9, 90), (200, 0.5, 100), (50, 0.2, 10), (0, 0.1, 0), ], ) def test_calculate_discount(price, discount, expected): assert price * discount expected運行之后Pytest會把每個參數(shù)組合當(dāng)成一條獨立用例輸出類似test_calculate_discount[100-0.9-90]這樣的名字。哪條掛了、掛在哪組數(shù)據(jù)上一眼就能定位。參數(shù)化還有兩個進階技巧。一個是ids參數(shù)給每組數(shù)據(jù)起別名pytest.mark.parametrize( price, discount, expected, [ (100, 0.9, 90), (200, 0.5, 100), ], ids[九折, 半價], ) def test_calculate_discount(price, discount, expected): ...這樣生成報告的時候用例名不再是那串?dāng)?shù)字而是“九折”“半價”可讀性直接拉滿。另一個技巧是參數(shù)化里放fixture的返回值但那是比較高級的玩法先把基礎(chǔ)搞清楚再說。3.4 標(biāo)記和條件跳過實際項目里總有幾種用例不能真跑依賴第三方環(huán)境但今天環(huán)境掛了、功能已知有bug且開發(fā)還沒修、在本地調(diào)試時不想跑慢速用例。Pytest用marker處理這些場景最簡單的是無條件跳過pytest.mark.skip(reason上游API暫未上線) def test_payment_notify(): ...如果想“跑一下看看知道失敗但我不讓它阻塞整個套件”用xfailpytest.mark.xfail(reason已知缺陷等待修復(fù)) def test_legacy_parsing(): ...xfail的妙處是如果某天這個用例突然過了Pytest會報告成XPASS這就是一個明確的信號開發(fā)把bug修了這時候你可以把標(biāo)記撤掉了。這個機制能幫你維護一個“已知問題清單”比在Excel里記錄靠譜得多。自定義marker也很實用。比如給接口測試、UI測試、冒煙測試打上不同標(biāo)簽然后在pytest.ini里統(tǒng)一注冊再配合-m smoke來單獨執(zhí)行冒煙用例集業(yè)務(wù)上靈活很多。4. 插件生態(tài)讓測試報告和效率起飛4.1 pytest-html零成本獲得一份網(wǎng)頁報告跑完測試之后黑壓壓的終端輸出不是不能看但要是想發(fā)給團隊其他人或者沉淀歷史記錄一份HTML報告會體面很多。pytest-html用起來幾乎不需要學(xué)習(xí)成本pip install pytest-html pytest --htmlreport.html --self-contained-html--self-contained-html參數(shù)的意思是把樣式和腳本全部內(nèi)嵌進一個文件方便單文件轉(zhuǎn)發(fā)對方不需要聯(lián)網(wǎng)也能正常打開。報告里有每個用例的耗時、狀態(tài)、失敗時的堆棧日常足夠用了。4.2 allure報告當(dāng)團隊需要更多細(xì)節(jié)時熱詞里頻繁出現(xiàn)的pytest allure報告是另一個重量級選手。Allure的優(yōu)勢在于它能把每個步驟、附帶的請求參數(shù)、日志、截圖都結(jié)構(gòu)化地整合進報告里適合做接口測試或UI測試的團隊。用法也不復(fù)雜pip install allure-pytest pytest --alluredirallure-results allure serve allure-results--alluredir先輸出原始數(shù)據(jù)再用allure serve起一個本地Web服務(wù)展示報告。相比pytest-htmlAllure能更直觀地展示歷史趨勢和分類統(tǒng)計長期維護的時候優(yōu)勢特別明顯。它的學(xué)習(xí)曲線主要在理解allure.feature、allure.story、allure.step這些裝飾器的組織方式建議從一個小模塊開始試點別一上來全量鋪。4.3 pytest-xdist多核并行跑測試測試套件大了以后串行執(zhí)行的耗時是團隊最直觀的痛點。pytest-xdist提供了無腦級別的加速pip install pytest-xdist pytest -n 4-n 4表示用4個并發(fā)worker。我實測下來一個400條用例的項目從6分鐘壓縮到2分鐘出頭效果非常顯著。但注意一點并行跑的前提是測試之間沒有共享可變狀態(tài)。如果你某些用例依賴同一個文件、同一個數(shù)據(jù)庫記錄并發(fā)時大概率隨機性的失敗。這類用例要先通過fixture的scope和tmp_path保證數(shù)據(jù)隔離否則加了并發(fā)會得到一堆莫名其妙的報錯半夜還得被報警吵醒。4.4 pytest-cov覆蓋率到底夠不夠覆蓋率這個問題經(jīng)常被誤解但還是要引入pytest-cov來度量哪怕只當(dāng)參考。安裝后運行pytest --covsrc --cov-reporthtml會在命令行給出整體覆蓋率并生成一個htmlcov目錄點開能看到每個文件里哪些行被覆蓋了、哪些漏掉了。覆蓋率數(shù)字高不代表測試寫得好但覆蓋率低一定說明有大量路徑?jīng)]測到。我通常把核心模塊的覆蓋率目標(biāo)定在80%以上但更關(guān)注的是“關(guān)鍵分支和異常路徑”有沒有覆蓋到而不是糾結(jié)那幾行打印日志沒跑到。4.5 結(jié)合requests做接口測試的場景最后接上很多人的實際場景用Pytest做接口自動化。其實不需要什么特別復(fù)雜的庫requests加上Pytest本身就夠用再加一個pytest.ini里統(tǒng)一配置BASE_URL接口測試就能跑得明明白白。常見的做法是fixture里創(chuàng)建session、維護token然后通過參數(shù)化去覆蓋各種入?yún)⒔M合。UI自動化那邊Pytest和Selenium、Playwright的配合也順理成章因為fixture可以很好地管理瀏覽器實例的啟動和關(guān)閉。熱詞里還有人問pytest pycharm其實就是在IDE里跑這些套件的配置前面已經(jīng)講過了。5. 常見問題與排查技巧5.1 中文路徑與編碼問題Windows環(huán)境下項目路徑帶中文時Pytest偶爾會報編碼相關(guān)的錯誤。這個問題的根源其實是Python默認(rèn)讀取配置和測試文件名時用了系統(tǒng)api而系統(tǒng)和終端編碼不一致。解決辦法是在pytest.ini里加一行[pytest] ...如果還不行檢查系統(tǒng)區(qū)域的UTF-8支持Windows設(shè)置里把“beta版使用Unicode UTF-8提供全球語言支持”打開重啟后再試。我在團隊里遇到過不下三次這種問題解決起來其實就這么簡單。5.2 測試收集不到用例明明寫了test文件剛用Pytest的人最常遇到“明明寫了測試文件運行卻顯示no tests ran”。逐個排查這三件事文件名是否滿足test_*.py模式里面的測試函數(shù)名是否以test開頭pytest.ini里的testpaths是否指向了正確目錄第三種情況是我踩坑最多的。testpaths寫錯之后Pytest會直接忽略你的測試目錄而且不報錯它只會覺得“沒找到用例”。我建議第一輪無論如何先把testpaths刪了測試一下確認(rèn)文件能發(fā)現(xiàn)再慢慢加上配置。5.3 fixture名字沖突問題fixture多了之后conftest.py里同名fixture可能覆蓋另一個文件里的本地同名fixturePytest的fixture解析順序是“最近的優(yōu)先”。這個問題有時會鬼魅地導(dǎo)致“本地定義了fixture但執(zhí)行時用的卻是另一個”。排查方法很簡單給fixture起名時帶上模塊前綴比如api_client改成user_api_client不要用data這種爛大街的名字。也可以用pytest --fixtures命令列出當(dāng)前所有的fixture定義直接看實際生效的是哪個。5.4 斷言失敗的堆??床欢芏嘈律鲜值耐瑢W(xué)看到一大片DAG追蹤就慌了。實際上Pytest在-v模式下失敗信息已經(jīng)通過assert的表達式分析給出了最關(guān)鍵的對比大段堆棧通常只是調(diào)用鏈。記住一條紀(jì)律斷言失敗時先看信息里的assert這一行和下面的對比值不要從頭開始讀堆棧。如果你覺得信息還不夠可以自己在fixture或被測函數(shù)入口打日志配合-s輸出很快就能定位。5.5 運行順序與隨機性問題在unittest里用例的執(zhí)行順序通常按名稱排序Pytest默認(rèn)也是這樣。但很多測試其實不該依賴順序。一旦你依賴“某個用例先跑、某個用例后跑”你就在給自己埋雷。比如某個用例依賴另一個用例寫入的數(shù)據(jù)一旦加并發(fā)或者調(diào)整執(zhí)行順序整套測試就崩。解決方案是把數(shù)據(jù)準(zhǔn)備收斂到fixture里誰需要誰就聲明而不是靠執(zhí)行順序傳遞。在本地調(diào)試時可以用pytest-randomly這類插件打亂順序運行盡早發(fā)現(xiàn)順序依賴。5.6 不要為了復(fù)用而過度設(shè)計最后這條算是我自己的體會。很多人寫測試框架的時候容易上頭一上來就是抽象類、工廠模式、自定義裝飾器連斷言都要封裝一層。結(jié)果測試代碼的復(fù)雜度遠超業(yè)務(wù)代碼用例出了問題時排查成本反而更高。Pytest本身已經(jīng)幫你做好了復(fù)用和擴展的底座你要做的更多是保持簡單。優(yōu)先用內(nèi)置的fixture、參數(shù)化、marker去組織等確確實實出現(xiàn)重復(fù)且固定的需求再去考慮更上層的封裝。我在實際帶項目時有個經(jīng)驗測試代碼的評審標(biāo)準(zhǔn)應(yīng)該和業(yè)務(wù)代碼一樣嚴(yán)格。命名、可讀性、職責(zé)單一都要遵守。你寫在測試?yán)锏拿恳粋€魔法數(shù)字、每一個“暫時這樣”、每一段無注釋的復(fù)雜邏輯都會在三個月后變成你和同事的噩夢。Pytest只是讓這個過程更舒服它替代不了工程紀(jì)律。如果讓我給新手一條最值得的建議那就是先把fixture和參數(shù)化這兩樣?xùn)|西吃透它們能解決你80%的測試組織問題。我見過太多人把精力耗在研究各種花哨插件上最后fixture都沒用明白這是本末倒置。測試的意義在于給你信心讓你敢改業(yè)務(wù)代碼而不是給你一套運行了卻沒人敢依賴的儀式。讓自己的套件始終保持著“跑起來很快、掛了能秒懂、換環(huán)境幾分鐘就能復(fù)現(xiàn)”的狀態(tài)這比任何技術(shù)選型都重要。