單元測(cè)試代碼實(shí)例)
前言u(píng)nittest是 Python 標(biāo)準(zhǔn)庫(kù)自帶的測(cè)試框架不需要安裝任何第三方包。它的設(shè)計(jì)借鑒了 Java 的 JUnit所以你會(huì)看到TestCase、setUp、tearDown這類(lèi)命名。雖然現(xiàn)在很多人轉(zhuǎn)向 pytest但unittest依然值得學(xué)一是零依賴(lài)二是大量存量項(xiàng)目用它三是理解它的結(jié)構(gòu)后看別的測(cè)試框架會(huì)輕松很多。這篇是實(shí)操向從寫(xiě)第一個(gè)TestCase開(kāi)始講到assertEqual系列斷言、setUp/tearDown的生命周期、assertRaises怎么驗(yàn)證異常、unittest.mock怎么打樁最后說(shuō)清測(cè)試目錄該怎么組織、怎么用一條命令跑起來(lái)。文中所有 API 名稱(chēng)與參數(shù)以 Python 官方文檔unittest與unittest.mock頁(yè)面為準(zhǔn)。示例以 Python 3 為基準(zhǔn)。一、第一個(gè) TestCase核心結(jié)構(gòu)是繼承unittest.TestCase把每個(gè)測(cè)試寫(xiě)成一個(gè)以test_開(kāi)頭的方法。# 適用于 Python 3.8import unittestdef add(a, b):return a bdef divide(a, b):if b 0:raise ValueError(除數(shù)不能為 0)return a / bclass TestCalculator(unittest.TestCase):def test_add(self):self.assertEqual(add(2, 3), 5)def test_divide(self):self.assertEqual(divide(10, 2), 5)def test_divide_by_zero(self):with self.assertRaises(ValueError):divide(1, 0)if __name__ __main__:unittest.main()幾點(diǎn)解釋方法名必須以test開(kāi)頭否則測(cè)試加載器不會(huì)收集它。這是最常見(jiàn)的「寫(xiě)了測(cè)試卻一個(gè)都沒(méi)跑」的原因。unittest.main()會(huì)自動(dòng)發(fā)現(xiàn)本模塊里的測(cè)試并運(yùn)行方便單文件直接python xxx.py執(zhí)行。斷言失敗時(shí)unittest會(huì)打印期望值與實(shí)際值并指出是哪個(gè)方法、哪一行。二、常用斷言方法TestCase提供了一整套以assert開(kāi)頭的方法。不要用裸assert語(yǔ)句因?yàn)閍ssert在-O優(yōu)化模式下會(huì)被整體移除測(cè)試就形同虛設(shè)。用斷言方法還能得到更好的失敗信息。方法用途備注assertEqual(a, b)判斷相等最常用底層用assertNotEqual(a, b)判斷不等assertTrue(x)/assertFalse(x)判斷真假assertIs(a, b)/assertIsNot(a, b)判斷同一對(duì)象比assertEqual嚴(yán)格assertIsNone(x)判斷是NoneassertIn(a, b)/assertNotIn(a, b)成員判斷assertRaises(exc)斷言拋異常上下文管理器用法assertAlmostEqual(a, b)浮點(diǎn)近似相等避免浮點(diǎn)直接比較assertCountEqual(a, b)兩序列元素相同不計(jì)順序一個(gè)補(bǔ)充說(shuō)明舊的assertEquals、assertNotEquals是assertEqual、assertNotEqual的別名官方已把它們標(biāo)記為廢棄deprecated新代碼應(yīng)當(dāng)用不帶s的版本。浮點(diǎn)數(shù)比較要特別注意0.1 0.2 0.3在二進(jìn)制浮點(diǎn)下不成立所以應(yīng)該用assertAlmostEqual# 適用于 Python 3.8import unittestclass TestFloat(unittest.TestCase):def test_float_sum(self):self.assertAlmostEqual(0.1 0.2, 0.3, places7)def test_raises_with_message(self):with self.assertRaises(KeyError) as ctx:{}[missing]self.assertIn(missing, str(ctx.exception))三、setUp 與 tearDown測(cè)試夾具測(cè)試之間應(yīng)當(dāng)相互獨(dú)立一個(gè)測(cè)試的狀態(tài)不能泄漏到下一個(gè)。setUp()在每個(gè)測(cè)試方法之前運(yùn)行tearDown()在每個(gè)測(cè)試方法之后運(yùn)行——用它們準(zhǔn)備和清理夾具fixture。# 適用于 Python 3.8import unittestclass Calculator:def __init__(self):self.history []def add(self, a, b):result a bself.history.append(result)return resultclass TestCalculator(unittest.TestCase):def setUp(self):# 每個(gè)測(cè)試都拿到一個(gè)全新的實(shí)例互不污染self.calc Calculator()def tearDown(self):# 每個(gè)測(cè)試結(jié)束后清理這里沒(méi)有外部資源僅演示位置self.calc Nonedef test_add_records_history(self):self.calc.add(1, 2)self.assertEqual(self.calc.history, [3])def test_history_starts_empty(self):# 上一個(gè)測(cè)試加過(guò)的結(jié)果不會(huì)出現(xiàn)在這里self.assertEqual(self.calc.history, [])生命周期規(guī)則要記準(zhǔn)setUp()拋異常該測(cè)試方法不會(huì)執(zhí)行tearDown()也不會(huì)執(zhí)行如果setUp()成功則無(wú)論測(cè)試方法是否失敗tearDown()都會(huì)執(zhí)行更粗粒度還有setUpClass()/tearDownClass()用classmethod裝飾整個(gè)類(lèi)跑一次和setUpModule()/tearDownModule()整個(gè)模塊跑一次適合建立昂貴的、所有測(cè)試共享的資源比如臨時(shí)數(shù)據(jù)庫(kù)。四、用 mock 隔離外部依賴(lài)單元測(cè)試只應(yīng)測(cè)「被測(cè)單元」不該真的去連數(shù)據(jù)庫(kù)、發(fā) HTTP 請(qǐng)求。unittest.mock用來(lái)把這些外部依賴(lài)替換成可控的假對(duì)象。最常用的是patch它既可以當(dāng)裝飾器也可以當(dāng)上下文管理器。# 適用于 Python 3.8# 被測(cè)模塊 weather.py 里寫(xiě)# import requests# def get_temp(city):# resp requests.get(fhttps://api.example.com/{city})# return resp.json()[temp]import unittestfrom unittest.mock import patch, MagicMockimport weather # 上面那個(gè)被測(cè)模塊class TestGetTemp(unittest.TestCase):patch(weather.requests) # patch 被測(cè)模塊里的名字不是全局 requestsdef test_get_temp(self, mock_requests):mock_requests.get.return_value.json.return_value {temp: 21}self.assertEqual(weather.get_temp(shanghai), 21)mock_requests.get.assert_called_once_with(https://api.example.com/shanghai)def test_with_magicmock_argument(self):# 另一種做法把假的 client 當(dāng)參數(shù)注入不碰請(qǐng)求庫(kù)client MagicMock()client.get.return_value.json.return_value {name: Ada}resp client.get(/users/1)self.assertEqual(resp.json()[name], Ada)關(guān)鍵要點(diǎn)patch 的位置要 patch「使用它的地方」而不是「定義它的地方」。上面被測(cè)模塊weather.py里import requests后調(diào)用requests.get就應(yīng)該 patchweather.requests這個(gè)名字而不是全局的requests。MagicMock會(huì)自動(dòng)生成屬性和方法所以client.get().json()這種鏈?zhǔn)秸{(diào)用不需要手工打樁。assert_called_once_with(...)、assert_called_with(...)用來(lái)驗(yàn)證調(diào)用參數(shù)是「行為測(cè)試」的常用手段。想模擬異常給 mock 設(shè)side_effect。五、測(cè)試目錄怎么組織怎么跑起來(lái)推薦把源碼和測(cè)試分開(kāi)放測(cè)試文件名以test_開(kāi)頭這樣unittest的自動(dòng)發(fā)現(xiàn)能識(shí)別project/├── myservice/│ ├── __init__.py│ └── calculator.py└── tests/├── __init__.py├── test_calculator.py└── test_api.py運(yùn)行方式有三種# 1. 跑單個(gè)文件python -m unittest tests.test_calculator# 2. 自動(dòng)發(fā)現(xiàn)當(dāng)前目錄下所有 test*.pypython -m unittest discover# 3. 指定起始目錄與匹配模式python -m unittest discover -s tests -p test_*.py -v注意discover默認(rèn)匹配的是test*.pytest開(kāi)頭不是*_test.py。如果你的文件命名成calculator_test.py默認(rèn)發(fā)現(xiàn)不了——要么改文件名要么用-p指定模式。另外測(cè)試目錄里放一個(gè)__init__.py能讓「按包名運(yùn)行」在多數(shù)配置下更穩(wěn)妥。常見(jiàn)坑點(diǎn)測(cè)試方法不以test開(kāi)頭?def check_add(self): ...?def test_add(self): ...用裸 assert 而不是斷言方法?assert add(1, 1) 2-O下會(huì)被優(yōu)化掉?self.assertEqual(add(1, 1), 2)測(cè)試之間共享可變狀態(tài)? 在類(lèi)屬性里建一個(gè)列表多個(gè)測(cè)試往里塞數(shù)據(jù)? 在setUp()里每個(gè)測(cè)試新建獨(dú)立實(shí)例浮點(diǎn)數(shù)直接assertEqual?self.assertEqual(0.1 0.2, 0.3)不相等?self.assertAlmostEqual(0.1 0.2, 0.3)patch 錯(cuò)了目標(biāo)? patch 庫(kù)定義處結(jié)果被測(cè)模塊用了自己的引用沒(méi)被替換? patch 被測(cè)模塊里「引用它的那個(gè)名字」測(cè)試?yán)镒稣鎸?shí)的網(wǎng)絡(luò) / 數(shù)據(jù)庫(kù)訪問(wèn)? 測(cè)試依賴(lài)外部服務(wù)是否在線慢且不穩(wěn)? 用mock隔離外部依賴(lài)文件名不匹配發(fā)現(xiàn)規(guī)則?calculator_test.py用默認(rèn)discover跑不到? 改成test_calculator.py或用-p指定模式依賴(lài)tearDown在setUp失敗時(shí)執(zhí)行?setUp拋異常還想靠tearDown清理? 記住setUp失敗時(shí)tearDown不會(huì)執(zhí)行清理邏輯要能容忍總結(jié)概念作用關(guān)鍵約束TestCase測(cè)試容器方法名以test開(kāi)頭setUp/tearDown每個(gè)測(cè)試前后setUp失敗則tearDown不跑setUpClass/tearDownClass每類(lèi)一次需classmethod斷言方法驗(yàn)證結(jié)果別用裸assertmock.patch隔離依賴(lài)patch 使用處而非定義處discover批量運(yùn)行默認(rèn)匹配test*.pyunittest的骨架說(shuō)穿了就四塊繼承TestCase、test_命名、setUp準(zhǔn)備、斷言驗(yàn)證。把這四塊用熟練再學(xué) pytest 只是換個(gè)更順手的皮。