unit test 之mock 用法

功能介紹

好的編碼習慣都應該爲每一行代碼做覆蓋測試,但有些時候代碼處理的是從網絡上獲取的內容,或者設備的返回,比如獲取交換機路由器的運行結果,或者從網絡上獲取頁面等等。這些動作要麼需要聯網,要麼需要設備,但實際上我們只是想測試代碼正確性而已,注重的是對返回的內容的處理而不必非要有實際設備。

mock 模塊就是在單元測試中模擬部分代碼的模塊,比如某個函數需要調用其他函數,這個時候我們可以模擬這個第三方函數的結果來略過實際調用它,不光可以節省時間,也可以避免因爲第三方函數出錯而影響自己的代碼,甚至可以很輕鬆的模擬難以出現的各種情況。

也正是因爲這個模塊是如此好用,在 Python2 中還需要單獨安裝 mock 模塊,而 Python3.3 開始這個模塊就被放入標準模塊了,名叫 unittest.mock

使用思路和實例

在概念上, mock 就是模擬某些模塊的行爲,比如你有一個函數調用了另一個函數,而另一個函數的代碼本身不是你寫的,或者不需要在當前單元測試中測試,你只是希望拿到另一個函數返回的結果,這個時候就可以用 mock 來模擬那個函數來略過各種中間過程而直接得到結果。比如下面這樣的代碼結構:

                                                                +======================+
                                                           +----|    send_shell_cmd    |
+==========================+    +=====================+    |    +======================+
| test_search_flow_session |----| search_flow_session |----+
+==========================+    +=====================+    |    +======================+
                                                           +----| get_all_flow_session |
                                                                +======================+

上面的 test_search_flow_session 是寫在單元測試腳本中的測試案例,用來測試在另一個源代碼文件中的 search_flow_session 函數。而 search_flow_session 要調用另 2 個其它文件中的函數 send_shell_cmd 和 get_all_flow_session 來完成功能。恰恰麻煩的是這 2 個函數其中一個需要一臺 PC 機來執行 linux 命令,另一個需要一臺昂貴的設備來獲取設備上的狀態和返回,更別說創建拓撲和恢復測試環境的工作,僅僅爲了檢查 search_flow_session 中的某些代碼而付出這樣的代價完全不值。

但是應該怎麼用 mock 模擬,或者怎麼把 mock 注入到你自己的函數中卻是一個很傷腦筋的問題,不同的代碼風格很容易把你帶進坑裏,比如要調用的其他函數使用 OOP 方式寫的,你會想難道我還得先實例化?或者我的函數是面向對象的,調用的卻是面向過程的,怎麼辦?在我剛剛開始接觸 mock 的時候,這些概念和行爲真是把我折磨的夠嗆。寫多了之後才慢慢感覺到了下面幾個規則:

  1. 不用管自己的函數怎麼寫, mock 只用來模擬別人的模塊,不管是面向過程還是面向對象都不用過多考慮,只考慮你的代碼中調用了哪些外部函數或者方法,這意味着你要 mock 多少東西
  2. 如果調用的外部代碼是面向過程的風格,也就是一個一個函數,那麼就用 mock.patch 就可以;如果是面向對象的風格,比如你調用的只是一個類中的某個方法,那麼要用 mock.patch.object 。現在看到什麼 mock.patch , mock.patch.object 可能你不理解,沒事,先放下,到後面會專門說

mock 概念很繞,但是真正用到的接口並不多。也是,模擬函數或者方法行爲而已,又能有幾種接口呢……大致說來我們能接觸到的也就是這麼幾個:

Mock

mock 是最初,也是最基本的一個函數,它的任務就是模擬某個當前模塊的函數。

patch - 補丁方式模擬

有些函數可能不屬於你,你也不在意它的內部實現而只是想調用這個函數然後得到結果而已,這種時候就可以用 patch 方式來模擬。

