好的编码习惯都应该为每一行代码做覆盖测试,但有些时候代码处理的是从网络上获取的内容,或者设备的返回,比如获取交换机路由器的运行结果,或者从网络上获取页面等等。这些动作要么需要联网,要么需要设备,但实际上我们只是想测试代码正确性而已,注重的是对返回的内容的处理而不必非要有实际设备。
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 的时候,这些概念和行为真是把我折磨的够呛。写多了之后才慢慢感觉到了下面几个规则:
不用管自己的函数怎么写, mock 只用来模拟别人的模块,不管是面向过程还是面向对象都不用过多考虑,只考虑你的代码中调用了哪些外部函数或者方法,这意味着你要 mock 多少东西
如果调用的外部代码是面向过程的风格,也就是一个一个函数,那么就用 mock.patch 就可以;如果是面向对象的风格,比如你调用的只是一个类中的某个方法,那么要用 mock.patch.object 。现在看到什么 mock.patch , mock.patch.object 可能你不理解,没事,先放下,到后面会专门说
mock 概念很绕,但是真正用到的接口并不多。也是,模拟函数或者方法行为而已,又能有几种接口呢……大致说来我们能接触到的也就是这么几个:
mock 是最初,也是最基本的一个函数,它的任务就是模拟某个当前模块的函数。
有些函数可能不属于你,你也不在意它的内部实现而只是想调用这个函数然后得到结果而已,这种时候就可以用 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 其实代码方面并没有太多麻烦的,但是厘清思路往往很困难:
实际测试代码和单元测试代码是分开在 2 个文件中的,第一个关卡往往就是怎么把这 2 个文件有机结合起来。这里的关键就是:源代码该怎么写就怎么写,不需要考虑为 mock 留下什么接口之类的东西。
单元测试文件中,首先写单元测试代码,就和正常的一样,最开始的时候只需要 import mock 模块即可。
判断要测试的函数中是否用了其他函数,有可能使用了多个外部函数,那么就判断哪个函数适合 mock ,哪些不需要,一般像浪费时间的,结果不定的,需要其他设备的函数最好都 mock ,其它一些功能函数可用可不用。
确定了哪些外部函数要 mock 就用 patch 语句将它们列出来,每个 patch 是一个函数,而且要确定这些外部函数都在文件头部用 import 语句载入到内存了,因为 mock 模块是通过替换内存中的函数微代码来实现功能的。
如果 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 引发
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]
这个方法目前我还没完全掌握,暂时保留
一旦 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)
作者:羽风之歌
链接:https://www.jianshu.com/p/55e5a6863c3f
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。