Python 的mock模擬測試介紹

如何不靠耐心測試

可能咱們正在寫一個社交軟件而且想測試一下「發佈到Facebook的功能」,可是咱們不但願每次運行測試集的時候都發布到Facebook上。

Python的unittest庫中有一個子包叫unittest.mock——或者你把它聲明成一個依賴,簡化爲mock——這個模塊提供了很是強大而且有用的方法,經過它們能夠模擬或者屏敝掉這些不受咱們但願的方面。html

Python 的模擬測試介紹

注意:mock是最近收錄在Python 3.3標準庫中的;以前發佈的版本必須經過 PyPI下載Mock庫。python

恐懼系統調用

不管你是想寫一個腳本彈出一個CD驅動,或者是一個web服務用來刪除/tmp目錄下的緩存文件,或者是一個socket服務來綁定一個TCP端口,這些調用都是在你單元測試的時候是不被但願的方面。web

做爲一個開發人員,你更關心你的庫是否是成功的調用了系統函數來彈出CD,而不是體驗每次測試的時候CD托盤都打開。緩存

對於咱們的第一個例子,咱們要重構一個從原始到使用mock的一個標準Python測試用例。咱們將會證實如何用mock寫一個測試用例使咱們的測試更智能、更快,而且能暴露更多關於咱們的軟件工做的問題。app

一個簡單的刪除功能

有時,咱們須要從文件系統中刪除文件,所以,咱們能夠寫這樣的一個函數在Python中,這個函數將使它更容易成爲咱們的腳本去完成這件事情。socket

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

讓咱們寫一個傳統的測試用例,即,不用模擬測試:ide

#!/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.tempfile), "Failed to remove the file.")

當它每次運行時,一個臨時文件被建立而後被刪除。咱們沒有辦法去測試咱們的rm方法是否傳遞參數到os.remove中。咱們能夠假設它是基於上面的測試,但仍有許多須要被證明。函數

重構與模擬測試

讓咱們使用mock重構咱們的測試用例:post

#!/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")

對於這些重構,咱們已經從根本上改變了該測試的運行方式。單元測試

如今,mymodule模塊中的os對象已經被mock對象替換,當調用mymodule的os模塊的remove方法時,實際調用的是mock_os這個mock對象的remove方法。

向‘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")

咱們的測試範例徹底變化了.mymodule的os模塊的isfile方法也被mock對象替換。

將刪除功能做爲服務

到目前爲止,咱們只是對函數功能提供模擬測試,並沒對須要傳遞參數的對象和實例的方法進行模擬測試。接下來咱們將介紹如何對對象的方法進行模擬測試。

首先,咱們先將rm方法重構成一個服務類。下面是重構的代碼:

#!/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(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, 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方法。這意味着,咱們實際上能夠檢查該實例自己。若是你想了解更多,能夠試着在模擬測試的代碼中下斷點來更好的認識這種修補機制是如何工做的。

@mock.patch.object用來對一個對象的某個方法或者屬性進行替換。

陷阱:裝飾的順序

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

@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")

在這個例子中,咱們甚至不須要補充任何功能,只需建立一個帶auto-spec方法的RemovalService類,而後將該實例注入到UploadService中對方法驗證。

mock.create_autospec爲類提供了一個同等功能實例。這意味着,實際上來講,在使用返回的實例進行交互的時候,若是使用了非法的方法將會引起異常。更具體地說,若是一個方法被調用時的參數數目不正確,將引起一個異常。這對於重構來講是很是重要。當一個庫發生變化的時候,中斷測試正是所指望的。若是不使用auto-spec,即便底層的實現已經破壞,咱們的測試仍然會經過。

陷阱:mock.Mock和mock.MagicMock類

mock庫包含兩個重要的類mock.Mockmock.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 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上):

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 開始編寫更加聰明的測試是真的很簡單的.

如何用mock模擬python的builtin內建函數

from mymodule import test

class TestLogParse(unittest2.TestCase):

    @patch('__builtin__.open')
    def test_parse1(self,mock_open):
        mock_open.return_value = 'local'
        print open('abf')

    @patch('mymodule.open',create=True)
    def test_parse2(self,mock_open):
        mock_open.return_value = 'remote'
        test()

mock中side_effect的使用

爲mock對象指定side_effect屬性後,每次mock被調用,side_effect都將被調用,而且調用的參數也會被傳遞進來。咱們能夠根據這點來作一些判斷。

    @patch('mymodule.open',create=True)
    def test_parse(self,mock_open):
        def open_side_effect(*args, **kwargs):
            if len(args) == 1:
                return read_file
            else:
                return write_file
        read_file = StringIO.StringIO()
        write_file = StringIO.StringIO()
這裏,根據open傳遞的參數個數來判斷返回的對象。

總結

Python的 mock 庫, 使用起來是有點子迷惑, 是單元測試的遊戲規則變革者. 咱們經過開始在單元測試中使用 mock ,展現了一些一般的使用場景, 但願這篇文章能幫助 Python 克服一開始的障礙,寫出優秀的,能經得起測試的代碼.

相關文章
相關標籤/搜索