比如我有一個模塊文件叫 linux_tool.py ,然後裏面有 2 個函數,其中一個 send_shell_cmd 是其他人寫的,怎麼做的我不在乎,但是我可以用它向 Linux PC 發命令,然後獲取命令的返回。現在我在這個模塊裏又添加了一個函數 check_cmd_response 用來檢查返回的結果,然後對自己寫的 check_cmd_response 做單元測試。因爲 send_shell_cmd 函數需要一個真實的 PC ,這需要設備,而且每次返回還可能與預期不符,比如設備無法連接,想檢查的東西忘記配置所以取不回來等等,這些都會干擾我自己函數的行爲,而且問題和自己函數無關,這種時候就可以用 mock 模擬 send_shell_cmd 函數而且把預期返回寫到這個模擬過程中,這樣每次都會正確處理。當然有人說可能的確有錯誤情況啊,這也是你應該要處理的,或者有多種返回啊……沒錯,所以可以多寫幾個測試案例把這些情況都模擬一遍嘛。

面向過程代碼風格

下面是完整的模擬代碼,首先是 linux_tool.py 文件,裏面 2 個函數, send_shell_cmd 直接返回一個字符串,注意在現實中這是一個完整函數會連接設備並獲取返回的。另一個就是自己寫的函數了,中間的代碼都去掉,但是整體來說我希望獲取未來使用 mock 模擬的函數所返回的內容

#!/usr/bin/env python3
import re

def send_shell_cmd():
    return "Response from send_shell_cmd function"

def check_cmd_response():
    response = send_shell_cmd()
    print("response: {}".format(response))
    return re.search(r"mock_send_shell_cmd", response)

然後是單元測試案例,這裏要注意 patch 的用法,它是一個裝飾器,需要把你想模擬的函數寫在裏面,然後在後面的單元測試案例中爲它賦一個具體實例,再用 return_value 來指定模擬的這個函數希望返回的結果就可以了,後面就是正常單元測試代碼。

#!/usr/bin/env python3
from unittest import TestCase, mock
import linux_tool

class TestLinuxTool(TestCase):
    def setUp(self):
        pass

    def tearDown(self):
        pass

    @mock.patch("linux_tool.send_shell_cmd")
    def test_check_cmd_response(self, mock_send_shell_cmd):
        mock_send_shell_cmd.return_value = "Response from emulated mock_send_shell_cmd function"

        status = linux_tool.check_cmd_response()
        print("check result: %s" % status)
        self.assertTrue(status)

來看看測試結果:

[jonjiang@hutong-j:tmp]$ clear; pytest -v --html=~/public_html/report.html test_linux_tool.py

