Python 中的 Mocking 簡介

已發表: 2022-03-11

如何在不測試耐心的情況下在 Python 中運行單元測試

通常情況下,我們編寫的軟件直接與我們標記為“骯髒”的服務交互。 通俗地說:對我們的應用程序至關重要的服務,但其交互具有預期但不希望出現的副作用,即在自主測試運行的上下文中是不希望出現的。

例如:也許我們正在編寫一個社交應用程序並想測試我們的新“發佈到 Facebook 功能”,但不想在每次運行我們的測試套件時實際發佈到 Facebook。

Python unittest庫包含一個名為unittest.mock的子包——或者如果您將其聲明為依賴項,則簡單地mock它——它提供了極其強大和有用的方法來模擬和排除這些不希望的副作用。

python unittest庫中的模擬和單元測試

注意:從 Python 3.3 開始,標準庫中新包含了mock 以前的發行版必須使用可通過 PyPI 下載的 Mock 庫。

系統調用與 Python 模擬

再舉一個例子,我們將在本文的其餘部分使用這個例子,考慮系統調用。 不難看出這些是模擬的主要候選對象:無論您是在編寫用於彈出 CD 驅動器的腳本、從/tmp刪除過時緩存文件的 Web 服務器,還是綁定到 TCP 端口的套接字服務器,這些在單元測試的上下文中調用所有功能不需要的副作用。

作為開發人員,您更關心您的庫是否成功調用了用於彈出 CD 的系統函數,而不是每次運行測試時都會遇到 CD 托盤打開的情況。

作為開發人員,您更關心您的庫是否成功調用了用於彈出 CD 的系統函數(使用正確的參數等),而不是每次運行測試時實際體驗到您的 CD 托盤打開。 (或者更糟糕的是,多次,因為多個測試在單個單元測試運行期間引用彈出代碼!)

同樣,保持單元測試高效和高性能意味著在自動化測試運行(即文件系統和網絡訪問)之外保留盡可能多的“慢代碼”。

對於我們的第一個示例,我們將使用mock將標準 Python 測試用例從原始形式重構為一個。 我們將演示如何使用模擬編寫測試用例將使我們的測試更智能、更快,並能夠更多地揭示軟件的工作原理。

一個簡單的刪除函數

我們都需要不時地從文件系統中刪除文件,所以讓我們用 Python 編寫一個函數,這將使我們的腳本更容易執行此操作。

 #!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)

顯然,此時我們的rm方法並沒有提供比底層os.remove方法更多的功能,但是我們的代碼庫將會改進,允許我們在這裡添加更多功能。

讓我們編寫一個傳統的測試用例,即沒有模擬:

 #!/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調用。 我們可以根據上面的測試假設它確實如此,但還有很多不足之處。

使用 Python Mocks 進行重構

讓我們使用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")

通過這些重構,我們從根本上改變了測試的運行方式。 現在,我們有了一個Insider ,一個我們可以用來驗證另一個功能的對象。

潛在的 Python 模擬陷阱

應該突出的第一件事是我們正在使用mock.patch方法裝飾器來模擬位於mymodule.os的對象,並將該模擬注入到我們的測試用例方法中。 僅僅模擬os本身而不是在mymodule.os中對它的引用不是更有意義嗎?

好吧,在導入和管理模塊方面,Python 有點狡猾。 在運行時, mymodule模塊有自己的os ,該操作系統被導入到模塊中自己的本地範圍中。 因此,如果我們模擬os ,我們將不會在mymodule模塊中看到模擬的效果。

不斷重複的口頭禪是:

在使用它的地方模擬一個項目,而不是它來自哪裡。

如果您需要為myproject.app.MyElaborateClass模擬tempfile模塊,您可能需要將模擬應用於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")

我們的測試範式已經完全改變。 我們現在可以驗證和驗證方法的內部功能,而不會產生任何副作用。

使用模擬補丁將文件刪除作為服務

到目前為止,我們只為函數提供模擬,而不是為對像上的方法或發送參數需要模擬的情況。 讓我們首先介紹對象方法。

我們將從將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按計劃工作。 讓我們創建另一個將其聲明為依賴項的服務:

 #!/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方法,從我們之前的測試用例中我們知道該方法“正常工作”。

有兩種方法可以解決這個問題:

  1. 模擬出RemovalService.rm方法本身。
  2. UploadService的構造函數中提供一個模擬實例。

由於這兩種方法在單元測試中通常都很重要,因此我們將回顧這兩種方法。

選項 1:模擬實例方法

mock庫有一個特殊的方法裝飾器,用於模擬對象實例方法和屬性, @mock.patch.object裝飾器:

 #!/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方法。 注意到裡面有什麼有趣的東西嗎? 補丁機制實際上取代了我們測試方法中所有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:創建模擬實例

我們可以代替模擬特定的實例方法,而是提供一個模擬的實例給UploadService及其構造函數。 我更喜歡上面的選項 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類創建一個自動規範,然後將此實例注入到UploadService中以驗證功能。

mock.create_autospec方法為提供的類創建一個功能等效的實例。 這實際上意味著,當與返回的實例進行交互時,如果以非法方式使用它將引發異常。 更具體地說,如果使用錯誤數量的參數調用方法,則會引發異常。 當重構發生時,這一點非常重要。 隨著庫的變化,測試中斷,這是意料之中的。 如果不使用自動規範,即使底層實現被破壞,我們的測試仍然會通過。

陷阱: mock.Mockmock.MagicMock

mock庫還包括兩個重要的類,大多數內部功能都基於這些類構建: mock.Mockmock.MagicMock 。 當選擇使用mock.Mock實例、 mock.MagicMock實例或 auto-spec 時,總是傾向於使用 auto-spec,因為它有助於讓您的測試保持理智,以應對未來的變化。 這是因為mock.Mockmock.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方法和autospec參數與@patch@patch.object裝飾器一起使用。

Python 模擬示例:模擬 Facebook API 調用

最後,讓我們編寫一個更適用的真實 Python 模擬示例,我們在介紹中提到過:向 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庫,如果使用起來有點混亂,它是單元測試的遊戲規則改變者。 我們已經展示了在單元測試中開始使用mock的常見用例,希望本文能幫助 Python 開發人員克服最初的障礙並編寫出色的、經過測試的代碼。