Python 单元测试详解
作者:yukkizhang,騰訊 CSIG 測試工程師
本文直接從常用的 Python 單元測試框架出發,分別對幾種框架進行了簡單的介紹和小結,然后介紹了 Mock 的框架,以及測試報告生成方式,并以具體代碼示例進行說明,最后列舉了一些常見問題。
一、常用 Python 單測框架
若你不想安裝或不允許第三方庫,那么 unittest 是最好也是唯一的選擇。反之,pytest 無疑是最佳選擇,眾多 Python 開源項目(如大名鼎鼎的 requests)都是使用 pytest 作為單元測試框架。甚至,連 nose2 在官方文檔上都建議大家使用 pytest。我們知道,nose 已經進入了維護模式,取代者是 nose2。相比 nose2,pytest 的生態無疑更具優勢,社區的活躍度也更高。
總體來說,unittest 用例格式復雜,兼容性無,插件少,二次開發方便。pytest 更加方便快捷,用例格式簡單,可以執行 unittest 風格的測試用例,較好的兼容性,插件豐富。
二、unittest
1. 基本概念
unittest 中最核心的四個概念是:**test fixture、test case、test suite、test runner **。
test fixture:表示執行一個或多個測試所需的準備,以及任何關聯的清理操作。例如這可能涉及創建臨時或代理數據庫、目錄或啟動服務器進程。
test case:測試用例是最小的測試單元。它檢查特定的輸入集的響應。單元測試提供了一個基類測試用例,可用于創建新的測試用例。
test suite:測試套件是測試用例、測試套件或兩者的集合,用于歸檔需要一起執行的測試。
test runner:是一個用于執行和輸出結果的組件。這個運行器可能使用圖形接口、文本接口,或返回一個特定的值表示運行測試的結果。
2. 編寫規則
編寫單元測試時,我們需要編寫一個測試類,從unittest.TestCase繼承。
以test開頭的方法就是測試方法,不以test開頭的方法不被認為是測試方法,測試的時候不會被執行。
對每一類測試都需要編寫一個test_xxx()方法。
3. 簡單示例
3.1 目錄結構
$?tree?. . ├──?README.md ├──?requirements.txt └──?src├──?demo│???└──?calculator.py└──?tests└──?demo├──?__init__.py├──?test_calculator_unittest.py└──?test_calculator_unittest_with_fixture.py3.2 計算器實現代碼
class?Calculator:def?add(self,?a,?b):return?a?+?bdef?sub(self,?a,?b):return?a?-?bdef?mul(self,?a,?b):return?a?*?bdef?div(self,?a,?b):return?a?/?b3.3 計算器測試代碼
import?unittestfrom?src.demo.calculator?import?Calculatorclass?TestCalculator(unittest.TestCase):def?test_add(self):c?=?Calculator()result?=?c.add(3,?5)self.assertEqual(result,?8)def?test_sub(self):c?=?Calculator()result?=?c.sub(10,?5)self.assertEqual(result,?5)def?test_mul(self):c?=?Calculator()result?=?c.mul(5,?7)self.assertEqual(result,?35)def?test_div(self):c?=?Calculator()result?=?c.div(10,?5)self.assertEqual(result,?2)if?__name__?==?'__main__':unittest.main()3.4 執行結果
Ran?4?tests?in?0.002sOK4. 用例前置和后置
基于 unittest 的四個概念的理解,上述簡單用例,可以修改為:
import?unittestfrom?src.demo.calculator?import?Calculatorclass?TestCalculatorWithFixture(unittest.TestCase):#?測試用例前置動作def?setUp(self):print("test?start")#?測試用例后置動作def?tearDown(self):print("test?end")def?test_add(self):c?=?Calculator()result?=?c.add(3,?5)self.assertEqual(result,?8)def?test_sub(self):c?=?Calculator()result?=?c.sub(10,?5)self.assertEqual(result,?5)def?test_mul(self):c?=?Calculator()result?=?c.mul(5,?7)self.assertEqual(result,?35)def?test_div(self):c?=?Calculator()result?=?c.div(10,?5)self.assertEqual(result,?2)if?__name__?==?'__main__':#?創建測試套件suit?=?unittest.TestSuite()suit.addTest(TestCalculatorWithFixture("test_add"))suit.addTest(TestCalculatorWithFixture("test_sub"))suit.addTest(TestCalculatorWithFixture("test_mul"))suit.addTest(TestCalculatorWithFixture("test_div"))#?創建測試運行器runner?=?unittest.TestRunner()runner.run(suit)5. 參數化
標準庫的 unittest 自身不支持參數化測試,可以通過第三方庫來支持:parameterized 和 ddt。
其中 parameterized 只需要一個裝飾器@parameterized.expand,ddt 需要三個裝飾器@ddt、@data、@unpack,它們生成的 test 分別有一個名字,ddt 會攜帶具體的參數信息。
5.1 parameterized
import?unittestfrom?parameterized?import?parameterized,?paramfrom?src.demo.calculator?import?Calculatorclass?TestCalculator(unittest.TestCase):@parameterized.expand([param(3,?5,?8),param(1,?2,?3),param(2,?2,?4)])def?test_add(self,?num1,?num2,?total):c?=?Calculator()result?=?c.add(num1,?num2)self.assertEqual(result,?total)if?__name__?==?'__main__':unittest.main()執行結果:
test_add_0?(__main__.TestCalculator)?...?ok test_add_1?(__main__.TestCalculator)?...?ok test_add_2?(__main__.TestCalculator)?...?ok---------------------------------------------------------------------- Ran?3?tests?in?0.000sOK5.2 ddt
import?unittestfrom?ddt?import?data,?unpack,?ddtfrom?src.demo.calculator?import?Calculator@ddt class?TestCalculator(unittest.TestCase):@data((3,?5,?8),(1,?2,?3),(2,?2,?4))@unpackdef?test_add(self,?num1,?num2,?total):c?=?Calculator()result?=?c.add(num1,?num2)self.assertEqual(result,?total)if?__name__?==?'__main__':unittest.main()執行結果:
test_add_1__3__5__8_?(__main__.TestCalculator)?...?ok test_add_2__1__2__3_?(__main__.TestCalculator)?...?ok test_add_3__2__2__4_?(__main__.TestCalculator)?...?ok---------------------------------------------------------------------- Ran?3?tests?in?0.000sOK6. 斷言
unittest 提供了豐富的斷言,常用的包括:
assertEqual、assertNotEqual、assertTrue、assertFalse、assertIn、assertNotIn 等。
具體可以直接看源碼提供的方法:
enter image description here三、nose
nose 已經進入維護模式,從github nose上可以看到,nose 最近的一次代碼提交還是在 2016 年 5 月 4 日。
繼承 nose 的是 nose2,但要注意的是,nose2 并不支持 nose 的全部功能,它們的區別可以看這里。nose2 的主要目的是擴展 Python 的標準單元測試庫 unittest,因此它的定位是“帶插件的 unittest”。nose2 提供的插件,例如測試用例加載器,覆蓋度報告生成器,并行測試等內置插件和第三方插件,讓單元測試變得更加完善。
nose2 的社區沒有 pytest 的活躍,要使用高級框架,推薦使用 pytest,因此下文不做過多詳述。
1. 編寫規則
nose2 的測試用例并不限制于類,也可以直接使用函數。
任何函數和類,只要名稱匹配一定的條件(例如,以 test 開頭或以 test 結尾等),都會被自動識別為測試用例;
為了兼容 unittest, 所有的基于 unitest 編寫的測試用例,也會被 nose 自動識別為。
2. 簡單示例
2.1 計算器代碼
參考 unittest 的計算器代碼部分。
2.2 計算器測試代碼
import?nose2from?src.demo.calculator?import?Calculatordef?test_add():c?=?Calculator()result?=?c.add(3,?5)assert?result?==?8def?test_sub():c?=?Calculator()result?=?c.sub(10,?5)assert?result?==?5def?test_mul():c?=?Calculator()result?=?c.mul(5,?7)assert?result?==?35def?test_div():c?=?Calculator()result?=?c.div(10,?5)assert?result?==?2if?__name__?==?'__main__':nose2.main()2.3 執行結果
.... ---------------------------------------------------------------------- Ran?4?tests?in?0.000sOK3. 參數化
import?nose2 from?nose2.tools?import?paramsfrom?src.demo.calculator?import?Calculatortest_data?=?[{"nums":?(3,?5),?"total":?8},{"nums":?(1,?2),?"total":?3},{"nums":?(2,?2),?"total":?4} ]@params(*test_data) def?test_add(data):c?=?Calculator()result?=?c.add(*data['nums'])assert?result?==?data['total']if?__name__?==?'__main__':nose2.main()四、pytest
1. 編寫規則
測試文件以 test_開頭(以 test 結尾也可以)
測試類以 Test 開頭,并且不能帶有 init 方法
測試函數以 test_開頭
斷言使用基本的 assert 即可
可以通過下面的命令,查看 Pytest 收集到哪些測試用例:
$?py.test?--collect-only2. 簡單示例
2.1 計算器代碼
參考 unittest 的計算器代碼部分。
2.2 計算器實現代碼
import?pytestfrom?src.demo.calculator?import?Calculatorclass?TestCalculator():def?test_add(self):c?=?Calculator()result?=?c.add(3,?5)assert?result?==?8def?test_sub(self):c?=?Calculator()result?=?c.sub(10,?5)assert?result?==?5def?test_mul(self):c?=?Calculator()result?=?c.mul(5,?7)assert?result?==?35def?test_div(self):c?=?Calculator()result?=?c.div(10,?5)assert?result?==?2if?__name__?==?'__main__':pytest.main(['-s',?'test_calculator_pytest.py'])2.3 執行結果
=============================?test?session?starts?============================== platform?darwin?--?Python?3.8.3,?pytest-6.2.2,?py-1.10.0,?pluggy-0.13.1 rootdir:?python-ut/src/tests/demo plugins:?metadata-1.11.0,?html-3.1.1 collected?4?itemstest_calculator_pytest.py?....==============================?4?passed?in?0.01s?===============================3. 用例前置和后置
加上 fixture 夾具,有幾種方式:
將夾具函數名稱作為參數傳遞到測試用例函數當中
@pytest.mark.usefixtures("夾具函數名稱")
@pytest.fixture(autouse=True),設置了 autouse,就可以不用上述兩種手動方式,默認就會使用夾具
執行結果:
=============================?test?session?starts?============================== platform?darwin?--?Python?3.8.3,?pytest-6.2.2,?py-1.10.0,?pluggy-0.13.1 rootdir:?python-ut/src/tests/demo plugins:?metadata-1.11.0,?html-3.1.1 collected?4?itemstest_calculator_pytest_with_fixture.py?[pytest?with?fixture]?start .[pytest?with?fixture]?end [pytest?with?fixture]?start .[pytest?with?fixture]?end [pytest?with?fixture]?start .[pytest?with?fixture]?end [pytest?with?fixture]?start .[pytest?with?fixture]?end==============================?4?passed?in?0.01s?===============================4. 參數化
4.1 基礎知識
如果只有一個參數,里面則是值的列表,比如@pytest.mark.parametrize("num1", [3, 5, 8])
如果有多個參數,則需要用元祖來存放值,一個元祖對應一組參數的值,比如@pytest.mark.parametrize("num1, num2, total", [(3, 5, 8), (1, 2, 3), (2, 2, 4)])
當裝飾器 @pytest.mark.parametrize裝飾測試類時,會將數據集合傳遞給類的所有測試用例方法
一個函數或一個類可以裝飾多個 @pytest.mark.parametrize,當參數化有多個裝飾器時,用例數是 N*M...
4.2 參數化測試
import?pytestfrom?src.demo.calculator?import?Calculatorclass?TestCalculator():@pytest.mark.parametrize("num1,?num2,?total",?[(3,?5,?8),?(1,?2,?3),?(2,?2,?4)])def?test_add(self,?num1,?num2,?total):c?=?Calculator()result?=?c.add(num1,?num2)assert?result?==?totalif?__name__?==?'__main__':pytest.main(['test_calculator_pytest_with_parameterize.py'])執行結果:
=============================?test?session?starts?============================== platform?darwin?--?Python?3.8.3,?pytest-6.2.2,?py-1.10.0,?pluggy-0.13.1 rootdir:?python-ut/src/tests/demo plugins:?metadata-1.11.0,?html-3.1.1 collected?3?itemstest_calculator_pytest_with_paramtrize.py?...==============================?3?passed?in?0.01s?===============================4.3 參數化標記數據
class?TestCalculator():@pytest.mark.parametrize("num1,?num2,?total",?[pytest.param(5,?1,?4,?marks=pytest.mark.passed),pytest.param(5,?2,?4,?marks=pytest.mark.fail),(5,?4,?1)])def?test_sub(self,?num1,?num2,?total):c?=?Calculator()result?=?c.sub(num1,?num2)assert?result?==?totalif?__name__?==?'__main__':pytest.main(['test_calculator_pytest_with_parameterize.py'])執行結果:
=============================?test?session?starts?============================== platform?darwin?--?Python?3.8.3,?pytest-6.2.2,?py-1.10.0,?pluggy-0.13.1 rootdir:?python-ut/src/tests/demo plugins:?metadata-1.11.0,?html-3.1.1 collected?3?itemstest_calculator_pytest_with_paramtrize.py?.F.????????????????????????????[100%]===================================?FAILURES?=================================== ________________________?TestCalculator.test_sub[5-2-4]?________________________self?=?<demo.test_calculator_pytest_with_paramtrize.TestCalculator?object?at?0x110813d00> num1?=?5,?num2?=?2,?total?=?4@pytest.mark.parametrize("num1,?num2,?total",?[pytest.param(5,?1,?4,?marks=pytest.mark.passed),pytest.param(5,?2,?4,?marks=pytest.mark.fail),(5,?4,?1)])def?test_sub(self,?num1,?num2,?total):c?=?Calculator()result?=?c.sub(num1,?num2) >???????assert?result?==?total E???????assert?3?==?4test_calculator_pytest_with_paramtrize.py:21:?AssertionError ===========================?short?test?summary?info?============================ FAILED?test_calculator_pytest_with_paramtrize.py::TestCalculator::test_sub[5-2-4] ===================?1?failed,?2?passed,?2?warnings?in?0.04s?====================5. 斷言
在 unittest 單元測試框架中提供了豐富的斷言方法,例如 assertEqual()、assertIn()、assertTrue()、assertIs()等,而 pytest 單元測試框架中并沒提供特殊的斷言方法,而是直接使用 python 的 assert 進行斷言。
assert 可以使用==、!=、<、>、>=、<=等符號來比較相等、不相等、小于、大于、大于等于和小于等于。
斷言包含和不包含,使用assert a in b和assert a not in b
斷言真假,使用assert condition和assert not condition
斷言異常,使用 pytest.raise 獲取信息
#?詳細斷言異常 def?test_zero_division_long():with?pytest.raises(ZeroDivisionError)?as?excinfo:1?/?0#?斷言異常類型?typeassert?excinfo.type?==?ZeroDivisionError#?斷言異常?value?值assert?"division?by?zero"?in?str(excinfo.value)
6. 重跑
需要安裝額外的插件 pytest-rerunfailures
import?pytest@pytest.mark.flaky(reruns=5) def?test_example():import?randomassert?random.choice([True,?False,?False])執行結果:
collecting?...?collected?1?item11_reruns.py::test_example?RERUN?????????????????????????????????????????[100%] 11_reruns.py::test_example?PASSED????????????????????????????????????????[100%]=========================?1?passed,?1?rerun?in?0.05s?==========================五、Mock
1. mock
mock 原是 python 的第三方庫,python3 以后 mock 模塊已經整合到了 unittest 測試框架中。
如果使用的是 python3.3 以后版本,那么不用單獨安裝,使用的時候在文件開頭引入from unittest import mock即可。
如果使用的是 python2,需要先pip install mock安裝后再import mock即可。
1.1 Mock 一個方法
import?unittest from?unittest?import?mockfrom?src.demo.calculator?import?Calculatordef?multiple(a,?b):return?a?*?bclass?TestCalculator(unittest.TestCase):@mock.patch('test_calculator_mock.multiple')def?test_function_multiple(self,?mock_multiple):mock_return?=?1mock_multiple.return_value?=?mock_returnresult?=?multiple(3,?5)self.assertEqual(result,?mock_return)if?__name__?==?'__main__':unittest.main()1.2 Mock 一個對象里面的方法
分別給出了普通寫法和注解寫法,以及 side_effect 關鍵參數的效果案例。
import?unittest from?unittest?import?mockfrom?src.demo.calculator?import?Calculatorclass?TestCalculator(unittest.TestCase):def?test_add(self):c?=?Calculator()mock_return?=?10c.add?=?mock.Mock(return_value=mock_return)result?=?c.add(3,?5)self.assertEqual(result,?mock_return)def?test_add_with_side_effect(self):c?=?Calculator()mock_return?=?10#?傳遞side_effect關鍵字參數,?會覆蓋return_value參數值,?使用真實的add方法測試c.add?=?mock.Mock(return_value=mock_return,?side_effect=c.add)result?=?c.add(3,?5)self.assertEqual(result,?8)@mock.patch.object(Calculator,?'add')def?test_add_with_annotation(self,?mock_add):c?=?Calculator()mock_return?=?10mock_add.return_value?=?mock_returnresult?=?c.add(3,?5)self.assertEqual(result,?mock_return)if?__name__?==?'__main__':unittest.main()1.3 Mock 每次調用返回不同的值
import?unittest from?unittest?import?mockfrom?src.demo.calculator?import?Calculatorclass?TestCalculator(unittest.TestCase):@mock.patch.object(Calculator,?'add')def?test_add_with_different_return(self,?mock_add):c?=?Calculator()mock_return?=?[10,?8]mock_add.side_effect?=?mock_returnresult1?=?c.add(3,?5)result2?=?c.add(3,?5)self.assertEqual(result1,?mock_return[0])self.assertEqual(result2,?mock_return[1])if?__name__?==?'__main__':unittest.main()1.4 Mock 拋出異常的方法
import?unittest from?unittest?import?mockfrom?src.demo.calculator?import?Calculator#?被調用函數 def?multiple(a,?b):return?a?*?b#?實際調用函數 def?is_error(a,?b):try:return?multiple(a,?b)except?Exception?as?e:return?-1class?TestCalculator(unittest.TestCase):@mock.patch('test_calculator_mock.multiple')def?test_function_multiple_exception(self,?mock_multiple):mock_multiple.side_effect?=?Exceptionresult?=?is_error(3,?5)self.assertEqual(result,?-1)if?__name__?==?'__main__':unittest.main()1.5 Mock 多個方法
import?unittest from?unittest?import?mockfrom?src.demo.calculator?import?Calculatordef?multiple(a,?b):return?a?*?bclass?TestCalculator(unittest.TestCase):#?z'h@mock.patch.object(Calculator,?'add')@mock.patch('test_calculator_mock.multiple')def?test_both(self,?mock_multiple,?mock_add):c?=?Calculator()mock_add.return_value?=?1mock_multiple.return_value?=?2self.assertEqual(c.add(3,?5),?1)self.assertEqual(multiple(3,?5),?2)if?__name__?==?'__main__':unittest.main()2. pytest-mock
如果項目本身使用的框架是 pytest,則 Mock 更建議使用 pytest-mock 這個插件,它提供了一個名為 mocker 的 fixture,僅在當前測試 funciton 或 method 生效,而不用自行包裝。
mocker 和 mock.patch 有相同的 api,支持相同的參數。
2.1 簡單示例
import?pytestfrom?src.demo.calculator?import?Calculatorclass?TestCalculator():def?test_add(self,?mocker):c?=?Calculator()mock_return?=?10mocker.patch.object(c,?'add',?return_value=mock_return)result?=?c.add(3,?5)assert?result?==?mock_returnif?__name__?==?'__main__':pytest.main(['-s',?'test_calculator_pytest_mock.py'])2.2 mock 方法和域
class?ForTest:field?=?'origin'def?method():passdef?test_for_test(mocker):test?=?ForTest()#?方法mock_method?=?mocker.patch.object(test,?'method')test.method()#?檢查行為assert?mock_method.called#?域assert?'origin'?==?test.fieldmocker.patch.object(test,?'field',?'mocked')#?檢查結果assert?'mocked'?==?test.field3. monkeypatch
monkeypatch 是 pytest 框架內置的固件,有時候,測試用例需要調用某些依賴于全局配置的功能,或者這些功能本身又調用了某些不容易測試的代碼(例如:網絡接入)。monkeypatch 提供了一些方法,用于安全地修補和模擬測試中的功能:
monkeypatch.setattr(obj,?name,?value,?raising=True) monkeypatch.delattr(obj,?name,?raising=True) monkeypatch.setitem(mapping,?name,?value) monkeypatch.delitem(obj,?name,?raising=True) monkeypatch.setenv(name,?value,?prepend=False) monkeypatch.delenv(name,?raising=True) monkeypatch.syspath_prepend(path) monkeypatch.chdir(path)主要考慮以下情形:
修改測試的函數行為或類的屬性
修改字典的值
修改測試環境的環境變量
在測試期間,用于修改和 更改當前工作目錄的上下文。
六、單元測試覆蓋率報告
coverage 是 Python 推薦使用的覆蓋率統計工具。
pytest-cov 是 pytest 的插件,它可以讓你在 pytest 中使用 cpverage.py。
HtmlTestRunner,需要在代碼里面寫入一點配置,但是報告生成比較美觀。
coverage 和 pytest-cov 只需要配置,就可直接使用,不需要測試代碼配合。
1. coverage
1.1 安裝
pip?install?coverage詳情可參考:coverage
1.2 運行
coverage?run?-m?unittest?discover運行結束之后,會生成一個覆蓋率統計結果文件(data file).coverage文件,在 pycharm 里可識別為一個數據庫:
1.3 結果
1.3. 1 report
coverage?report?-m執行結果如下:
$?coverage?report?-m Name????????????????????????????????????????????????????????????Stmts???Miss??Cover???Missing --------------------------------------------------------------------------------------------- src/tests/demo/test_calculator_pytest_with_fixture.py??????????????28?????16????43%???8-10,?15-17,?20-22,?26-28,?32-34,?38 src/tests/demo/test_calculator_pytest_with_parameterize.py?????????15??????7????53%???9-11,?19-21,?25 src/tests/demo/test_calculator_unittest.py?????????????????????????22??????1????95%???31 src/tests/demo/test_calculator_unittest_with_ddt.py????????????????13??????1????92%???181.3.2 html
會生成 htmlcov/index.html 文件,在瀏覽器查看:
coverage?html點擊各個 py 文件,可以查看詳細情況。
2. html-testRunner
2.1 安裝
?pip?install?html-testRunner詳細說明可參考HtmlTestRunner。
2.2 運行
在代碼中加上 HTMLTestRunner,如下
import?HtmlTestRunner#?some?tests?hereif?__name__?==?'__main__':unittest.main(testRunner=HtmlTestRunner.HTMLTestRunner())如果是在測試套件中運行,換成 HTMLTestRunner 即可:
#?創建測試運行器 #?runner?=?unittest.TestRunner() runner?=?HTMLTestRunner() runner.run(suit)2.3 結果
默認會生成reports/文件夾,按照時間顯示報告:
3. pytest-cov
3.1 安裝
pip?install?pytest-cov詳細可參考pytest-cov
3.2 運行
?pytest?--cov?--cov-report=html或者指定目錄:
?pytest?--cov=src?--cov-report=html3.3 結果
會生成 htmlcov/index.html 文件,在瀏覽器查看,類似于 coverage 的報告。
4. 可能的問題
4.1 報告沒生成
如果出現不了報告,pycharm 運行的時候,記得選擇 python,而不是 Python tests
4.2 在 Pycharm 中配置覆蓋率展示
可選擇 unittest 和 pytest 為默認 runner
enter image description here可顯示覆蓋率窗口:
七、情景示例
1. 概覽
1.1 項目介紹
一個簡單的博客系統,包含:
創建文章
獲取文章
獲取文章列表
1.2 項目結構
├──?README.md ├──?requirements.txt └──?src├──?blog│???├──?__init__.py│???├──?app.py│???├──?commands.py│???├──?database.db│???├──?init_db.py│???├──?models.py│???└──?queries.py└──?tests└──?blog├──?__init__.py├──?conftest.py├──?schemas│???├──?Article.json│???├──?ArticleList.json│???└──?__init__.py├──?test_app.py├──?test_commands.py└──?test_queries.py1.3 關鍵技術
Flask,web 框架
SQLite,輕量級數據庫,文件格式
pytest,單元測試框架
Pydantic,數據校驗
2. Service 測試
2.1 創建文章
models.py 如下:
import?os import?sqlite3 import?uuid from?typing?import?Listfrom?pydantic?import?BaseModel,?EmailStr,?Fieldclass?NotFound(Exception):passclass?Article(BaseModel):id:?str?=?Field(default_factory=lambda:?str(uuid.uuid4()))author:?EmailStrtitle:?strcontent:?str@classmethoddef?get_by_id(cls,?article_id:?str):con?=?sqlite3.connect(os.getenv('DATABASE_NAME',?'database.db'))con.row_factory?=?sqlite3.Rowcur?=?con.cursor()cur.execute("SELECT?*?FROM?articles?WHERE?id=?",?(article_id,))record?=?cur.fetchone()if?record?is?None:raise?NotFoundarticle?=?cls(**record)??#?Row?can?be?unpacked?as?dictcon.close()return?article@classmethoddef?get_by_title(cls,?title:?str):con?=?sqlite3.connect(os.getenv('DATABASE_NAME',?'database.db'))con.row_factory?=?sqlite3.Rowcur?=?con.cursor()cur.execute("SELECT?*?FROM?articles?WHERE?title?=??",?(title,))record?=?cur.fetchone()if?record?is?None:raise?NotFoundarticle?=?cls(**record)??#?Row?can?be?unpacked?as?dictcon.close()return?article@classmethoddef?list(cls)?->?List['Article']:con?=?sqlite3.connect(os.getenv('DATABASE_NAME',?'database.db'))con.row_factory?=?sqlite3.Rowcur?=?con.cursor()cur.execute("SELECT?*?FROM?articles")records?=?cur.fetchall()articles?=?[cls(**record)?for?record?in?records]con.close()return?articlesdef?save(self)?->?'Article':with?sqlite3.connect(os.getenv('DATABASE_NAME',?'database.db'))?as?con:cur?=?con.cursor()cur.execute("INSERT?INTO?articles?(id,author,title,content)?VALUES(?,??,??,??)",(self.id,?self.author,?self.title,?self.content))con.commit()return?self@classmethoddef?create_table(cls,?database_name='database.db'):conn?=?sqlite3.connect(database_name)conn.execute('CREATE?TABLE?IF?NOT?EXISTS?articles?(id?TEXT,?author?TEXT,?title?TEXT,?content?TEXT)')conn.close()commands.py 如下:
from?pydantic?import?BaseModel,?EmailStrfrom?src.blog.models?import?Article,?NotFoundclass?AlreadyExists(Exception):passclass?CreateArticleCommand(BaseModel):author:?EmailStrtitle:?strcontent:?strdef?execute(self)?->?Article:try:Article.get_by_title(self.title)raise?AlreadyExistsexcept?NotFound:passarticle?=?Article(author=self.author,title=self.title,content=self.title).save()return?article單元測試 test_commands.py:
import?pytestfrom?src.blog.commands?import?CreateArticleCommand,?AlreadyExists from?src.blog.models?import?Articledef?test_create_article():"""GIVEN?CreateArticleCommand?with?a?valid?properties?author,?title?and?contentWHEN?the?execute?method?is?calledTHEN?a?new?Article?must?exist?in?the?database?with?the?same?attributes"""cmd?=?CreateArticleCommand(author='john@doe.com',title='New?Article',content='Super?awesome?article')article?=?cmd.execute()db_article?=?Article.get_by_id(article.id)assert?db_article.id?==?article.idassert?db_article.author?==?article.authorassert?db_article.title?==?article.titleassert?db_article.content?==?article.contentdef?test_create_article_with_mock(monkeypatch):"""GIVEN?CreateArticleCommand?with?valid?properties?author,?title?and?contentWHEN?the?execute?method?is?calledTHEN?a?new?Article?must?exist?in?the?database?with?same?attributes"""article?=?Article(author='john@doe.com',title='New?Article',content='Super?awesome?article')monkeypatch.setattr(Article,'save',lambda?self:?article)cmd?=?CreateArticleCommand(author='john@doe.com',title='New?Article',content='Super?awesome?article')db_article?=?cmd.execute()assert?db_article.id?==?article.idassert?db_article.author?==?article.authorassert?db_article.title?==?article.titleassert?db_article.content?==?article.contentdef?test_create_article_already_exists():"""GIVEN?CreateArticleCommand?with?a?title?of?some?article?in?databaseWHEN?the?execute?method?is?calledTHEN?the?AlreadyExists?exception?must?be?raised"""Article(author='jane@doe.com',title='New?Article',content='Super?extra?awesome?article').save()cmd?=?CreateArticleCommand(author='john@doe.com',title='New?Article',content='Super?awesome?article')with?pytest.raises(AlreadyExists):cmd.execute()當多次運行時候,需要清理數據庫,那么需要使用到用例前置和后置:
confest.py:
import?os import?tempfileimport?pytestfrom?src.blog.models?import?Article@pytest.fixture(autouse=True) def?database():_,?file_name?=?tempfile.mkstemp()os.environ['DATABASE_NAME']?=?file_nameArticle.create_table(database_name=file_name)yieldos.unlink(file_name)再次運行,執行結果:
$?python3?-m?pytest?src/tests/blog/test_commands.py ===================?test?session?starts?====================== platform?darwin?--?Python?3.8.3,?pytest-6.2.2,?py-1.10.0,?pluggy-0.13.1 rootdir:?python-ut plugins:?metadata-1.11.0,?html-3.1.1,?mock-3.5.1 collected?3?itemssrc/tests/blog/test_commands.py?...??????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????[100%]=====================?3?passed?in?0.02s?=======================2.2 獲取文章列表
queries.py:
from?typing?import?Listfrom?pydantic?import?BaseModelfrom?src.blog.models?import?Articleclass?ListArticlesQuery(BaseModel):def?execute(self)?->?List[Article]:articles?=?Article.list()return?articles單元測試 test_queries.py:
from?src.blog.models?import?Article from?src.blog.queries?import?ListArticlesQuery,?GetArticleByIDQuerydef?test_list_articles():"""GIVEN?2?articles?stored?in?the?databaseWHEN?the?execute?method?is?calledTHEN?it?should?return?2?articles"""Article(author='jane@doe.com',title='New?Article',content='Super?extra?awesome?article').save()Article(author='jane@doe.com',title='Another?Article',content='Super?awesome?article').save()query?=?ListArticlesQuery()assert?len(query.execute())?==?22.3 獲取文章
queries.py 里面加入:
class?GetArticleByIDQuery(BaseModel):id:?strdef?execute(self)?->?Article:article?=?Article.get_by_id(self.id)return?article單元測試 test_queries.py 里加入:
def?test_get_article_by_id():"""GIVEN?ID?of?article?stored?in?the?databaseWHEN?the?execute?method?is?called?on?GetArticleByIDQuery?with?id?setTHEN?it?should?return?the?article?with?the?same?id"""article?=?Article(author='jane@doe.com',title='New?Article',content='Super?extra?awesome?article').save()query?=?GetArticleByIDQuery(id=article.id)assert?query.execute().id?==?article.id3. 其他功能測試
應用入口 app.py:
from?flask?import?Flask,?jsonify,?requestfrom?src.blog.commands?import?CreateArticleCommand from?src.blog.queries?import?GetArticleByIDQuery,?ListArticlesQuery from?pydantic?import?ValidationErrorapp?=?Flask(__name__)@app.route('/articles/',?methods=['POST']) def?create_article():cmd?=?CreateArticleCommand(**request.json)return?jsonify(cmd.execute().dict())@app.route('/articles/<article_id>/',?methods=['GET']) def?get_article(article_id):query?=?GetArticleByIDQuery(id=article_id)return?jsonify(query.execute().dict())@app.route('/articles/',?methods=['GET']) def?list_articles():query?=?ListArticlesQuery()records?=?[record.dict()?for?record?in?query.execute()]return?jsonify(records)@app.errorhandler(ValidationError) def?handle_validation_exception(error):response?=?jsonify(error.errors())response.status_code?=?400return?responseif?__name__?==?'__main__':app.run()暴露 json schema,校驗響應 payload:
Article.json
{"$schema":?"http://json-schema.org/draft-07/schema#","title":?"Article","type":?"object","properties":?{"id":?{"type":?"string"},"author":?{"type":?"string"},"title":?{"type":?"string"},"content":?{"type":?"string"}},"required":?["id","author","title","content"] }ArticleList.json
{"$schema":?"http://json-schema.org/draft-07/schema#","title":?"ArticleList","type":?"array","items":?{"$ref":?"file:Article.json"} }從應用本身,串起來整個流程的測試,測試 test_app.py:
import?json import?pathlibimport?pytest from?jsonschema?import?validate,?RefResolverfrom?src.blog.app?import?app from?src.blog.models?import?Article@pytest.fixture def?client():app.config['TESTING']?=?Truewith?app.test_client()?as?client:yield?clientdef?validate_payload(payload,?schema_name):"""Validate?payload?with?selected?schema"""schemas_dir?=?str(f'{pathlib.Path(__file__).parent.absolute()}/schemas')schema?=?json.loads(pathlib.Path(f'{schemas_dir}/{schema_name}').read_text())validate(payload,schema,resolver=RefResolver('file://'?+?str(pathlib.Path(f'{schemas_dir}/{schema_name}').absolute()),schema??#?it's?used?to?resolve?file:?inside?schemas?correctly))def?test_create_article(client):"""GIVEN?request?data?for?new?articleWHEN?endpoint?/articles/?is?calledTHEN?it?should?return?Article?in?json?format?matching?schema"""data?=?{'author':?'john@doe.com','title':?'New?Article','content':?'Some?extra?awesome?content'}response?=?client.post('/articles/',data=json.dumps(data),content_type='application/json',)validate_payload(response.json,?'Article.json')def?test_get_article(client):"""GIVEN?ID?of?article?stored?in?the?databaseWHEN?endpoint?/articles/<id-of-article>/?is?calledTHEN?it?should?return?Article?in?json?format?matching?schema"""article?=?Article(author='jane@doe.com',title='New?Article',content='Super?extra?awesome?article').save()response?=?client.get(f'/articles/{article.id}/',content_type='application/json',)validate_payload(response.json,?'Article.json')def?test_list_articles(client):"""GIVEN?articles?stored?in?the?databaseWHEN?endpoint?/articles/?is?calledTHEN?it?should?return?list?of?Article?in?json?format?matching?schema"""Article(author='jane@doe.com',title='New?Article',content='Super?extra?awesome?article').save()response?=?client.get('/articles/',content_type='application/json',)validate_payload(response.json,?'ArticleList.json')@pytest.mark.parametrize('data',[{'author':?'John?Doe','title':?'New?Article','content':?'Some?extra?awesome?content'},{'author':?'John?Doe','title':?'New?Article',},{'author':?'John?Doe','title':?None,'content':?'Some?extra?awesome?content'}] ) def?test_create_article_bad_request(client,?data):"""GIVEN?request?data?with?invalid?values?or?missing?attributesWHEN?endpoint?/create-article/?is?calledTHEN?it?should?return?status?400?and?JSON?body"""response?=?client.post('/articles/',data=json.dumps(data),content_type='application/json',)assert?response.status_code?==?400assert?response.json?is?not?None4. 小結
自此,上面的 web 小應用基本可以完成,包含了基本的服務層單元測試、數據庫模擬、mock 創建文章以及參數化請求驗證。
八、結語
1. 小結
Python 的單元測試框架中,Python 庫本身提供了 unittest,也有第三方框架進行了封裝。原生的庫插件少,二次開發非常方便。第三方框架融合了不少插件,上手簡單。
Python 屬于腳本語言,不像編譯型語言那樣先將程序編譯成二進制再運行,而是動態地逐行解釋運行,雖然其本身的結構靈活多變,但是仍然不妨礙我們用單元測試保證其質量、權衡其設計、設置其有形和無形的約束,為開發保駕護航。
2. 相關閱讀
Python 測試框架最全資源匯總
Python Testing Tools Taxonomy
Modern Test-Driven Development in Python
騰訊看點商業化中心招聘信息
視頻號最新視頻
總結
以上是生活随笔為你收集整理的Python 单元测试详解的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 揭秘数据技术的前世今生,Techo TV
- 下一篇: Facebook、谷歌、微软和亚马逊的网