1. 程式人生 > >[翻譯]Mock 在 Python 中的使用介紹

[翻譯]Mock 在 Python 中的使用介紹

訪問 測試用例 intro 智能 有時 rom 了解 AS 並且

Mock 在 Python 中的使用介紹

[TOC]

原文鏈接與說明

  1. https://www.toptal.com/python/an-introduction-to-mocking-in-python
  2. 本翻譯文檔原文選題自 Linux中國 ,翻譯文檔版權歸屬 Linux中國 所有

本文講述的是 Python 中 Mock 的使用

如何在避免測試你的耐心的情況下執行單元測試

很多時候,我們編寫的軟件會直接與那些被標記為骯臟無比的服務交互。用外行人的話說:交互已設計好的服務對我們的應用程序很重要,但是這會給我們帶來不希望的副作用,也就是那些在一個自動化測試運行的上下文中不希望的功能。

例如:我們正在寫一個社交 app,並且想要測試一下 "發布到 Facebook" 的新功能,但是不想每次運行測試集的時候真的發布到 Facebook。

Python 的 unittest 庫包含了一個名為 unittest.mock 或者可以稱之為依賴的子包,簡稱為
mock —— 其提供了極其強大和有用的方法,通過它們可以模擬和打樁來去除我們不希望的副作用。

技術分享圖片

註意:mock 最近收錄到了 Python 3.3 的標準庫中;先前發布的版本必須通過 PyPI 下載 Mock 庫。

恐懼系統調用

再舉另一個例子,思考一個我們會在余文討論的系統調用。不難發現,這些系統調用都是主要的模擬對象:無論你是正在寫一個可以彈出 CD 驅動的腳本,還是一個用來刪除 /tmp 下過期的緩存文件的 Web 服務,或者一個綁定到 TCP 端口的 socket 服務器,這些調用都是在你的單元測試上下文中不希望的副作用。

作為一個開發者,你需要更關心你的庫是否成功地調用了一個可以彈出 CD 的系統函數,而不是切身經歷 CD 托盤每次在測試執行的時候都打開了。

作為一個開發者,你需要更關心你的庫是否成功地調用了一個可以彈出 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.osos 的引用(註意 @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 進行驗證。相反,我們將調用 UploadServiceRemovalService.rm 方法來進行簡單測試(當然沒有其他副作用),我們通過之前的測試用例便能知道它可以正確地工作。

這裏有兩種方法來實現測試:

  1. 模擬 RemovalService.rm 方法本身。
  2. 在 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.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 方法,並且在使用 @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

[翻譯]Mock 在 Python 中的使用介紹