===================================== test session starts =====================================
platform linux -- Python 3.5.2, pytest-3.0.7, py-1.4.33, pluggy-0.4.0 -- /usr/bin/python3
cachedir: .cache
metadata: {'Packages': {'pytest': '3.0.7', 'pluggy': '0.4.0', 'py': '1.4.33'}, 'Python': '3.5.2', 'Platform': 'Linux-4.4.0-62-generic-x86_64-with-Ubuntu-16.04-xeni
rootdir: /home/jonjiang/tmp, inifile:
plugins: metadata-1.3.0, html-1.14.2, cov-2.4.0
collected 1 items

test_linux_tool.py::TestLinuxTool::test_check_cmd_response PASSED

----------------- generated html file: /home/jonjiang/public_html/report.html ---------------------
linux_tool.py  __pycache__  test_linux_tool.py  TOBYUnitTest.py
===================================== 1 passed in 0.05 seconds =====================================

# HTML 報告的結果
Passed  test_linux_tool.py::TestLinuxTool::test_check_cmd_response  0.00
----------------------------- Captured stdout call -----------------------------
response: Response from emulated mock_send_shell_cmd function
check result: <_sre.SRE_Match object; span=(23, 42), match='mock_send_shell_cmd'>

好了,我們再來梳理一下思路,使用 mock 其實代碼方面並沒有太多麻煩的,但是釐清思路往往很困難:

  1. 實際測試代碼和單元測試代碼是分開在 2 個文件中的,第一個關卡往往就是怎麼把這 2 個文件有機結合起來。這裏的關鍵就是:源代碼該怎麼寫就怎麼寫,不需要考慮爲 mock 留下什麼接口之類的東西。

  2. 單元測試文件中,首先寫單元測試代碼,就和正常的一樣,最開始的時候只需要 import mock 模塊即可。

  3. 判斷要測試的函數中是否用了其他函數,有可能使用了多個外部函數,那麼就判斷哪個函數適合 mock ,哪些不需要,一般像浪費時間的,結果不定的,需要其他設備的函數最好都 mock ,其它一些功能函數可用可不用。

  4. 確定了哪些外部函數要 mock 就用 patch 語句將它們列出來,每個 patch 是一個函數,而且要確定這些外部函數都在文件頭部用 import 語句載入到內存了,因爲 mock 模塊是通過替換內存中的函數微代碼來實現功能的。

  5. 如果 patch 多個外部函數,那麼調用遵循自下而上的規則,比如:

    @mock.patch("function_C")
    @mock.patch("function_B")
    @mock.patch("function_A")
    def test_check_cmd_response(self, mock_function_A, mock_function_B, mock_function_C):
        mock_function_A.return_value = "Function A return"
        mock_function_B.return_value = "Function B return"
        mock_function_C.return_value = "Function C return"
    
        self.assertTrue(re.search("A", mock_function_A()))
        self.assertTrue(re.search("B", mock_function_B()))
        self.assertTrue(re.search("C", mock_function_C()))
    

面向對象代碼風格

如果你的代碼風格是面向對象的呢?也可以,用 patch.object 就行,來看看例子:

# linux_tool.py
import re

class LinuxTool(object):
    def __init__(self):
        pass

    def send_shell_cmd(self):
        return "Response from send_shell_cmd function"

    def check_cmd_response(self):
        response = self.send_shell_cmd()
        print("response: {}".format(response))
        return re.search(r"mock_send_shell_cmd", response)

再來寫單元測試的案例:

from unittest import TestCase, mock
from linux_tool import LinuxTool

class TestLinuxTool(TestCase):
    def setUp(self):
        self.linux_tool = LinuxTool()

    def tearDown(self):
        pass

    @mock.patch.object(LinuxTool, "send_shell_cmd")
    def test_check_cmd_response(self, mock_send_shell_cmd):
        mock_send_shell_cmd.return_value = "Response from emulated mock_send_shell_cmd function"

        status = self.linux_tool.check_cmd_response()
        print("check result: %s" % status)
        self.assertTrue(status)

好了,來執行一下測試:

[jonjiang@hutong-j:tmp]$ clear; pytest -v --html=~/public_html/report.html test_linux_tool.py
========================= test session starts =========================
platform linux -- Python 3.5.2, pytest-3.0.7, py-1.4.33, pluggy-0.4.0 -- /usr/bin/python3
cachedir: .cache
metadata: {'Packages': {'pytest': '3.0.7', 'pluggy': '0.4.0', 'py': '1.4.33'}, 'Platform': 'Linux-4.4.0-62-generic-x86_64-with-Ubuntu-16.04-xenial', 'Python': '3.5
rootdir: /home/jonjiang/tmp, inifile:
plugins: metadata-1.3.0, html-1.14.2, cov-2.4.0
collected 1 items

test_linux_tool.py::TestLinuxTool::test_check_cmd_response PASSED

------- generated html file: /home/jonjiang/public_html/report.html --------
import re
========================= 1 passed in 0.02 seconds =========================

# HTML 的結果
Passed  test_linux_tool.py::TestLinuxTool::test_check_cmd_response  0.00
----------------------------- Captured stdout call -----------------------------
response: Response from emulated mock_send_shell_cmd function
check result: <_sre.SRE_Match object; span=(23, 42), match='mock_send_shell_cmd'>

面向對象的 mock 和麪向過程的很相似,唯一就是把 mock.patch 替換成 mock.patch.object ,並且在裏面列出類實例和方法名。仔細觀察,是類的實例 (不是字符串) 和方法名 (是字符串的方法名而不是方法對象)

side_effect

side_effect 是 mock 中角色比較複雜的方法,它有好幾種用法

模擬同一個函數被多次調用

有的時候在同一個功能中可能要多次調用同一個外部方法,比如有一個外部方法叫 linux_tool.send_shell_cmd 用來執行命令並返回命令中間的輸出,利用這個函數我又寫了一個自己的方法用來建立 vsftpd 服務器,其中多次調用外部方法來創建備份文件,建立配置文件,重啓服務,檢查服務狀態等等。或者某個命令在一個循環中被調用,循環次數也可能是不定的。上面的例子都只是模擬了一次,那麼模擬多次怎麼辦?

答案就是使用 side_effect ,比如下面的例子中在方法 start_ftp_service 中調用了 5 次 send_shell_cmd 方法:

class TestSetupServer(TestCase):
    @mock.patch.object(linux_tool, "send_shell_cmd")
    def test_start_ftp_service_for_default_conf(self, mock_send_shell_cmd):
        mock_send_shell_cmd.side_effect = [
            "cmd1_response",
            "cmd2_response",
            "cmd3_response",
            "cmd4_response",
            "cmd5_response",
        ]

        self.mytool.start_ftp_service()

如果某個命令在循環中被調用,滿足判斷結果纔會跳出循環,那麼也要用 side_effect 來模擬循環中的每次結果,一定數清楚具體的循環次數或者精心設計返回,否則執行會出錯。

模擬異常

用上面模擬同一個函數多次被調用的實例爲例,如果希望主動引發異常,比如 Exception 那麼可以這樣:

mock_send_shell_cmd.side_effect = Exception("Raise Exception")

所有 raise 語句可以引發的異常都可以用 side_effect 引發

MagicMock

Python 中有 MagicMethod 的概念,表現爲雙下劃線包圍的方法,比如最熟悉的 init 或者 str 之類的。而 mock 中的 MagicMock 則用來模擬一個第三方類,然後爲這個模擬的類賦予各種行爲,比如:

# 建立一個新的第三方函數,並模擬函數返回
mock_func = mock.MagicMock()
mock_func.return_value = 10
mock_func.side_effect = [1, 2, 3, 4, 5]

# 建立一個新的第三方類,並模擬類中方法的返回
mock_object = mock.MagicMock()
mock_object.method_A.return_value = 10
mock_object.method_B.side_effect = [1, 2, 3, 4, 5]

這個方法目前我還沒完全掌握,暫時保留

內建的其他方法

called

一旦 mock 被創建,比如上面用 patch 模擬的 mock_send_shell_cmd ,或者用 MagicMock 模擬的 mock_func ,都可以用 called() 方法來檢查自己究竟有沒有被調用,比如:

mock_send_shell_cmd.called
>> True

call_count

返回模擬的函數或方法被調用了幾次:

mock_send_shell_cmd.call_count
>> 2

call_args

返回 mock 的東西在調用時傳入的具體參數

>>> mock_send_shell_cmd.some_method3(cmd="ls -l", mode="shell")
>>> mock_send_shell_cmd.some_method3.call_args
call(cmd="ls -l", mode="shell")

還有一個叫 call_args_list ,這個用於 mock 的方法被多次調用的情況,會返回一個列表,列表中是每次被調用時的參數

assert_called_with(*args, **kwargs)

有時候我們不光想確認自己 mock 的東西有沒有被調用,還想確認調用時傳入的參數是不是正確的,就可以用 assert_called_with ,比如:

>>> mock_send_shell_cmd.some_method3(a=1, b=4)
>>> mock_send_shell_cmd.some_method3.assert_called_with(a=1, b=4)
>>> mock_send_shell_cmd.some_method3.assert_called_with(a=1, b=5)
Traceback (most recent call last):
...
raise AssertionError(_error_message()) from cause
AssertionError: Expected call: some_method3(a=1, b=5)
Actual call: some_method3(a=1, b=4)


 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章