利用Python中的mock庫對Python代碼進行類比測試

來源:互聯網
上載者:User
如何不靠耐心測試

通常,我們編寫的軟體會直接與那些我們稱之為“骯髒的”服務互動。通俗地說,服務對我們的應用來說是至關重要的,它們之間的互動是我們設計好的,但這會帶來我們不希望的副作用——就是那些在我們自己測試的時候不希望的功能。

比如,可能我們正在寫一個社交軟體並且想測試一下“發布到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中,這個函數將使它更容易成為我們的指令碼去完成這件事情。

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

很明顯,在這個時間點上,我們的rm方法不提供比基本os.remove方法更多的功能,但我們的代碼將會有所改進,允許我們在這裡添加更多的功能。

讓我們寫一個傳統的測試案例,即,不用類比測試:

#!/usr/bin/env python# -*- coding: utf-8 -*-from mymodule import rmimport os.pathimport tempfileimport unittestclass 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.tempfile), "Failed to remove the file.")

我們的測試案例是相當簡單的,但當它每次運行時,一個臨時檔案被建立然後被刪除。此外,我們沒有辦法去測試我們的rm方法是否傳遞參數到os.remove中。我們可以假設它是基於上面的測試,但仍有許多需要被證實。

重構與類比測試

讓我們使用mock重構我們的測試案例:

#!/usr/bin/env python# -*- coding: utf-8 -*-from mymodule import rmimport mockimport unittestclass 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模組的影響。

這句話需要深刻的記住:

複製代碼 代碼如下:

類比測試一個項目,只需要瞭解它用在哪裡,而不是它從哪裡來.

如果你需要為myproject.app.MyElaborateClass類比tempfile模型,你可能需要去類比myproject.app.tempfile的每個模組來保持自己的進口。

這就是用陷阱的方式來類比測試。

向‘rm'中加入驗證

之前定義的 rm 方法相當的簡單 . 在盲目的刪除之前,我們會拿它來驗證一個路徑是否存在,並驗證其是否是一個檔案. 讓我們重構 rm 使其變得更加聰明:

#!/usr/bin/env python# -*- coding: utf-8 -*-import osimport os.pathdef rm(filename):  if os.path.isfile(filename):    os.remove(filename)

很好. 現在,讓我們調整我們的測試案例來保持測試的覆蓋程度.

#!/usr/bin/env python# -*- coding: utf-8 -*-from mymodule import rmimport mockimport unittestclass 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 osimport os.pathclass 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 RemovalServiceimport mockimport unittestclass 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 osimport os.pathclass RemovalService(object):  """A service for removing objects from the filesystem."""   def rm(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(filename):    self.removal_service.rm(filename)

到目前為止,我們的測試已經覆蓋了RemovalService, 我們不會對我們測試案例中UploadService的內建函式rm進行驗證。相反,我們將調用UploadService的RemovalService.rm方法來進行簡單的測試(為了不產生其他副作用),我們通過之前的測試案例可以知道它可以正確地工作。

有兩種方法可以實現以上需求:

  1. 類比RemovalService.rm方法本身。
  2. 在UploadService類的建構函式中提供一個類比執行個體。

因為這兩種方法都是單元測試中非常重要的方法,所以我們將同時對這兩種方法進行回顧。

選項1: 類比執行個體的方法

該類比庫有一個特殊的方法用來裝飾類比對象執行個體的方法和參數。@mock.patch.object 進行裝飾:

#!/usr/bin/env python# -*- coding: utf-8 -*-from mymodule import RemovalService, UploadServiceimport mockimport unittestclass 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方法。這意味著,我們實際上可以檢查該執行個體本身。如果你想瞭解更多,可以試著在類比測試的代碼中下斷點來更好的認識這種修補機制是如何工作的。

陷阱:裝飾的順序

當使用多個裝飾方法來裝飾測試方法的時候,裝飾的順序很重要,但很容易混亂。基本上,當裝飾方法唄映射到帶參數的測試方法中時,裝飾方法的工作順序是反向的。比如下面這個例子:

  @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, UploadServiceimport mockimport unittestclass 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,接受所有的方法調用和參數賦值。比如下面這個用例:

class Target(object):  def apply(value):    return valuedef 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 facebookclass 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上):

import facebookimport simple_facebookimport mockimport unittestclass 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 克服一開始的障礙,寫出優秀的,能經得起測試的代碼.

  • 聯繫我們

    該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

    如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

    A Free Trial That Lets You Build Big!

    Start building with 50+ products and up to 12 months usage for Elastic Compute Service

    • Sales Support

      1 on 1 presale consultation

    • After-Sales Support

      24/7 Technical Support 6 Free Tickets per Quarter Faster Response

    • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.