說到 unit test(即單元測試,下文統一用中文稱呼),大部分人的反應估計有這么兩種:
-
要么就是,單元測試啊,挺簡單的呀,做不做無所謂吧;
-
要么就是,哎呀,項目進度太趕,單元測試拖一拖之后再來吧。
顯然,這兩種人,都沒有正確認識到單元測試的價值,也沒能掌握正確的單元測試方法。你是不是覺得自己只要了解 Python 的各個 feature,能夠編寫出符合規定功能的程序就可以了呢?
其實不然,完成產品的功能需求只是很基礎的一部分,如何保證所寫代碼的穩定、高效、無誤,才是我們工作的關鍵。而學會合理地使用單元測試,正是幫助你實現這一目標的重要路徑。
我們總說,測試驅動開發(TDD)。今天我就以 Python 為例,教你設計編寫 Python 的單元測試代碼,帶你熟悉並掌握這一重要技能。
什么是單元測試?
單元測試,通俗易懂地講,就是編寫測試來驗證某一個模塊的功能正確性,一般會指定輸入,驗證輸出是否符合預期。
實際生產環境中,我們會對每一個模塊的所有可能輸入值進行測試。這樣雖然顯得繁瑣,增加了額外的工作量,但是能夠大大提高代碼質量,減小 bug 發生的可能性,也更方便系統的維護。
說起單元測試,就不得不提 Python unittest 庫,它提供了我們需要的大多數工具。我們來看下面這個簡單的測試,從代碼中了解其使用方法:
import unittest # 將要被測試的排序函數 def sort(arr): l = len(arr) for i in range(0, l): for j in range(i + 1, l): if arr[i] >= arr[j]: tmp = arr[i] arr[i] = arr[j] arr[j] = tmp # 編寫子類繼承unittest.TestCase class TestSort(unittest.TestCase): # 以test開頭的函數將會被測試 def test_sort(self): arr = [3, 4, 1, 5, 6] sort(arr) # assert 結果跟我們期待的一樣 self.assertEqual(arr, [1, 3, 4, 5, 6]) if __name__ == '__main__': ## 如果在Jupyter下,請用如下方式運行單元測試 unittest.main(argv=['first-arg-is-ignored'], exit=False) ## 如果是命令行下運行,則: ## unittest.main() ## 輸出 .. ---------------------------------------------------------------------- Ran 2 tests in 0.002s OK
這里,我們創建了一個排序函數的單元測試,來驗證排序函數的功能是否正確。代碼里我做了非常詳細的注釋,相信你能夠大致讀懂,我再來介紹一些細節。
首先,我們需要創建一個類TestSort,繼承類‘unittest.TestCase’;然后,在這個類中定義相應的測試函數 test_sort(),進行測試。注意,測試函數要以‘test’開頭,而測試函數的內部,通常使用 assertEqual()、assertTrue()、assertFalse() 和 assertRaise() 等 assert 語句對結果進行驗證。
最后運行時,如果你是在 IPython 或者 Jupyter 環境下,請使用下面這行代碼:
unittest.main(argv=['first-arg-is-ignored'], exit=False)
而如果你用的是命令行,直接使用 unittest.main() 就可以了。你可以看到,運行結果輸出’OK‘,這就表示我們的測試通過了。
當然,這個例子中的被測函數相對簡單一些,所以寫起對應的單元測試來也非常自然,並不需要很多單元測試的技巧。但實戰中的函數往往還是比較復雜的,遇到復雜問題,高手和新手的最大差別,便是單元測試技巧的使用。
單元測試的幾個技巧
接下來,我將會介紹 Python 單元測試的幾個技巧,分別是 mock、side_effect 和 patch。這三者用法不一樣,但都是一個核心思想,即用虛假的實現,來替換掉被測試函數的一些依賴項,讓我們能把更多的精力放在需要被測試的功能上。
mock
mock 是單元測試中最核心重要的一環。mock 的意思,便是通過一個虛假對象,來代替被測試函數或模塊需要的對象。
舉個例子,比如你要測一個后端 API 邏輯的功能性,但一般后端 API 都依賴於數據庫、文件系統、網絡等。這樣,你就需要通過 mock,來創建一些虛假的數據庫層、文件系統層、網絡層對象,以便可以簡單地對核心后端邏輯單元進行測試。
Python mock 則主要使用 mock 或者 MagicMock 對象,這里我也舉了一個代碼示例。這個例子看上去比較簡單,但是里面的思想很重要。下面我們一起來看下:
import unittest from unittest.mock import MagicMock class A(unittest.TestCase): def m1(self): val = self.m2() self.m3(val) def m2(self): pass def m3(self, val): pass def test_m1(self): a = A() a.m2 = MagicMock(return_value="custom_val") a.m3 = MagicMock() a.m1() self.assertTrue(a.m2.called) #驗證m2被call過 a.m3.assert_called_with("custom_val") #驗證m3被指定參數call過 if __name__ == '__main__': unittest.main() ## 輸出 .. ---------------------------------------------------------------------- Ran 2 tests in 0.002s OK
這段代碼中,我們定義了一個類的三個方法 m1()、m2()、m3()。我們需要對 m1() 進行單元測試,但是 m1() 取決於 m2() 和 m3()。如果 m2() 和 m3() 的內部比較復雜, 你就不能只是簡單地調用 m1() 函數來進行測試,可能需要解決很多依賴項的問題。
這一聽就讓人頭大了吧?但是,有了 mock 其實就很好辦了。我們可以把 m2() 替換為一個返回具體數值的 value,把 m3() 替換為另一個 mock(空函數)。這樣,測試 m1() 就很容易了,我們可以測試 m1() 調用 m2(),並且用 m2() 的返回值調用 m3()。
可能你會疑惑,這樣測試 m1() 不是基本上毫無意義嗎?看起來只是象征性地測了一下邏輯呀?
其實不然,真正工業化的代碼,都是很多層模塊相互邏輯調用的一個樹形結構。單元測試需要測的是某個節點的邏輯功能,mock 掉相關的依賴項是非常重要的。這也是為什么會被叫做單元測試 unit test,而不是其他的 integration test、end to end test 這類。
Mock Side Effect
第二個我們來看 Mock Side Effect,這個概念很好理解,就是 mock 的函數,屬性是可以根據不同的輸入,返回不同的數值,而不只是一個 return_value。
比如下面這個示例,例子很簡單,測試的是輸入參數是否為負數,輸入小於 0 則輸出為 1 ,否則輸出為 2。代碼很簡短,你一定可以看懂,這便是 Mock Side Effect 的用法。
from unittest.mock import MagicMock def side_effect(arg): if arg < 0: return 1 else: return 2 mock = MagicMock() mock.side_effect = side_effect mock(-1) 1 mock(1) 2
patch
至於 patch,給開發者提供了非常便利的函數 mock 方法。它可以應用 Python 的 decoration 模式或是 context manager 概念,快速自然地 mock 所需的函數。它的用法也不難,我們來看代碼:
from unittest.mock import patch @patch('sort') def test_sort(self, mock_sort): ... ...
在這個 test 里面,mock_sort 替代 sort 函數本身的存在,所以,我們可以像開始提到的 mock object 一樣,設置 return_value 和 side_effect。
另一種 patch 的常見用法,是 mock 類的成員函數,這個技巧我們在工作中也經常會用到,比如說一個類的構造函數非常復雜,而測試其中一個成員函數並不依賴所有初始化的 object。它的用法如下:
with patch.object(A, '__init__', lambda x: None):
…
代碼應該也比較好懂。在 with 語句里面,我們通過 patch,將 A 類的構造函數 mock 為一個 lambda 函數,這樣就可以很方便地避免一些復雜的初始化(initialization)。
其實,綜合前面講的這幾點來看,你應該感受到了,單元測試的核心還是 mock,mock 掉依賴項,測試相應的邏輯或算法的准確性。在我看來,雖然 Python unittest 庫還有很多層出不窮的方法,但只要你能掌握了 MagicMock 和 patch,編寫絕大部分工作場景的單元測試就不成問題了。
高質量單元測試的關鍵
這節課的最后,我想談一談高質量的單元測試。我很理解,單元測試這個東西,哪怕是正在使用的人也是“百般討厭”的,不少人很多時候只是敷衍了事。我也嫌麻煩,但從來不敢松懈,因為在大公司里,如果你寫一個很重要的模塊功能,不寫單元測試是無法通過代碼評審的。
低質量的單元測試,可能真的就是擺設,根本不能幫我們驗證代碼的正確性,還浪費時間。那么,既然要做單元測試,與其浪費時間糊弄自己,不如追求高質量的單元測試,切實提高代碼品質。
那該怎么做呢?結合工作經驗,我認為一個高質量的單元測試,應該特別關注下面兩點。
Test Coverage
首先我們要關注 Test Coverage,它是衡量代碼中語句被 cover 的百分比。可以說,提高代碼模塊的 Test Coverage,基本等同於提高代碼的正確性。
為什么呢?
要知道,大多數公司代碼庫的模塊都非常復雜。盡管它們遵從模塊化設計的理念,但因為有復雜的業務邏輯在,還是會產生邏輯越來越復雜的模塊。所以,編寫高質量的單元測試,需要我們 cover 模塊的每條語句,提高 Test Coverage。
我們可以用 Python 的 coverage tool 來衡量 Test Coverage,並且顯示每個模塊為被 coverage 的語句。如果你想了解更多更詳細的使用,可以點擊這個鏈接來學習:https://coverage.readthedocs.io/en/v4.5.x/ 。
模塊化
高質量單元測試,不僅要求我們提高 Test Coverage,盡量讓所寫的測試能夠 cover 每個模塊中的每條語句;還要求我們從測試的角度審視 codebase,去思考怎么模塊化代碼,以便寫出高質量的單元測試。
光講這段話可能有些抽象,我們來看這樣的場景。比如,我寫了一個下面這個函數,對一個數組進行處理,並返回新的數組:
def work(arr): # pre process ... ... # sort l = len(arr) for i in range(0, l): for j in range(i + 1, j): if arr[i] >= arr[j]: tmp = arr[i] arr[i] = arr[j] arr[j] = tmp # post process ... ... Return arr
這段代碼的大概意思是,先有個預處理,再排序,最后再處理一下然后返回。如果現在要求你,給這個函數寫個單元測試,你是不是會一籌莫展呢?
畢竟,這個函數確實有點兒復雜,以至於你都不知道應該是怎樣的輸入,並要期望怎樣的輸出。這種代碼寫單元測試是非常痛苦的,更別談 cover 每條語句的要求了。
所以,正確的測試方法,應該是先模塊化代碼,寫成下面的形式:
def preprocess(arr): ... return arr def sort(arr): ... return arr def postprocess(arr): ... return arr def work(self): arr = preprocess(arr) arr = sort(arr) arr = postprocess(arr) return arr
接着再進行相應的測試,測試三個子函數的功能正確性;然后通過 mock 子函數,調用 work() 函數,來驗證三個子函數被 call 過。
from unittest.mock import patch def test_preprocess(self): ... def test_sort(self): ... def test_postprocess(self): ... @patch('%s.preprocess') @patch('%s.sort') @patch('%s.postprocess') def test_work(self,mock_post_process, mock_sort, mock_preprocess): work() self.assertTrue(mock_post_process.called) self.assertTrue(mock_sort.called) self.assertTrue(mock_preprocess.called)
你看,這樣一來,通過重構代碼就可以使單元測試更加全面、精確,並且讓整體架構、函數設計都美觀了不少。
總結
回顧下這節課,整體來看,單元測試的理念是先模塊化代碼設計,然后針對每個作用單元,編寫單獨的測試去驗證其准確性。更好的模塊化設計和更多的 Test Coverage,是提高代碼質量的核心。而單元測試的本質就是通過 mock,去除掉不影響測試的依賴項,把重點放在需要測試的代碼核心邏輯上。
講了這么多,還是想告訴你,單元測試是個非常非常重要的技能,在實際工作中是保證代碼質量和准確性必不可少的一環。同時,單元測試的設計技能,不只是適用於 Python,而是適用於任何語言。所以,單元測試必不可少。