Mock 在 Python 單元測試中的使用
本文講述的是 Python 中 Mock 的使用。
如何執行單元測試而不用考驗你的耐心
很多時候,我們編寫的軟體會直接與那些被標記為“垃圾”的服務互動。用外行人的話說:服務對我們的應用程式很重要,但是我們想要的是互動,而不是那些不想要的副作用,這裡的“不想要”是在自動化測試執行的語境中說的。例如:我們正在寫一個社交 app,並且想要測試一下 "釋出到 Facebook" 的新功能,但是不想每次執行測試集的時候真的釋出到 Facebook。
Python 的 unittest
庫包含了一個名為 unittest.mock
或者可以稱之為依賴的子包,簡稱為 mock
—— 其提供了極其強大和有用的方法,透過它們可以模擬並去除那些我們不希望的副作用。
注意:mock
最近被收錄到了 Python 3.3 的標準庫中;先前釋出的版本必須透過 PyPI 下載 Mock 庫。
恐懼系統呼叫
再舉另一個例子,我們在接下來的部分都會用到它,這是就是系統呼叫。不難發現,這些系統呼叫都是主要的模擬物件:無論你是正在寫一個可以彈出 CD 驅動器的指令碼,還是一個用來刪除 /tmp 下過期的快取檔案的 Web 服務,或者一個繫結到 TCP 埠的 socket 伺服器,這些呼叫都是在你的單元測試上下文中不希望產生的副作用。
作為一個開發者,你需要更關心你的庫是否成功地呼叫了一個可以彈出 CD 的系統函式(使用了正確的引數等等),而不是切身經歷 CD 托盤每次在測試執行的時候都開啟了。(或者更糟糕的是,彈出了很多次,在一個單元測試執行期間多個測試都引用了彈出程式碼!)
同樣,保持單元測試的效率和效能意味著需要讓如此多的“緩慢程式碼”遠離自動測試,比如檔案系統和網路訪問。
對於第一個例子來說,我們要從原始形式換成使用 mock
重構一個標準 Python 測試用例。我們會演示如何使用 mock 寫一個測試用例,使我們的測試更加智慧、快速,並展示更多關於我們軟體的工作原理。
一個簡單的刪除函式
我們都有過需要從檔案系統中一遍又一遍的刪除檔案的時候,因此,讓我們在 Python 中寫一個可以使我們的指令碼更加輕易完成此功能的函式。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
def rm(filename):
os.remove(filename)
很明顯,我們的 rm
方法此時無法提供比 os.remove
方法更多的相關功能,但我們可以在這裡新增更多的功能,使我們的基礎程式碼逐步改善。
讓我們寫一個傳統的測試用例,即,沒有使用 mock
:
#!/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 重構
讓我們使用 mock 重構我們的測試用例:
#!/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
的物件,並且將 mock 注入到我們的測試用例方法。那麼只是模擬 os
本身,而不是 mymodule.os
下 os
的引用(LCTT 譯註:注意 @mock.patch('mymodule.os')
便是模擬 mymodule.os
下的 os
),會不會更有意義呢?
當然,當涉及到匯入和管理模組,Python 的用法就像蛇一樣靈活。在執行時,mymodule
模組有它自己的被匯入到本模組區域性作用域的 os
。因此,如果我們模擬 os
,我們是看不到 mock 在 mymodule
模組中的模仿作用的。
這句話需要深刻地記住:
模擬一個東西要看它用在何處,而不是來自哪裡。
如果你需要為 myproject.app.MyElaborateClass
模擬 tempfile
模組,你可能需要將 mock 用於 myproject.app.tempfile
,而其他模組保持自己的匯入。
先將那個陷阱放一邊,讓我們繼續模擬。
向 ‘rm’ 中加入驗證
之前定義的 rm 方法相當的簡單。在盲目地刪除之前,我們傾向於驗證一個路徑是否存在,並驗證其是否是一個檔案。讓我們重構 rm 使其變得更加智慧:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import os.path
def rm(filename):
if os.path.isfile(filename):
os.remove(filename)
很好。現在,讓我們調整測試用例來保持測試的覆蓋率。
#!/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")
我們的測試用例完全改變了。現在我們可以在沒有任何副作用的情況下核實並驗證方法的內部功能。
將檔案刪除作為服務
到目前為止,我們只是將 mock 應用在函式上,並沒應用在需要傳遞引數的物件和例項的方法上。我們現在開始涵蓋物件的方法。
首先,我們將 rm
方法重構成一個服務類。實際上將這樣一個簡單的函式轉換成一個物件,在本質上這不是一個合理的需求,但它能夠幫助我們瞭解 mock
的關鍵概念。讓我們開始重構:
#!/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)
你會注意到我們的測試用例沒有太大變化:
#!/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
會如預期般的工作。接下來讓我們建立另一個服務,將 RemovalService
宣告為它的一個依賴:
#!/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
庫有一個特殊的方法裝飾器,可以模擬物件例項的方法和屬性,即 @mock.patch.object decorator
裝飾器:
#!/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")
非常棒!我們驗證了 UploadService
成功呼叫了我們例項的 rm
方法。你是否注意到一些有趣的地方?這種修補機制(patching mechanism)實際上替換了我們測試用例中的所有 RemovalService
例項的 rm
方法。這意味著我們可以檢查例項本身。如果你想要了解更多,可以試著在你模擬的程式碼下斷點,以對這種修補機制的原理獲得更好的認識。
陷阱:裝飾順序
當我們在測試方法中使用多個裝飾器,其順序是很重要的,並且很容易混亂。基本上,當裝飾器被對映到方法引數時,裝飾器的工作順序是反向的。思考這個例子:
@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 的工作方式所導致的。這裡是使用多個裝飾器的情況下它們執行順序的虛擬碼:
patch_sys(patch_os(patch_os_path(test_something)))
因為 sys
補丁位於最外層,所以它最晚執行,使得它成為實際測試方法引數的最後一個引數。請特別注意這一點,並且在執行你的測試用例時,使用偵錯程式來保證正確的引數以正確的順序注入。
方法 2:建立 Mock 例項
我們可以使用建構函式為 UploadService
提供一個 Mock 例項,而不是模擬特定的例項方法。我更推薦方法 1,因為它更加精確,但在多數情況,方法 2 或許更加有效和必要。讓我們再次重構測試用例:
#!/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")
在這個例子中,我們甚至不需要修補任何功能,只需為 RemovalService
類建立一個 auto-spec,然後將例項注入到我們的 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,接受所有的方法呼叫和屬性賦值。比如下面這個用例:
class Target(object):
def apply(value):
return value
def method(target, value):
return target.apply(value)
我們可以像下面這樣使用 mock.Mock 例項進行測試:
class MethodTestCase(unittest.TestCase):
def test_method(self):
target = mock.Mock()
method(target, "value")
target.apply.assert_called_with("value")
這個邏輯看似合理,但如果我們修改 Target.apply
方法接受更多引數:
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。我將寫一個不錯的包裝類及其對應的測試用例。
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)
這是我們的測試用例,它可以檢查我們釋出的訊息,而不是真正地釋出訊息:
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 總結
即使對它的使用還有點不太熟悉,對單元測試來說,Python 的 mock
庫可以說是一個規則改變者。我們已經演示了常見的用例來了解了 mock
在單元測試中的使用,希望這篇文章能夠幫助 Python 開發者克服初期的障礙,寫出優秀、經受過考驗的程式碼。
via: https://www.toptal.com/python/an-introduction-to-mocking-in-python
作者:NAFTULI TZVI KAY 譯者:cposture 校對:wxy
本文由 LCTT 原創翻譯,Linux中國 榮譽推出
相關文章
- 單元測試:單元測試中的mockMock
- Go 單元測試之mock介面測試GoMock
- 單元測試-mock使用應該注意什麼Mock
- 掌握 xUnit 單元測試中的 Mock 與 Stub 實戰Mock
- Python中的單元測試框架:使用unittest進行有效測試Python框架
- 使用SAP CRM mock框架進行單元測試的設計Mock框架
- 搞定Go單元測試(二)—— mock框架(gomock)GoMock框架
- 單元測試在Unity中的應用Unity
- python 單元測試Python
- 首次在WebAPI中寫單元測試WebAPI
- 換種思路寫Mock,讓單元測試更簡單Mock
- 在C#中進行單元測試C#
- java中的單元測試Java
- .net持續整合單元測試篇之單元測試簡介以及在visual studio中配置Nunit使用環境
- 史上最輕量!阿里新型單元測試 Mock 工具開源阿里Mock
- 測試 之Java單元測試、Android單元測試JavaAndroid
- 單元測試工具 TestNG 使用
- PHP單元測試框架PHPUnit的使用PHP框架
- .NET 專案中的單元測試
- 單元測試-【轉】論單元測試的重要性
- 使用JUnit進行單元測試
- Cmocka 單元測試配置與使用Mock
- 使用jest進行單元測試
- Python和單元測試那些事兒Python
- Java中的單元測試與整合測試最佳實踐Java
- Mock技術在測試領域的應用Mock
- 使用 YApi 管理 API 文件,測試, mockAPIMock
- Python單元測試框架pytest常用測試報告型別Python框架測試報告型別
- 使用 PyHamcrest 執行健壯的單元測試REST
- springboot系列文章之使用單元測試Spring Boot
- 使用Jest進行React單元測試React
- 如何使用MOQ進行單元測試
- 使用 Spring Boot 進行單元測試Spring Boot
- 求助,在Laravel 8的單元測試中,使用 資料工廠建立模型,faker不可用Laravel模型
- 單元測試,只是測試嗎?
- 鮑勃大爺:單元測試中單元是多小?
- 單元測試-一份如何寫好單元測試的參考
- 使用ABAP實現Mock測試工具MockitoMockito