本文深入讲解 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. 模拟文件的删除#
- 文件的删除 一个简单的例子如下:
import osimport os.path
def rm_file(filename):if os.path.isfile(filename):os.remove(filename)传统的测试写法如下:
# 当被测函数在另外一个模块里时,需要导入
from mymodule import rm_file
import os.pathimport tempfileimport 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 测试的写法如下:
# 当被测函数在另外一个模块里时,需要导入
from mymodule import rm_file
import mockimport unittest
class TestRemoveFile(unittest.TestCase):@patch('os.remove') # 模拟对象 os.remove@patch('os.path') # 模拟对象 os.pathdef test_rm(self, mock_path, mock_remove): # mock 对象 mock_remove 会替换 os.remove 函数 # 模拟对象 mock_path.isfile 返回值为 True, 表示文件存在mock_path.isfile.return_value = True # 被测函数调用rm_file('dummy.txt')
# 断言参数被 os.path.isfile(), os.remove() 调用,且只调用一次 mock_path.isfile.assert_called_once_with('dummy.txt') mock_remove.assert_called_once_with('dummy.txt')
@patch('os.remove') @patch('os.path.isfile') # 模拟对象 os.path.isfile,跟模拟 os.path 差不多 def test_rm_file_does_not_exist(self, mock_isfile, mock_remove): # 这里参数的顺序不能错,按照装饰器 @patch 从下到上的顺序在这里从左到右 # 模拟当文件不存在时 mock_isfile.return_value = False # 被测函数调用 rm_file('dummy.txt')
# 只调用了 os.path.isfile() 一次,但是没有调用 os.remove(),因为文件不存在 mock_isfile.assert_called_once_with('dummy.txt') mock_remove.assert_not_called()这样我们对 rm_file() 函数的内部逻辑进行了充分的测试,不需要创建真实的文件,不需要真的测试删除就可以对代码的逻辑正确性进行测试。
先看下 mock 对象的定义,如下:
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 对象进行具体设置的方式如下:
# 假设有另外一个函数
def mock_func(): # 做一些操作,然后返回一个值return 30
# 1. 创建 mock 对象可以这样
mock_obj = mock.Mock(return_value=20,side_effect=mock_func, name='mock_obj')
# 2. 或者这样:
mock_obj = mock.Mock()mock_obj.return_value = 20mock_obj.side_effect = mock_func
# mock_func 和 return_value 同时存在,所以模拟替换之后,返回的值是 30另外,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() 的定义是:
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 测试使得测试更加灵活和可控,测试覆盖率也更高。
另一个更复杂点的删除文件的例子:
def cleanup_backups(count, backup_path=default_backup_dir):"""cleanup outdated backup files删除指定路径下指定数量的备份文件"""if count < 0:returnbackups = glob.glob("{}/\*.gz".format(backup_path))backups.sort(reverse=True) # 遍历最老的 count 个文件for f in backups[count:]:md5sum_file = f.replace(".gz", "") + ".md5sum"os.remove(f) # 如果备份文件有对应的 md5sum 文件,也进行删除if os.path.exists(md5sum_file):os.remove(md5sum_file)else: # 没有就不删除logging.error("File {} not found, skipping deletion".format(md5sum_file))continue # 输出 log 信息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 方法来测试,如下:
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 对应的是 os.path.exists # 因为在循环了只被调用两次,所有其结果有两次值,[True, True] 代表两次判断都是文件存在。[False, True] 代表有个文件不存在。 # 所以这里可以控制产生多个测试 case # 测试有一个文件不存在对应的 .md5sum 文件,则跳过删除 mock_exists.side_effect = [False, True] # 只删除最老的 2 份文件 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" ) ] ) # 所以实际只删除一个备份文件 20231017 mock_remove.assert_has_calls( [ call("/path/to/backup/pg-data/20231017.gz"), ] ) # 最后分别调用了以下 logging.info 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. 模拟网络请求#
在进行单元测试时,我们希望将测试对象与外部依赖(如网络请求)隔离,以便更好地聚焦于测试代码本身。模拟网络请求可以:
- 提高测试速度:避免实际发起网络请求带来的延迟。
- 增强测试稳定性:避免因网络波动或服务器故障导致测试失败。
- 方便测试各种场景:可以灵活地模拟不同的响应结果,包括成功、失败、异常等。
例子如下:
import unittestfrom unittest.mock import patchimport 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): # 模拟请求返回的 responsemock_response = mock_get.return_value # 模拟返回状态码为 200mock_response.status_code = 200mock_response.json.return_value = {'data': 'test'}
result = get_data_from_api() assert result['data'] == 'test'所以通过 mock 可以很轻松的模拟测试,不管是状态码为 200,还是 404,500 等等,完全不依赖真实的网络测试环境。
3. 模拟数据库操作#
在单元测试中,我们希望将测试对象与数据库交互隔离开,以达到以下目的:
- 提高测试速度:避免每次测试都连接数据库,从而加快测试执行速度。
- 增强测试稳定性:避免由于数据库连接问题或数据变更导致测试失败。
- 方便测试各种场景:可以灵活地模拟各种数据库操作的结果。
from sqlalchemy import create_enginefrom sqlalchemy.orm import sessionmakerimport unittestfrom unittest.mock import patch
from models import User # 假设有一个 User 模型
# 假设需要根据 id 查询用户信息
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 userclass TestQueryFromDB(unittest.TestCase):def test_get_user_by_id(self): # 模拟一个 models.Session 对象。 # 并且这里使用 with 语法将 mock 对象用作上下文,但是这样只适合有一个模拟对象的情况with patch('models.Session') as mock_session: # 模拟 session 返回的一个实例mock_instance = mock_session.return_value # 模拟实例的 query, filter_by, first 方法的返回值,模拟查询结果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 服务,然后执行命令。
import requestsfrom unittest.mock import patchimport 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 Nonereturn 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") # 创建 mock 对象,并且模拟没有找到容器的错误 casedocker_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")] ) # 最后清除 mock 对象资源:reset_mock() docker_client_mock.reset_mock()
@patch("docker.DockerClient") def test_run_cmd_in_container(self, mock_docker_client): # 创建 Mock 对象,模拟一个 docker client mock_client = Mock() mock_docker_client.return_value = mock_client # 创建 Mock 对象,模拟一个 docker 容器 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)以上。