Skip to content

Python Mock 测试

· 17 min
TL;DR

本文深入讲解 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. 模拟文件的删除#

  1. 文件的删除 一个简单的例子如下:
mymodule.py
import os
import os.path
def rm_file(filename):
if os.path.isfile(filename):
os.remove(filename)

传统的测试写法如下:

test_mymodule.py
# 当被测函数在另外一个模块里时,需要导入
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.py
# 当被测函数在另外一个模块里时,需要导入
from mymodule import rm_file
import mock
import unittest
class TestRemoveFile(unittest.TestCase):
@patch('os.remove') # 模拟对象 os.remove
@patch('os.path') # 模拟对象 os.path
def 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 = 20
mock_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 没有被调用。


通过这个例子,我们可以看到:

另一个更复杂点的删除文件的例子:

mymodule.py
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) # 遍历最老的 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 方法来测试,如下:

test_mymodule.py
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. 模拟网络请求#

在进行单元测试时,我们希望将测试对象与外部依赖(如网络请求)隔离,以便更好地聚焦于测试代码本身。模拟网络请求可以:

例子如下:

test_mymodule.py
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): # 模拟请求返回的 response
mock_response = mock_get.return_value # 模拟返回状态码为 200
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.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import unittest
from 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 user
class 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 服务,然后执行命令。

test_mymodule.py
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") # 创建 mock 对象,并且模拟没有找到容器的错误 case
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")]
)
# 最后清除 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)

以上。

References:#