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