本文深入讲解Python的Mock测试技术,详细介绍了如何使用unittest.mock模块在单元测试中模拟外部依赖。通过文件操作、网络请求和数据库交互的实例,对比传统测试与Mock测试的区别,并全面解析Mock对象的属性配置、patch装饰器的使用以及断言方法的应用,帮助开发者编写更加独立、可靠的测试代码。
什么是 mock test
mock 测试是在单元测试中常用的技术,它通过模拟对象来替代软件中的真实对象,从而隔离测试对象,使测试更加独立、可靠。
比如项目里面需要新增一个复杂的业务模块,内部调用了身份验证、数据库查询、文件读写等等模块。在上线之前,开发阶段对这个功能进行测试是有必要的,但是有时候为了增加这个功能搭建一套真实测试环境是比较麻烦的,或者说成本比较大。而如果不能提供真实的测试环境,我们无法准确测试这个模块是否能全部按预期执行,包括各种逻辑分支的流程,和对错误的处理等等,毕竟完美的代码很少。
再比如要测试一个文件删除功能,传统的测试可能需要每次调用 tempfile 模块在临时目录中先创建临时文件,然后再删除。但这样我们没办法知道删除函数内部是如何传递参数,如何执行各个步骤的,只能对结果进行断言。
这个时候 mock 测试就正是派上用场了。在这些涉及到文件操作、网络请求、数据库操作等外部依赖的场景下,mock 测试就很有用处。
Python 3.3 开始,Python 内置了 unittest.mock 模块,提供了 Mock 功能。它通过创建 mock 对象替换掉指定的对象(Python 中一切皆对象)来模拟对象的行为。
什么场景下适合使用 mock test
在进行单元测试时,一个模块、函数内部调用了外部的、系统的模块的时候使用 mock 测试比较合适。这样可以保持测试目标与其他模块隔离,提高效率和可靠性。
mock test 用例
大概从文件的操作、网络请求、数据库操作三个方面进行举例说明。
1. 模拟文件的删除
文件的删除一个简单的例子如下:
mymodule.py1 2 3 4 5 6
| import os import os.path
def rm_file(filename): if os.path.isfile(filename): os.remove(filename)
|
传统的测试写法如下:
test_mymodule.py1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
from mymodule import rm_file
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): rm_file(self.tmpfilepath) self.assertFalse(os.path.isfile(self.tmpfilepath), "Failed to remove the file.")
|
这样 rm_file 内部的执行情况如何,从这里的测试里是看不出来的,也没有测试到如果文件不存在的情况。最关键是这个测试实际上跟系统有交互,真实的使用了测试模块外部的系统资源来创建临时测试文件。
而 mock 测试的写法如下:
test_mymodule.py1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
from mymodule import rm_file
import mock import unittest
class TestRemoveFile(unittest.TestCase): @patch('os.remove') @patch('os.path') def test_rm(self, mock_path, mock_remove): mock_path.isfile.return_value = True rm_file('dummy.txt')
mock_path.isfile.assert_called_once_with('dummy.txt') mock_remove.assert_called_once_with('dummy.txt')
@patch('os.remove') @patch('os.path.isfile') def test_rm_file_does_not_exist(self, mock_isfile, mock_remove): mock_isfile.return_value = False rm_file('dummy.txt')
mock_isfile.assert_called_once_with('dummy.txt') mock_remove.assert_not_called()
|
这样我们对 rm_file() 函数的内部逻辑进行了充分的测试,不需要创建真实的文件,不需要真的测试删除就可以对代码的逻辑正确性进行测试。
先看下 mock 对象的定义,如下:
1
| class unittest.mock.Mock(spec=None, side_effect=None, return_value=DEFAULT, wraps=None, name=None, spec_set=None, unsafe=False, \*\*kwargs)
|
其中:
return_value :调用 mock 的返回值,模拟某一个方法的返回值。
side_effect :调用 mock 时的返回值,可以是函数,异常类,可迭代对象。使用 side_effect 可以将模拟对象的返回值变成函数,异常类,可迭代对象等。当设置了该方法时,如果该方法返回值是 DEFAULT,那么返回 return_value 的值,如果不是,则返回该方法的值。 return_value 和 side_effect 同时存在,side_effect 会覆盖 return_value 的值。如果 side_effect 是异常类或实例时,调用模拟程序时将引发异常。如果 side_effect 是可迭代对象,则每次调用 mock 都将返回可迭代对象的下一个值。
name :mock 的名称。 这个是用来命名一个 mock 对象,只是起到标识作用,当你 print 一个 mock 对象的时候,可以看到它的 name。
spec_set:更加严格的要求,spec_set=True 时,如果访问 mock 不存在属性或方法会报错。
spec: 参数可以把一个对象设置为 Mock 对象的属性。访问 mock 对象上不存在的属性或方法时,将会抛出属性错误。
创建 mock 对象的方式就是 mock.Mock()
给 mock 对象进行具体设置的方式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
def mock_func(): return 30
mock_obj = mock.Mock(return_value=20,side_effect=mock_func, name='mock_obj')
mock_obj = mock.Mock() mock_obj.return_value = 20 mock_obj.side_effect = mock_func
|
另外,MagicMock 是 Mock 的一个子类,具有大多数魔法方法(Magic Method)的默认实现。在 mock.patch 中 new 参数如果没写,默认创建的是 MagicMock。
Python 中魔方方法就是以两个下画线开头和结尾的方法如: __new__(),__init__()。
使用 MagicMock 和 Mock 的场景:使用 MagicMock 则需要魔法方法的场景,如迭代使用 Mock 则不需要魔法方法的场景可以用 Mock
好了,继续说上面的测试代码,上面的测试代码中 patch() 模拟了一个函数(同样的,patch.object() 可以模拟一个类)。
@patch('os.remove'), @patch('os.path') 都是装饰其紧跟着的函数,如 test_rm(self, mock_path, mock_remove),
这里参数 mock_path, mock_remove 的顺序不能错,按照装饰顺序从下到上在这里从左到右排序,在这里就是先模拟生成了 mock_path, 后生成了 mock_remove。
mock.patch() 的定义是:
1
| unittest.mock.patch(target, new=DEFAULT, spec=None, create=False, spec_set=None, autospec=None, new_callable=None, \*\*kwargs)
|
其中:
target 指要模拟的目标对象,这里是 os.remove, os.path。
new 是被模拟替换后的对象,在这里是 mock_isfile, mock_remove,
因为先是使用 @patch('os.path') 装饰器的,所以被创建的模拟对象 mock_isfile 在被装饰的函数 test_rm() 中排在前面。
spec 为 mock 对象添加属性。
create 允许访问 mock 对象不存在的属性。
spec_set 属性限制,当访问 mock 对象不存在的属性时会报错。
autospec 标记 mock 对象属性全部被 spec 替换。
new_callable 模拟返回的结果,是可调用对象,会覆盖 new 。
测试代码中还用到了构造器 return_value,模拟被调用对象的返回值。
最后用到了断言:assert_called_once_with, 表示模拟对象仅被调用了一次,且使用了指定的参数。类似的还有 assert_called_once 表示仅调用了一次,assert_called_with 表示使用了指定的参数,
assert_called 表示至少调用了一次,assert_not_called 没有被调用。
通过这个例子,我们可以看到:
我们没有真正创建并删除一个文件,而是模拟了文件和它的删除过程。
测试用例专注于测试 rm_file 函数的内部逻辑,而不需要关心文件系统的实际状态。
mock 测试使得测试更加灵活和可控,测试覆盖率也更高。
另一个更复杂点的删除文件的例子:
mymodule.py1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| def cleanup_backups(count, backup_path=default_backup_dir): """ cleanup outdated backup files 删除指定路径下指定数量的备份文件 """ if count < 0: return backups = glob.glob("{}/\*.gz".format(backup_path)) backups.sort(reverse=True) for f in backups[count:]: md5sum_file = f.replace(".gz", "") + ".md5sum" os.remove(f) if os.path.exists(md5sum_file): os.remove(md5sum_file) else: logging.error("File {} not found, skipping deletion".format(md5sum_file)) continue logging.info("Deleting expired backup file {}".format(f)) logging.info("Deleting expired backup file {}".format(md5sum_file))
|
这里调用了 glob,os.path, os.remove, logging.error 等外部函数。用 mock 方法来测试,如下:
test_mymodule.py1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| class TestCleanUpBackups(unittest.TestCase): backup_path = "/path/to/backup/pg-data" backup_files = [ "/path/to/backup/pg-data/20231020.gz", "/path/to/backup/pg-data/20231019.gz", "/path/to/backup/pg-data/20231018.gz", "/path/to/backup/pg-data/20231017.gz", ]
@patch("glob.glob") @patch("os.path.exists") @patch("os.remove") @patch("logging.error") @patch("logging.info") def test_cleanup_backups_01( self, mock_info, mock_error, mock_remove, mock_exists, mock_glob ): mock_glob.return_value = backup_files mock_exists.side_effect = [False, True] cleanup_backups(2, self.backup_path)
mock_glob.assert_called_with("{}/*.gz".format(self.backup_path)) mock_error.assert_has_calls( [ call( "File /path/to/backup/pg-data/20231018.md5sum not found, skipping deletion" ) ] ) mock_remove.assert_has_calls( [ call("/path/to/backup/pg-data/20231017.gz"), ] ) mock_info.assert_has_calls( [ call( "Deleting expired backup file /path/to/backup/pg-data/20231017.gz" ), call( "Deleting expired backup file /path/to/backup/pg-data/20231017.md5sum" ), ] )
|
这里用到了断言 assert_has_calls 来模拟对象上调用方法的顺序和参数,里面是一个列表 [call(), call()],里面的顺序不能错。不过可以接收一个参数,允许不严格顺序,如 assert_has_calls(calls, any_call=False), 默认 any_call 是 True。
还用到了 side_effect 构造器,模拟对象被调用时的返回值,会覆盖 return_value。
2. 模拟网络请求
在进行单元测试时,我们希望将测试对象与外部依赖(如网络请求)隔离,以便更好地聚焦于测试代码本身。模拟网络请求可以:
例子如下:
test_mymodule.py1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import unittest from unittest.mock import patch import requests
def get_data_from_api(): response = requests.get('https://api.example.com/data') return response.json()
class TestGetDataFromApi(unittest.TestCase): @patch('requests.get') def test_get_data_from_api(self, mock_get): mock_response = mock_get.return_value mock_response.status_code = 200 mock_response.json.return_value = {'data': 'test'}
result = get_data_from_api() assert result['data'] == 'test'
|
所以通过 mock 可以很轻松的模拟测试,不管是状态码为 200,还是 404,500 等等,完全不依赖真实的网络测试环境。
3. 模拟数据库操作
在单元测试中,我们希望将测试对象与数据库交互隔离开,以达到以下目的:
提高测试速度: 避免每次测试都连接数据库,从而加快测试执行速度。
增强测试稳定性: 避免由于数据库连接问题或数据变更导致测试失败。
方便测试各种场景: 可以灵活地模拟各种数据库操作的结果。
test_mymodule.py1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker import unittest from unittest.mock import patch
from models import User
def get_user_by_id(user_id): engine = create_engine('sqlite:///test.db') Session = sessionmaker(bind=engine) session = Session() user = session.query(User).filter_by(id=user_id).first() return user class TestQueryFromDB(unittest.TestCase): def test_get_user_by_id(self): with patch('models.Session') as mock_session: mock_instance = mock_session.return_value mock_instance.query.return_value.filter_by.return_value.first.return_value = User(id=1, name='Alice')
result = get_user_by_id(1) assert result.id == 1 assert result.name == 'Alice'
|
模拟数据库操作是单元测试中非常重要的一环,它可以帮助我们更好地测试数据库交互逻辑。通过灵活运用 Mock 工具,我们可以模拟各种数据库操作场景,确保代码在不同的数据库环境下都能正常运行。
4. 模拟外部系统调用
跟前面的类似,这里是获取系统中 Docker 服务,然后执行命令。
test_mymodule.py1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| import requests from unittest.mock import patch import docker
def get_container(name): client = docker.DockerClient(base_url="unix://var/run/docker.sock") try: container = client.containers.get(name) except docker.errors.NotFound as e: logging.error("container {} not found, error: {}".format(name, e)) return None return container
def run_cmd_in_container(command, container, user="root"): if container is None: return False, None
exit_code, output = container.exec_run(command, user=user, privileged=True) if exit_code != 0: logging.error( "Failed to run {} in container , exit code: {}, output: {}".format( command, exit_code, output ) ) return False, None return True, output
class TestDocker(unittest.TestCase): not_found_exception = docker.errors.NotFound("Container not found") docker_client_mock = Mock() docker_client_mock.containers.get.side_effect = not_found_exception
@patch("docker.DockerClient", return_value=docker_client_mock) @patch("logging.error") def test_get_container(self, mock_error, docker_mock): container = get_container("sds-postgres")
self.assertIsNone(container) docker_mock.assert_called_once() mock_error.assert_called_once() mock_error.assert_has_calls( [call("container sds-postgres not found, error: Container not found")] ) docker_client_mock.reset_mock()
@patch("docker.DockerClient") def test_run_cmd_in_container(self, mock_docker_client): mock_client = Mock() mock_docker_client.return_value = mock_client mock_container = Mock() mock_client.containers.get.return_value = mock_container
mock_container.exec_run.return_value = (0, b"Success") ret, output = run_cmd_in_container("psql", mock_container)
mock_container.exec_run.assert_called_with("psql", user="root", privileged=True) self.assertEqual(output, b"Success") self.assertTrue(ret)
|
以上。
References: