Python单元测试之Mock的使用

简介

在单元测试中,Mock是一个很重要的行为,它可以帮助我们排除其他服务的依赖,将需要进行的测试隔离开来。mock是Python中提供的测试库,在Python2中,mock是独立的库,而在Python3中,mock被加入了标准单元测试库unittest当中,官方文档为unittest.mock|docs

mock库中,有两个非常重要的概念,分别是Mock以及patchMock指的是mock库中提供的Mock类,它产生的对象可以模拟各种行为,简单来说就是根据程序员指定输入和输出进行工作。而patch则用于在特定作用范围内执行模拟,将指定的实际对象变为Mock对象,此时实际对象就会按照我们预先指定的方式进行工作。Mockpatch结合工作,联系紧密。

Mock

quick start

Mock是unittest.mock库中提供的一个类,要使用它首先得进行对象的创建。

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_valueside_effect

return_value用于指定该Mock对象被调用时的返回值,且该返回值始终不变。

side_effect用于指定可变的返回值或者抛出特定的异常,该参数可以设置为三种类型:

  • 如果设置为异常类,则执行该Mock对象时会抛出异常
  • 如果设置为方法,则执行该Mock对象时,会将传入的参数传递给设置的方法,然后返回方法的返回值
  • 如果设置为可迭代对象,则执行该Mock对象时会按照迭代顺序进行返回,得到可变的返回值

需要注意的是side_effect的优先级高于return_value

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from unittest.mock import Mock

# 设置固定的return_value
mock = Mock(return_value=1)
assert mock() == 1

# side_effect 设置抛出异常
mock = Mock(side_effect=RuntimeError)

# side_effect 设置方法
def add_one(num: int):
return num + 1
mock = Mock(side_effect=add_one)
assert mock(2) == 3

# side_effect 设置可迭代对象
mock = Mock(side_effect=[1, 2, 3])
assert mock() == 1
assert mock() == 2
assert mock() == 3
# 再次调用mock会报错,因为迭代器没有next了

我们可以手动指定某个对象的方法,将其使用Mock进行替换:

1
2
3
4
5
6
7
8
class TestClass:
def func(self, a, b):
return a + b


tc = TestClass()
tc.func = Mock(return_value=666)
print(tc.func(1, 2)) # print content: 666

Mock类同样支持对Python中所有的magic方法进行mock。不过如果需要使用到magic方法,最简单的方法是使用MagicMock类。MagicMock类是Mock类的一个子类,它实现了所有常用的magic方法。

1
2
3
4
5
6
7
8
9
10
11
from unittest.mock import Mock, MagicMock

# mock magic method
mock = Mock()
mock.__str__ = Mock(return_value="my mock")
print(mock) # print content: my mock

# MagicMock
magic_mock = MagicMock()
magic_mock.__str__.return_value = "my magic mock"
print(magic_mock) # print content: my magic mock

另外,我们还可以使用create_autospec方法来创建与原对象相同的api。

创建一个具有相同入参签名的Mock方法:

1
2
3
4
5
6
7
8
9
10
11
from unittest.mock import create_autospec


def func(a: int, b: int, c: int):
return a + b + c


mock_func = create_autospec(func, return_value="abc")
print(func(1, 2, 3)) # 6
print(mock_func(1, 2, 3)) # abc
print(mock_func(1, 2)) # TypeError: missing a required argument: 'c'

创建一个具有相同属性以及方法签名的Mock测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from unittest.mock import create_autospec


class TestCase:
test_attr = 1

def test_func(self, num: int):
return self.test_attr + num


mock_class = create_autospec(TestCase, return_value=1)

print(mock_class.test_attr) # <NonCallableMagicMock name='mock.test_attr' spec='int' id='4328630928'>
print(mock_class.test_attr_not_exist) # AttributeError: Mock object has no attribute 'test_attr_not_exist'

print(mock_class.test_func) # <MagicMock name='mock.test_func' spec='function' id='4335451600'>
mock_class.test_func.return_value = 123
print(mock_class.test_func(1)) # 123
print(mock_class.test_func_not_exist) # AttributeError: Mock object has no attribute 'test_func_not_exist'

这里我们得到的Mock测试类具有与TestCase相同的属性和方法签名,不过对应的属性和方法签名仍然是一个Mock类,我们可以像之前的操作一样指定它的return_value等相关信息。

相关方法

Mock类还有其他一些常用的方法,主要是一些assert方法。因为Mock对象除了可以用来模拟对象、属性和方法等,它还会记录自身被使用的过程。利用相关assert方法可以验证代码是否被执行过,被怎样执行过。

  • assert_called(*args, **kwargs) :断言mock至少被调用一次
  • assert_called_once(*args, **kwargs): 断言mock调用仅一次
  • assert_called_with(*args, **kwargs) :断言mock以某种参数至少调用一次,且符合条件的调用是最近一次
  • assert_called_once_with(*args, **kwargs): 断言mock调用仅一次,并且调用参数符合条件
  • assert_any_called(*args, **kwargs) :断言mock以某种参数曾经被调用过,注意上面的assert_called_with()必须是最近的那次调用符合断言,而any不需要
  • assert_has_calls(calls, any_order=False) :断言mock被按照的特定一组调用的方式调用过。如果any_order是False,那么必须满足calls中的调用顺序,而且必须是连续的,如果any_order是True,那么就只需要执行了calls中的调用即可
1
2
3
4
5
6
7
8
# assert_has_calls
from unittest.mock import Mock, call

mock = Mock(return_value="123")
mock(1, 2)
mock(2, 3)
mock.assert_has_calls([call(2, 3), call(1, 2)], any_order=True) # assert pass
mock.assert_has_calls([call(2, 3), call(1, 2)], any_order=False) # assert error
  • assert_not_called(): 断言没有被调用

  • reset_mock(*args, return_value=False, side_effect=False) :重置mock对象的所有调用

  • call_count: 返回调用次数

  • call_args :返回最后调用的参数

  • call_args_list:返回调用的历史参数列表

  • method_calls:返回调用的历史方法列表

其他Mock类

  • NonCallableMock:一个不可被调用的Mock类,return_valueside_effect这两个参数对它来说是无意义的
  • PropertyMock:一个专门用于替换属性的Mock类,它提供了属性对应的get和set方法
  • AsyncMock:MagicMock的异步版本。AsyncMock对象会像一个异步数一样运行,它的调用的返回值是一个awaitable对象,这个awaitable对象返回 side_effect 或者 return_value 指定的值

Patch

quick start

patch可以帮助我们在特定范围内执行模拟,在代码运行时将指定的对象变为Mock对象。patch有多种使用方式,可以使用装饰器、with语句以及利用start()stop()方法指定模拟的开始和结束。

patch实际上是一个方法,方法签名如下:

1
unittest.mock.patch(target, new=DEFAULT, spec=None, create=False, spec_set=None, autospec=None, new_callable=None, **kwargs)

其中参数target 用于指定被模拟的对象,是一个类似package.module.className 格式的字符串;参数new_callable 可以用于指定最终创建的模拟对象类型,默认情况下是MagicMock类型的Mock对象。

patch装饰器的使用方式如下。通过target参数指定被模拟的类,创建出的Mock对象通过参数传递给方法。此时的模拟范围是函数的范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from unittest.mock import patch


class SomeClass:
pass


@patch('__main__.SomeClass')
def func(a, b, mock_class):
print(a)
print(b)
print(mock_class)


if __name__ == '__main__':
func(2, 3)

'''
print content:
2
3
<MagicMock name='SomeClass' id='4488196304'>
'''

patch with语句的使用方式如下。通过上下文管理的方式设置模拟范围,创建出的Mock对象通过as语句进行传递。

1
2
3
4
with patch('__main__.SomeClass') as mock_class:
print(mock_class)

# <MagicMock name='SomeClass' id='4365561808'>

patch手动指定作用范围的使用方式如下。

1
2
3
4
5
6
patcher = patch('__main__.SomeClass')
mock_class = patcher.start()
print(mock_class)
patcher.stop()

# <MagicMock name='SomeClass' id='4437302224'>

在上面的情况中,使用注解的情况非常常见,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from unittest.mock import patch


class SomeClass:
num = 1

def func(self, other_num):
return self.num + other_num


@patch('__main__.SomeClass.func', return_value="mock")
def test_patch_some_class(mock_func):
some_class = SomeClass()
print(some_class.func(2))


if __name__ == '__main__':
test_patch_some_class()

'''
print content: mock
'''

其他方法

  • patch.object():用于模拟对象的属性,使用Mock对象模拟对象的属性。初始化时使用参数target 指定对象,使用参数attribute 设置模拟的属性。
1
2
3
4
5
6
7
8
9
10
11
12
13
# 仍然借用上面最近的SomeClass定义,包含一个num属性以及一个func相加方法
def test_patch_some_class():
with patch.object(SomeClass, 'func', return_value="mock") as mock_func:
some_class = SomeClass()
print(some_class.func(2))


if __name__ == '__main__':
test_patch_some_class()

'''
print content: mock
'''
  • patch.dict() :用于模拟dict类型的对象,在模拟结束时恢复被模拟对象的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def test_patch_dict():
foo = {'key': 'value'}
original = foo.copy() # 浅拷贝
# clear参数表示是否保留原有的项,True表示不保留, 默认保留
with patch.dict(foo, {'new_key': 'new_value'}, clear=True):
print(foo)
assert foo == {'new_key': 'new_value'}

print(foo) # foo原本的值并没有被改变
assert foo == original


if __name__ == '__main__':
test_patch_dict()

'''
print content:
{'new_key': 'new_value'}
{'key': 'value'}
'''
  • patch.multiple(): 用于同时模拟多个对象
1
2
3
4
5
6
7
8
9
10
thing = object()
other = object()

# from unittest.mock import DEFAULT
@patch.multiple('__main__', thing=DEFAULT, other=DEFAULT)
def test_function(thing, other): # 对于patch.multiple对应的参数,并没有特别顺序要求
assert isinstance(thing, MagicMock)
assert isinstance(other, MagicMock)

test_function()

如果在with语句中使用,则返回一个字典对象:

1
2
3
4
5
6
7
8
thing = object()
other = object()

with patch.multiple('__main__', thing=DEFAULT, other=DEFAULT) as values:
assert 'other' in repr(values['other'])
assert 'thing' in repr(values['thing'])
assert values['thing'] is thing
assert values['other'] is other

特殊情况

Method not exist

在一些时候,我们可能需要模拟一个不存在的方法,例如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 仍然借用上面最近的SomeClass定义,包含一个num属性以及一个func相加方法
@patch('__main__.SomeClass.func_not_exist', return_value="mock")
def test_patch_some_class(mock_func):
some_class = SomeClass()
print(some_class.func(2))


if __name__ == '__main__':
test_patch_some_class()

'''
AttributeError: <class '__main__.SomeClass'> does not have the attribute 'func_not_exist'
'''

这里我们模拟了一个不存在的方法,默认情况是会报错的,报无法找到属性的错。此时我们可以指定create=True,此时如果patch对象不存在的话,会自动创建,不会报错。

1
2
3
4
5
6
7
8
9
10
11
12
@patch('__main__.SomeClass.func_not_exist', return_value="mock", create=True)
def test_patch_some_class(mock_func):
some_class = SomeClass()
print(some_class.func(2))


if __name__ == '__main__':
test_patch_some_class()

'''
print content: 3
'''

上面的代码不会报错,并且由于我们并没有Mock真实的逻辑,因此输出也是正常的3。

Module not exist

在一些时候,我们可能要模拟一个方法,但是这个方法所属的Module又没有安装,例如下面的代码,正常运行的话,会报错为Module not found

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from unittest.mock import patch


def method_need_to_be_test():
try:
from xxx.yyy.zzz import some_method
except ModuleNotFoundError:
raise RuntimeError("Module not found, please make sure the dependency have installed")
return some_method()


def test_method():
method_need_to_be_test()


if __name__ == '__main__':
test_method()

为了解决Module不存在的情况,我们首先需要安装surrogate。这个库允许为一个不存在的Module创建测试桩。

安装了依赖之后,利用@surrogate装饰器为不存在的Module创建测试桩之后,再利用@patch装饰器为这个不存在的方法进行模拟和Mock,如下所示。之后再次运行,就可以得到Mock后的结果了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from unittest.mock import patch
from surrogate import surrogate


def method_need_to_be_test():
try:
from xxx.yyy.zzz import some_method
except ModuleNotFoundError:
raise RuntimeError("Module not found, please make sure the dependency have installed")
return some_method()


@surrogate('xxx.yyy.zzz.some_method')
@patch('xxx.yyy.zzz.some_method', return_value="mock result")
def test_method(mock_func):
print(method_need_to_be_test())


if __name__ == '__main__':
test_method()

'''
print content: mock result
'''

参考文章

  1. 单元测试mock模块介绍

  2. Python内置库 unittest.mock 的基础使用

  3. How do I mock the hierarchy of non-existing modules|StackOverflow


Python单元测试之Mock的使用
http://example.com/2023/07/28/Python单元测试之Mock的使用/
作者
EverNorif
发布于
2023年7月28日
许可协议