如何不靠耐心測試
通常,我們編寫的軟體會直接與那些我們稱之為“骯髒的”服務互動。通俗地說,服務對我們的應用來說是至關重要的,它們之間的互動是我們設計好的,但這會帶來我們不希望的副作用——就是那些在我們自己測試的時候不希望的功能。
比如,可能我們正在寫一個社交軟體並且想測試一下“釋出到Facebook的功能”,但是我們不希望每次執行測試集的時候都發布到Facebook上。
Python的unittest庫中有一個子包叫unittest.mock——或者你把它宣告成一個依賴,簡化為mock——這個模組提供了非常強大並且有用的方法,通過它們可以模擬或者屏敝掉這些不受我們希望的方面。
注意:mock是最近收錄在Python 3.3標準庫中的;之前釋出的版本必須通過 PyPI下載Mock庫。
恐懼系統呼叫
再舉一個例子,考慮系統呼叫,我們將在餘下的文章中討論它們。不難發現,這些都可以考慮使用模擬:無論你是想寫一個指令碼彈出一個CD驅動,或者是一個web服務用來刪除/tmp目錄下的快取檔案,或者是一個socket服務來繫結一個TCP埠,這些呼叫都是在你單元測試的時候是不被希望的方面。
作為一個開發人員,你更關心你的庫是不是成功的呼叫了系統函式來彈出CD,而不是體驗每次測試的時候CD托盤都開啟。
作為一個開發人員,你更關心你的庫是不是成功呼叫了系統函式來彈出CD(帶著正確的引數等)。而不是體驗每次測試的時候CD托盤都開啟(或者更糟,很多次,當一個單元測試執行的時候,很多測試點都涉及到了彈出程式碼)。
同樣地,保持你的單元測試效率和效能意味著要還要保留一些自動化測試之外的“緩慢程式碼”,比如檔案系統和網路的訪問。
對於我們的第一個例子,我們要重構一個從原始到使用mock的一個標準Python測試用例。我們將會證明如何用mock寫一個測試用例使我們的測試更智慧、更快,並且能暴露更多關於我們的軟體工作的問題。
一個簡單的刪除功能
有時,我們需要從檔案系統中刪除檔案,因此,我們可以寫這樣的一個函式在Python中,這個函式將使它更容易成為我們的指令碼去完成這件事情。
1 2 3 4 5 6 7 |
#!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename) |
很明顯,在這個時間點上,我們的rm方法不提供比基本os.remove方法更多的功能,但我們的程式碼將會有所改進,允許我們在這裡新增更多的功能。
讓我們寫一個傳統的測試用例,即,不用模擬測試:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import os.path import tempfile import unittest class RmTestCase(unittest.TestCase): tmpfilepath = os.path.join(tempfile.gettempdir(), "tmp-testfile") def setUp(self): with open(self.tmpfilepath, "wb") as f: f.write("Delete me!") def test_rm(self): # remove the file rm(self.tmpfilepath) # test that it was actually removed self.assertFalse(os.path.isfile(self.tmpfilepath), "Failed to remove the file.") |
我們的測試用例是相當簡單的,但當它每次執行時,一個臨時檔案被建立然後被刪除。此外,我們沒有辦法去測試我們的rm方法是否傳遞引數到os.remove中。我們可以假設它是基於上面的測試,但仍有許多需要被證實。
重構與模擬測試
讓我們使用mock重構我們的測試用例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os') def test_rm(self, mock_os): rm("any path") # test that rm called os.remove with the right parameters mock_os.remove.assert_called_with("any path") |
對於這些重構,我們已經從根本上改變了該測試的執行方式。現在,我們有一個內部的物件,讓我們可以使用另一個功能驗證。
潛在的陷阱
第一件要注意的事情就是,我們使用的mock.patch方法的裝飾位於mymodule.os模擬物件,並注入到我們測試案例的模擬方法。是模擬os更有意義,還是它在mymodule.os的參考更有意義?
當然,當Python出現在進口和管理模組時,用法是非常的靈活。在執行時,該mymodule模組有自己的os作業系統——被引入到自己的範圍內的模組。因此,如果我們模擬os系統,我們不會看到模擬測試在mymodule模組的影響。
這句話需要深刻的記住:
1 |
模擬測試一個專案,只需要瞭解它用在哪裡,而不是它從哪裡來. |
如果你需要為myproject.app.MyElaborateClass模擬tempfile模型,你可能需要去模擬myproject.app.tempfile的每個模組來保持自己的進口。
這就是用陷阱的方式來模擬測試。
向‘rm’中加入驗證
之前定義的 rm 方法相當的簡單 . 在盲目的刪除之前,我們會拿它來驗證一個路徑是否存在,並驗證其是否是一個檔案. 讓我們重構 rm 使其變得更加聰明:
1 2 3 4 5 6 7 8 9 |
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename) |
很好. 現在,讓我們調整我們的測試用例來保持測試的覆蓋程度。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # set up the mock mock_path.isfile.return_value = False rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True rm("any path") mock_os.remove.assert_called_with("any path") |
我們的測試範例完全變化了. 現在我們可以核實並驗證方法的內部功能是否有任何副作用.
將刪除功能作為服務
到目前為止,我們只是對函式功能提供模擬測試,並沒對需要傳遞引數的物件和例項的方法進行模擬測試。接下來我們將介紹如何對物件的方法進行模擬測試。
首先,我們先將rm方法重構成一個服務類。實際上將這樣一個簡單的函式轉換成一個物件並不需要做太多的調整,但它能夠幫助我們瞭解mock的關鍵概念。下面是重構的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 |
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(filename): if os.path.isfile(filename): os.remove(filename) |
你可以發現我們的測試用例實際上沒有做太多的改變:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") |
很好,RemovalService如同我們計劃的一樣工作。接下來讓我們建立另一個以該物件為依賴項的服務:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(self, filename): if os.path.isfile(filename): os.remove(filename) class UploadService(object): def __init__(self, removal_service): self.removal_service = removal_service def upload_complete(self, filename): self.removal_service.rm(filename) |
到目前為止,我們的測試已經覆蓋了RemovalService, 我們不會對我們測試用例中UploadService的內部函式rm進行驗證。相反,我們將呼叫UploadService的RemovalService.rm方法來進行簡單的測試(為了不產生其他副作用),我們通過之前的測試用例可以知道它可以正確地工作。
有兩種方法可以實現以上需求:
- 模擬RemovalService.rm方法本身。
- 在UploadService類的建構函式中提供一個模擬例項。
因為這兩種方法都是單元測試中非常重要的方法,所以我們將同時對這兩種方法進行回顧。
選項1: 模擬例項的方法
該模擬庫有一個特殊的方法用來裝飾模擬物件例項的方法和引數。@mock.patch.object 進行裝飾:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): @mock.patch.object(RemovalService, 'rm') def test_upload_complete(self, mock_rm): # build our dependencies removal_service = RemovalService() reference = UploadService(removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # check that it called the rm method of any RemovalService mock_rm.assert_called_with("my uploaded file") # check that it called the rm method of _our_ removal_service removal_service.rm.assert_called_with("my uploaded file") |
太棒了!我們驗證了上傳服務成功呼叫了例項的rm方法。你是不是注意到這當中有意思的地方了?這種修補機制實際上取代了我們的測試方法的刪除服務例項的rm方法。這意味著,我們實際上可以檢查該例項本身。如果你想了解更多,可以試著在模擬測試的程式碼中下斷點來更好的認識這種修補機制是如何工作的。
陷阱:裝飾的順序
當使用多個裝飾方法來裝飾測試方法的時候,裝飾的順序很重要,但很容易混亂。基本上,當裝飾方法唄對映到帶引數的測試方法中時,裝飾方法的工作順序是反向的。比如下面這個例子:
1 2 3 4 5 |
@mock.patch('mymodule.sys') @mock.patch('mymodule.os') @mock.patch('mymodule.os.path') def test_something(self, mock_os_path, mock_os, mock_sys): pass |
注意到了嗎,我們的裝飾方法的引數是反向匹配的? 這是有部分原因是因為Python的工作方式。下面是使用多個裝飾方法的時候,實際的程式碼執行順序:
1 |
patch_sys(patch_os(patch_os_path(test_something))) |
由於這個關於sys的補丁在最外層,因此會在最後被執行,使得它成為實際測試方法的最後一個引數。請特別注意這一點,並且在做測試使用偵錯程式來保證正確的引數按照正確的順序被注入。
選項2: 建立模擬測試介面
我們可以在UploadService的建構函式中提供一個模擬測試例項,而不是模擬建立具體的模擬測試方法。 我推薦使用選項1的方法,因為它更精確,但在多數情況下,選項2是必要的並且更加有效。讓我們再次重構我們的測試例項:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): def test_upload_complete(self, mock_rm): # build our dependencies mock_removal_service = mock.create_autospec(RemovalService) reference = UploadService(mock_removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # test that it called the rm method mock_removal_service.rm.assert_called_with("my uploaded file") |
在這個例子中,我們甚至不需要補充任何功能,只需建立一個帶auto-spec方法的RemovalService類,然後將該例項注入到UploadService中對方法驗證。
mock.create_autospec為類提供了一個同等功能例項。這意味著,實際上來說,在使用返回的例項進行互動的時候,如果使用了非法的方法將會引發異常。更具體地說,如果一個方法被呼叫時的引數數目不正確,將引發一個異常。這對於重構來說是非常重要。當一個庫發生變化的時候,中斷測試正是所期望的。如果不使用auto-spec,即使底層的實現已經破壞,我們的測試仍然會通過。
陷阱:mock.Mock和mock.MagicMock類
mock庫包含兩個重要的類mock.Mock和mock.MagicMock,大多數內部函式都是建立在這兩個類之上的。在選擇使用mock.Mock例項,mock.MagicMock例項或auto-spec方法的時候,通常傾向於選擇使用 auto-spec方法,因為它能夠對未來的變化保持測試的合理性。這是因為mock.Mock和mock.MagicMock會無視底層的API,接受所有的方法呼叫和引數賦值。比如下面這個用例:
1 2 3 4 5 6 |
class Target(object): def apply(value): return value def method(target, value): return target.apply(value) |
我們像下面這樣使用mock.Mock例項來做測試:
1 2 3 4 5 6 7 8 |
class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value") |
這個邏輯看似合理,但如果我們修改Target.apply方法接受更多引數:
1 2 3 4 5 6 |
class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None |
重新執行你的測試,然後你會發現它仍然能夠通過。這是因為它不是針對你的API建立的。這就是為什麼你總是應該使用create_autospec方法,並且在使用@patch和@patch.object裝飾方法時使用autospec引數。
真實世界的例子: 模仿一次 Facebook API 呼叫
在結束之際,讓我寫一個更加實用的真實世界的例子, 這在我們的介紹部分曾今提到過: 向Facebook傳送一個訊息. 我們會寫一個漂亮的封裝類,和一個產生回應的測試用例
1 2 3 4 5 6 7 8 9 10 |
import facebook class SimpleFacebook(object): def __init__(self, oauth_token): self.graph = facebook.GraphAPI(oauth_token) def post_message(self, message): """Posts a message to the Facebook wall.""" self.graph.put_object("me", "feed", message=message) |
下面是我們的測試用例, 它檢查到我傳送了資訊,但並沒有實際的傳送出這條資訊(到Facebook上):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import facebook import simple_facebook import mock import unittest class SimpleFacebookTestCase(unittest.TestCase): @mock.patch.object(facebook.GraphAPI, 'put_object', autospec=True) def test_post_message(self, mock_put_object): sf = simple_facebook.SimpleFacebook("fake oauth token") sf.post_message("Hello World!") # verify mock_put_object.assert_called_with(message="Hello World!") |
就我們目前所看到的,在Python中用 mock 開始編寫更加聰明的測試是真的很簡單的.
總結
Python的 mock 庫, 使用起來是有點子迷惑, 是單元測試的遊戲規則變革者. 我們通過開始在單元測試中使用 mock ,展示了一些通常的使用場景, 希望這篇文章能幫助 Python 克服一開始的障礙,寫出優秀的,能經得起測試的程式碼.