研效优化实践:Python单测——从入门到起飞
作者:uniquewang,騰訊安全平臺后臺開發(fā)工程師
福生于微,積微成著,一行代碼的精心調(diào)試,一條指令的細心驗證,一個字節(jié)的研磨優(yōu)化,都是影響企業(yè)研發(fā)效能工程的細節(jié)因素。而單元測試,是指針對軟件中的最小可測試單元的檢查驗證,一個單元測試往往就是一小段代碼。本文基于騰訊安全平臺部的研效優(yōu)化實踐,介紹和總結(jié)公司第三大后端開發(fā)語言 python 的單測編寫方法,面向單測 0 基礎(chǔ)同學(xué),歡迎共同交流探討。
前言
本文面向單測 0 基礎(chǔ)的同學(xué),介紹和總結(jié)python的單測編寫方法。首先會介紹主流的單測框架,重點 pytest。第二部分介紹如何使用 Mock 來輔助實現(xiàn)一些復(fù)雜場景測試,第三部分單測覆蓋率統(tǒng)計。中間穿插借助 IDE 工具來提效的手段
一、python 單測框架
單測框架無外乎封裝了測試相關(guān)的核心能力來輔助我們快速進行單測,例如 java 的junit,golang 的gocover,python 目前的主流單測框架有unittest,nose,pytest
unittest
unittest 是 python 官方標準庫中自帶的單元測試框架,又是也稱為 PyUnit。類似于 JUnit 是 java 語言的標準單元測試框架一樣。unittest 對于 python2.7+,python3 使用方法一致。
基本實例
#?test_str.py import?unittestclass?TestStringMethods(unittest.TestCase):def?setUp(self):#?單測啟動前的準備工作,比如初始化一個mysql連接對象#?為了說明函數(shù)功能,測試的時候沒有CMysql模塊注釋掉或者換做print學(xué)習(xí)self.conn?=?CMysql()def?tearDown(self):#?單測結(jié)束的收尾工作,比如數(shù)據(jù)庫斷開連接回收資源self.conn.disconnect()def?test_upper(self):self.assertEqual('foo'.upper(),?'FOO')def?test_isupper(self):self.assertTrue('FOO'.isupper())self.assertFalse('Foo'.isupper())def?test_split(self):s?=?'hello?world'self.assertEqual(s.split(),?['hello',?'world'])#?check?that?s.split?fails?when?the?separator?is?not?a?stringwith?self.assertRaises(TypeError):s.split(2)if?__name__?==?'__main__':unittest.main()編寫方法
test_str.py,測試文件名約定 test_xxx
import unittest ,python 自帶,無需額外 pip 安裝
class 名字 Test 打頭
類繼承 unittest.TestCase
每個單測方法命名 test_xxx
每個測試的關(guān)鍵是:調(diào)用 assertEqual() 來檢查預(yù)期的輸出;調(diào)用 assertTrue() 或 assertFalse() 來驗證一個條件;調(diào)用 assertRaises() 來驗證拋出了一個特定的異常。使用這些方法而不是 assert 語句是為了讓測試運行者能聚合所有的測試結(jié)果并產(chǎn)生結(jié)果報告。注意這些方法是 unnitest 模塊的方法,需要使用 self 調(diào)用。
setUp()方法,單測啟動前的準備工作,比如初始化一個 mysql 連接對象
tearDown()方法,單測結(jié)束的收尾工作,比如數(shù)據(jù)庫斷開連接回收資源。setUp 和 tearDown 非常類似于 java 里的切面編程
unittest.main() 提供了一個測試腳本的命令行接口
參數(shù)化
標準庫的 unittest 自身不支持參數(shù)化測試,需要通過第三方庫來支持:parameterized 和 ddt。官方文檔這里完全沒做介紹,暫不深入
執(zhí)行結(jié)果
... ---------------------------------------------------------------------- Ran?3?tests?in?0.001sOKnose
nose 是 Python 的一個第三方單元測試框架。這意味著,如果要使用 nose,需要先顯式安裝它
pip?install?nose一個簡單的 nose 單元測試示例如下:
import?nosedef?test_example?():passif?__name__?==?'__main__':nose.runmodule()需要注意的是,nose 已經(jīng)進入維護模式,最近官方已經(jīng)沒有提交記錄,最近的 relase 是 jun 2,2015
這里由于使用經(jīng)驗有限,也沒有深入去調(diào)研當(dāng)前的一個使用情況,就不做進一步介紹,有興趣自行 google。
pytest
先放官方 slogan
pytest: helps you write better programs
The pytest framework makes it easy to write small tests, yet scales to support complex functional testing for applications and libraries.
pytest 得益于其簡單的實現(xiàn)方案、豐富的參數(shù)化功能、易用的前后置邏輯(固件)特性,以及通用的 mock 功能,目前在是非常火爆的 python 單測框架。
安裝
pytest 是第三方包,使用功能需要提前安裝,支持 python2.7 和 python3.5 及以上
pip?install?pytest測試發(fā)現(xiàn)
pytest 在所選目錄中查找test_*.py或*_test.py文件。在選定的文件中,pytest 在類之外查找?guī)熬Y的測試函數(shù),并在帶前綴的測試類中查找?guī)熬Y的測試方法(無__init__()方法)。
基本實例
直接放官網(wǎng)的幾個例子感受一下
#?content?of?test_sample1.py def?inc(x):return?x?+?1def?test_answer():assert?inc(3)?==?5 #?content?of?test_class.py import?pytestdef?f():raise?SystemExit(1)class?TestClass:def?test_one(self):x?=?"this"assert?"h"?in?xdef?test_two(self):x?=?"hello"assert?hasattr(x,?"check")def?test_mytest(self):with?pytest.raises(SystemExit):f()運行
$?pytest無參數(shù),運行當(dāng)前目錄及子目錄下所有的測試文件,發(fā)現(xiàn)規(guī)則見上
$?pytest?test_sample1.py運行指定測試文件
$?pytest?test_class.py::TestClass [root?test]#?pytest?test_class.py::TestClass ============================================?test?session?starts?============================================= platform?linux?--?Python?3.6.8,?pytest-6.2.4,?py-1.10.0,?pluggy-0.13.1 rootdir:?/data/ftp/unique/mf_web_proj_v2/test collected?3?itemstest_class.py?.F.??????????????????????????????????????????????????????????????????????????????????????[100%]==================================================?FAILURES?================================================== _____________________________________________?TestClass.test_two?_____________________________________________self?=?<test_class.TestClass?object?at?0x7f55f3e0e518>def?test_two(self):x?=?"hello" >???????assert?hasattr(x,?"check") E???????AssertionError:?assert?False E????????+??where?False?=?hasattr('hello',?'check')test_class.py:13:?AssertionError ==========================================?short?test?summary?info?=========================================== FAILED?test_class.py::TestClass::test_two?-?AssertionError:?assert?False ========================================?1?failed,?2?passed?in?0.03s?=========================================運行指定指定測試類。根據(jù)運行結(jié)果可以看出 test_two 測試方法失敗
$?pytest?test_class.py::TestClass::test_two運行指定測試類下的指定測試方法
跳過指定測試
通過@pytest.mark.skip注解跳過指定的測試,跳過測試的原因比如有當(dāng)前方法已經(jīng)發(fā)現(xiàn)有 bug 還在 fix,測試的數(shù)據(jù)數(shù)據(jù)庫未就緒,環(huán)境不滿足等等
#?content?of?test_skip.py import?pytest @pytest.mark.skip def?test_min():values?=?(2,?3,?1,?4,?6)assert?min(values)?==?1def?test_max():values?=?(2,?3,?1,?4,?6)assert?5?in?values標記函數(shù)
類似于上面的 skip,也可指定其他的標簽,使用 @pytest.mark 在函數(shù)上進行各種標記。比如 fixed,finished 等等你想要的各種標簽。然后在運行的時候可以根據(jù)指定的標簽跑某些標簽的測試方法。
#?test_with_mark.py@pytest.mark.finished def?test_func1():assert?1?==?1@pytest.mark.unfinished def?test_func2():assert?1?!=?1測試時使用-m選擇標記的測試函數(shù)
$?pytest?-m?finished?test_with_mark.py參數(shù)化測試
通過參數(shù)化測試,我們可以向斷言中添加多個值。這個功能使用頻率非常高,我們可以模擬各種正常的、非法的入?yún)ⅰ.?dāng)然很多同學(xué)習(xí)慣直接在函數(shù)內(nèi)部構(gòu)造一個參數(shù)集合,通過 for 循環(huán)挨個測,通過 try/catch 的方式捕獲異常使得所有參數(shù)都跑一遍,,但要分析測試結(jié)果就需要做不少額外的工作。在 pytest 中,我們有更好的解決方法,就是參數(shù)化測試,即每組參數(shù)都獨立執(zhí)行一次測試。使用的工具就是 @pytest.mark.parametrize(argnames, argvalues)。在函數(shù)內(nèi)部的 for 循環(huán)模式,會當(dāng)做一次測試用例,而采用pytest.mark.parametrize方式會產(chǎn)生 N 個測試用例,N=len(argnames)。
#?test_parametrize_sigle.py #?單參數(shù) import?pytest @pytest.mark.parametrize('passwd',['123456','abcdefdfs','as52345fasdf4']) def?test_passwd_length(passwd):assert?len(passwd)?>=?8 #?test_parametrize_multiple.py #?多參數(shù) import?pytest @pytest.mark.parametrize('user,?passwd',[('jack',?'abcdefgh'),('tom',?'a123456a')]) def?test_passwd_md5(user,?passwd):db?=?{'jack':?'e8dc4081b13434b45189a720b77b6818','tom':?'1702a132e769a623c1adb78353fc9503'}import?hashlibassert?hashlib.md5(passwd.encode()).hexdigest()?==?db[user]argnames可以是用逗號分隔的字符串,也可以是列表,比如上面的'user, passwd',或者['user','passwd']
argvalues類型是 List[Tuple]。
fixture
定義
fixture 翻譯過來是固定,固定狀態(tài);固定物;【機械工程】裝置器,工件夾具,直接看官方的解釋更好理解。
In testing, a fixture provides a defined, reliable and consistent context for the tests. This could include environment (for example a database configured with known parameters) or content (such as a dataset).
Fixtures define the steps and data that constitute the arrange phase of a test (see Anatomy of a test). In pytest, they are functions you define that serve this purpose. They can also be used to define a test’s act phase; this is a powerful technique for designing more complex tests.
谷歌翻譯
在測試中,fixture 為測試提供定義的、可靠的和一致的上下文。這可能包括環(huán)境(例如配置了已知參數(shù)的數(shù)據(jù)庫)或內(nèi)容(例如數(shù)據(jù)集)。
Fixtures 定義了構(gòu)成測試編排階段的步驟和數(shù)據(jù)(參見 Anatomy of a test) . 在 pytest 中,它們是您定義的用于此目的的函數(shù)。它們還可用于定義測試的 行為階段;這是設(shè)計更復(fù)雜測試的強大技術(shù)。
總結(jié)下就是使用fixture可以為你的測試用例定義一些可復(fù)用的、一致的功能支持,其中最常見的可能就是數(shù)據(jù)庫的初始連接和最后關(guān)閉操作,測試數(shù)據(jù)集的統(tǒng)一提供接口。功能類似于上面unittest框架的 setup()和 teardown()。同時也是 pytest 更加出眾的地方,包括:
有獨立的命名,并通過聲明它們從測試函數(shù)、模塊、類或整個項目中的使用來激活。
按模塊化的方式實現(xiàn),每個 fixture 都可以互相調(diào)用。
fixture 的范圍從簡單的單元測試到復(fù)雜的功能測試,可以對 fixture 配置參數(shù),或者跨函數(shù) function,類 class,模塊 module 或整個測試 session 范圍。
使用
#?官方Quick?example import?pytestclass?Fruit:def?__init__(self,?name):self.name?=?nameself.cubed?=?Falsedef?cube(self):self.cubed?=?Trueclass?FruitSalad:def?__init__(self,?*fruit_bowl):self.fruit?=?fruit_bowlself._cube_fruit()def?_cube_fruit(self):for?fruit?in?self.fruit:fruit.cube()#?Arrange @pytest.fixture???#1 def?fruit_bowl():return?[Fruit("apple"),?Fruit("banana")]def?test_fruit_salad(fruit_bowl):?#2#?Actfruit_salad?=?FruitSalad(*fruit_bowl)#?Assertassert?all(fruit.cubed?for?fruit?in?fruit_salad.fruit)使用很簡單:
1 通過@pytest.fixture裝飾器裝飾一個函數(shù)
2 直接將 fixture 作為參數(shù)傳給測試用例,這樣就可以做到測試用例只關(guān)心當(dāng)前的測試邏輯,數(shù)據(jù)準備等交給 fixture 來搞定
這是一個通過 fixture 來管理 db 連接的例子,
1 前置準備
2 yield 關(guān)鍵詞將 fixture 分為兩部分,yield 之前的代碼屬于預(yù)處理,會在測試前執(zhí)行;yield 之后的代碼屬于后處理,將在測試完成后執(zhí)行。如果沒有返回給yield即可
3 結(jié)束收尾
4 @pytest.fixture(autouse=True) autouse 關(guān)鍵字告訴框架在跑用例之前自動運行該 fixture
作用域
通過 scope 參數(shù)聲明作用域,可選項有:
function: 函數(shù)級,每個測試函數(shù)都會執(zhí)行一次固件;
class: 類級別,每個測試類執(zhí)行一次,所有方法都可以使用;
module: 模塊級,每個模塊執(zhí)行一次,模塊內(nèi)函數(shù)和方法都可使用;
session: 會話級,一次測試只執(zhí)行一次,所有被找到的函數(shù)和方法都可用。
默認的作用域為 function
管理
可以單獨在每個測試 py 文件中放置需要的 fixture
對于復(fù)雜項目,可以在不同的目錄層級定義 conftest.py,其作用域為其所在的目錄和子目錄。例如上面的fixture/connection,就應(yīng)該放在公用的 conftest.py 中,統(tǒng)一管理測試數(shù)據(jù)的連接。這樣就很好的做到的復(fù)用。
借助 IDE 提效
已 PyCharm 為例介紹,vscode 等 ide 應(yīng)該大同小異
Settings/Preferences | Tools | Python Integrated Tools選擇單測框架
創(chuàng)建目標測試代碼文件 Car.py
測試 break 方法,右鍵或者 Ctrl+Shift+T 選中break(),選擇Go To | Test。當(dāng)然也可以直接直接右鍵一次性為多個方法創(chuàng)建對應(yīng)測試用例
點擊Create New Test...,創(chuàng)建測試文件
2.png完善測試代碼邏輯
3.png點擊運行按鈕,可以選擇運行測試或者調(diào)試測試
4.png運行結(jié)果,4 個測試用例,有 2 個失敗。
二、Mock
上面的介紹的 pytest 框架可以輔助我們解決掉日常工作 70%的單測問題,但是對于一些不容易構(gòu)造/獲取的對象,需要依賴外部其他接口,特定運行環(huán)境等場景,需要借助 Mock 工具來幫我們構(gòu)建全面的單測用例,而不至于卡在某一環(huán)境無法繼續(xù)推進。
舉一個實際工作中分布式任務(wù)下發(fā)的場景,master 節(jié)點需要通過調(diào)用資源管理服務(wù)下的 worker 節(jié)點 cpu 占用率、內(nèi)存占用率等多項資源接口,來評估任務(wù)下發(fā)哪些節(jié)點。但是現(xiàn)在這些資源接口部署在 idc 環(huán)境,或者接口由其他同學(xué)在負責(zé)開發(fā)中,這時我們需要測試調(diào)度功能是否正常工作。
根據(jù)本人之前的經(jīng)歷,一個簡單的辦法是搭建一個測試的服務(wù)器,然后全部模擬實現(xiàn)一遍這些接口。之前這樣做確實也挺爽,但是后邊就麻煩了,調(diào)用的接口越來越來,每次都要全部實現(xiàn)一遍。最重要的時候測試的時候這個服務(wù)還不能掛,不然就跑步起來了。:)
其實還有一個更佳方案就是就是我們用一個 mock 對象替換掉和遠端 rpc 服務(wù)的交互過程,給定任何我們期望返回的指標值。
unittest
python2.7 需要手動安裝 mock 模塊
pip?install?mockpython3.3 開始,mock 模塊已經(jīng)被合并到標準庫中,命名為 unittest.mock,可以直接 imort 使用
from?unittest?import?mockmock 支持對象、方法、異常的寫法,直接上代碼說話,還是上面提到的分布式任務(wù)下發(fā)的場景。
ResourceManager負責(zé)資源管理,get_cpu_cost通過網(wǎng)絡(luò)請求拉取節(jié)點利用率。
WorkerManager負責(zé)管理所有 worker 節(jié)點,get_all_works返回所有 worker 節(jié)點。
Schedule負責(zé)任務(wù)調(diào)度,核心sch方法挑選一個負載最低的節(jié)點下發(fā)任務(wù)
#?content?in?res.py class?ResourceManager:#?資源管理類def?get_cpu_cost(self,ip):#?已經(jīng)開發(fā)完的邏輯import?requestsimport?jsonresponse?=?requests.get("http://xx.oa.com/%s"%ip)rs?=?json.loads(response.json())return?rs["num"]class?WorkerManager:def?get_all_works(self):#?已經(jīng)開發(fā)完的邏輯import?pymysql#?打開數(shù)據(jù)庫連接db?=?pymysql.connect("localhost",?"xx",?"xx",?"TESTDB")cursor?=?db.cursor()cursor.execute("SELECT?ip?from?works?where?alive=1")ips?=?cursor.fetchall()db.close()return?[ip[0]?for?ip?in?ips] #?content?in?schedule.py from?src.demo.res?import?WorkerManager,?ResourceManagerclass?Schedule:def?__init__(self):self.work_mgr?=?WorkerManager()self.res_mgr?=?ResourceManager()def?sch(self):ips?=?self.work_mgr.get_all_works()cpu_cost?=?[self.res_mgr.get_cpu_cost(ip)?for?ip?in?ips]min_cost_index?=?cpu_cost.index(min(cpu_cost))?#?獲取負載最低的ipreturn?ips[min_cost_index]現(xiàn)在我們要針對Schedule類中的方法進行測試。此時獲取節(jié)點的方法要從 db 拉取,獲取資源負責(zé)的方法要從遠端網(wǎng)絡(luò)請求拉取。兩部分目前都很難搭建環(huán)境,我們希望 mock 這兩部分,來測試我們調(diào)度的邏輯是否滿足挑選負載最低的節(jié)點調(diào)度。
mock 對象的寫法
簡要說下關(guān)鍵幾步的寫法:
這里import我們要 mock 的類,下面兩種 import 方法都可以
使用@mock.patch.object注解寫法,參數(shù)ResourceManager是要 mock 的類對象(類名),'get_cpu_cost'是要 mock 的方法名
我們一次性 mock 兩個對象,@mock.patch.object的順序從下到上來從前到后對應(yīng)參數(shù)名。all_workers和cpu_cost是兩個臨時名字,你可以自行定義。
重點:cpu_cost.side_effect = mock_cpu_cost用自定義方法替換目標對象方法。
重點:all_workers.return_value = workers用自定義返回值來替換目標對象返回值,直白說就是接下來調(diào)用get_all_works都會返回['1.1.1.1', '2.2.2.2']
標準 unittest 寫法來判斷言
mock 方法的寫法
唯一不同點@mock.patch('src.demo.res.ResourceManager.get_cpu_cost'),參數(shù)寫方法全路徑。
當(dāng)然 return_value 和 side_effect 也可一次定義
class?TestSchedule(TestCase):#?mock對象的寫法@mock.patch.object(ResourceManager,?'get_cpu_cost')@mock.patch.object(WorkerManager,?'get_all_works',return_value=['1.1.1.1',?'2.2.2.2'])#?mock方法的寫法#?@mock.patch('src.demo.res.ResourceManager.get_cpu_cost')#?@mock.patch('src.demo.res.WorkerManager.get_all_works')def?test_sch(self,?_,?cpu_cost):cpu_cost.side_effect?=?mock_cpu_cost#?all_workers.return_value?=?workerssch?=?Schedule()res?=?sch.sch()self.assertEqual(res,?'1.1.1.1')pytest
pytest 框架沒有提供 mock 模塊,使用需要在安裝一個包 pytest-mock
pip?install?pytest-mock使用方法及寫法幾乎與unittest.mock完全一致
class?TestSchedule:def?test_sch(self,?mocker):?#?1workers?=?['1.1.1.1',?'2.2.2.2']mocker.patch.object(ResourceManager,?'get_cpu_cost',?side_effect=mock_cpu_cost)?#?2mocker.patch.object(WorkerManager,?'get_all_works',?return_value=workers)sch?=?Schedule()res?=?sch.sch()assert?res?==?workers[0]無需手動 import,test 方法參數(shù)使用mocker,pytest-mock 會自動注入。名字不能換,只能使用`mocker
寫法和 unittest.mock 完全一致。
目前沒有找到原生優(yōu)雅寫注解的辦法,只能吧 mock 邏輯放到 test 方法中,后邊封裝后再補充
如果掃一眼源碼可以看到 mock 是 pytest_mock.plugin 模塊下的一個 fixture
def?_mocker(pytestconfig:?Any)?->?Generator[MockerFixture,?None,?None]:"""Return?an?object?that?has?the?same?interface?to?the?`mock`?module,?buttakes?care?of?automatically?undoing?all?patches?after?each?test?method."""result?=?MockerFixture(pytestconfig)yield?resultresult.stopall() mocker?=?pytest.fixture()(_mocker)??#?default?scope?is?function class_mocker?=?pytest.fixture(scope="class")(_mocker) module_mocker?=?pytest.fixture(scope="module")(_mocker) package_mocker?=?pytest.fixture(scope="package")(_mocker) session_mocker?=?pytest.fixture(scope="session")(_mocker)三、覆蓋率
覆蓋率是用來衡量單元測試對功能代碼的測試情況,通過統(tǒng)計單元測試中對功能代碼中行、分支、類等模擬場景數(shù)量,來量化說明測試的充分度
同 Java 的 JaCoCo、Golang 的 GoCover 等一樣,Python 也有自己的單元測試覆蓋率統(tǒng)計工具,Coverage 就是使用最廣的一種。
安裝
pip?install?coverage使用
For pytest
coverage?run?-m?pytest?arg1?arg2?arg3For unittest
coverage?run?-m?unittest?discover上報結(jié)果
$?coverage?report?-m Name??????????????????????Stmts???Miss??Cover???Missing ------------------------------------------------------- my_program.py????????????????20??????4????80%???33-35,?39 my_other_module.py???????????56??????6????89%???17-23 ------------------------------------------------------- TOTAL????????????????????????76?????10????87%更多展示
生成 html 文件及 css 等樣式,豐富展示
coverage?html借助 IDE 提效
右鍵呼出跑整個測試文件
小箭頭跑單個測試用例
右側(cè)或者左側(cè)項目樹可以看到整個覆蓋情況
向上小箭頭可以導(dǎo)出覆蓋情況報告,Save會直接打開瀏覽器給出結(jié)果,很方便。點擊具體文件還有詳細說明。
接入公司覆蓋率平臺
如果所在公司有覆蓋率檢測平臺,接入原理很簡單。通過發(fā)布流水線集成項目代碼,拉取到構(gòu)建機,將上面在本地跑的 coverage 放到構(gòu)建機上執(zhí)行,將結(jié)果上報到遠端平臺。
后記
在騰訊安全平臺部實際研發(fā)與測試工作中,單元測試是保證代碼質(zhì)量的有效手段,也是效能優(yōu)化實踐的重要一環(huán)。本文是筆者在學(xué)習(xí) python 單測整個過程的總結(jié),介紹了 python 的幾種主流單測框架,Mock 的使用以及使用 coverage 來計算單測覆蓋率。推薦使用 pytest 來進行日常測試框架,支持的插件足夠豐富,希望可以對有需要接入 python 單測的同學(xué)有些幫助。安平研效團隊仍在持續(xù)探索優(yōu)化中,若大家在工作中遇到相關(guān)問題,歡迎一起交流探討,共同把研效測試工作做好、做強。
github 倉庫地址:
https://github.com/nudt681/python_unittest_guide
參考文章
pytest: helps you write better programs
Pytest 使用手冊— learning-pytest 1.0 文檔
Python 中 Mock 到底該怎么玩?一篇文章告訴你(超全) - 知乎
Step 3. Test your first Python application | PyCharm - JetBrains
總結(jié)
以上是生活随笔為你收集整理的研效优化实践:Python单测——从入门到起飞的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 带你快速了解 Docker 和 Kube
- 下一篇: 数据上报痛点解决方案