上篇文章介绍了python
测试开发的第一大神器fixture
, 本篇则聚焦另一神器mock
。
关于mock
业务逻辑会依赖第三方API,或者特定数据库内容。如果单元测试把这些依赖都囊括进来,逻辑将会十分复杂。运行测试也会消耗大量时间和计算资源。这种情况下,使用unittest.mock模拟外部依赖,提供“设计”好的结果给测试逻辑用,会起到事半功倍的效果。
好的系统设计层次分明,每层都将细节封装,抽象出一组API供上层逻辑调用。因此,下层API都可以视作上层逻辑的“外部依赖”。对上层逻辑进行测试时,可使用mock
模拟下层API的返回值。这样做优点有三:
- 首要优点涉及“分层系统测试原则”,即:分层系统测试,每一层的测试重点应该放在当前层的逻辑(如何调用各下层API,并将结果组装为本层结果);而不应该分散精力测试下层API对错 (下层API对错应由下层API的单元测试保证,无需在当前层重复验证)。使用
mock
模拟下层API是上述测试原则的体现。 - 其次,系统分层的目的就是隐藏细节,防止底层细节变化对上层逻辑造成冲击。单元测试在验证上层逻辑时,也应该遵循同样的思想:避免依赖底层“细节”准备测试数据。否则底层细节一旦变化,测试用例不得不调整。
- 最后,无需每层测试都从底层开始准备测试数据,减少单元测试的复杂度。
举个例子,假设系统有两层:数据层MySQLProvider
和CacheCombinedDataProvider
。
1class MySQLProvider:
2 _data = []
3
4 def insert(self, item):
5 if item >= 2:
6 MySQLProvider._data.append(item)
7
8 def select(self):
9 return MySQLProvider._data
10
11 def delete(self, item):
12 MySQLProvider._data.remove(item)
13
14class CacheCombinedDataProvider:
15 def __init__(self):
16 self._db = MySQLProvider()
17
18 def get_data(self):
19 data = self._db.select()
20 first = data[0]
21 remained = data[1:]
22 return [x for x in remained if 1 < first + x < 5]
不用mock
,则测试用例如下:
1def test_mysql_provider():
2 # 底层API测试用例
3 target = MySQLProvider()
4 try:
5 # 准备测试数据
6 target.insert(1)
7 target.insert(2)
8 # 测试
9 assert 1 == len(target.select())
10 finally:
11 # 清理数据
12 target.delete(2)
13
14
15def test_cache_combined_data_provider():
16 # 底层API测试用例
17 target = MySQLProvider()
18 try:
19 # 准备测试数据
20 target.insert(2)
21 target.insert(2.5)
22 target.insert(5)
23 # 测试
24 assert 1 == len(CacheCombinedDataProvider().get_data())
25 assert CacheCombinedDataProvider().get_data()[0] == 2.5
26 finally:
27 # 清理数据
28 target.delete(2)
29 target.delete(2.5)
30 target.delete(5)
上述测试有几个问题:
- 为了测试
get_data
,不得不在test_cache_combined_data_provider
中重复test_mysql_provider
做过的准备工作。 -test_cache_combined_data_provider
失败的原因应该是CacheCombinedDataProvider().get_data()
逻辑有缺陷。然而当前的测试方法下,insert
或者select
有错误,也会导致测试失败。这就违反了前文所述第一原则,当前测试不仅在测试当前层,还分散了精力重复测试底层(即test_mysql_provider
应该保证的内容) - 为了能测试
get_data
,我们不得不理解insert
逻辑,插入满足测试需要的数据;一旦insert
逻辑发生变化,test_cache_combined_data_provider
相关代码也不得不调整。何其无辜!get_data
的逻辑并没有任何变化呀!
如果我们使用mock
,则代码变为:
1def test_cache_combined_data_provider_2():
2 with mock.patch.object(MySQLProvider, "select") as mock_select:
3 mock_select.return_value = [2, 2.5, 5]
4 actual = CacheCombinedDataProvider().get_data()
5 assert 1 == len(actual)
6 assert actual[0] == 2.5
测试代码清爽许多。更重要的是,只要select
接口不变,test_cache_combined_data_provider_2
就无需修改。一旦test_cache_combined_data_provider_2
测试失败,毫无疑问get_data
出了bug
(而不是MySQLProvider
有bug
)。
使用mock测试的缺点
有好必有坏, 使用mock
进行单元测试的诟病之一就是:单元测试不再是针对API输入输出的测试,而是“利用(耦合)”当前层的实现细节(mock
实现细节中的某些依赖)。因此两种情况下,应避免使用mock
:
- 使用
mock
带来的复杂度不比准备测试数据低多少,甚至更高时,不妨直接准备测试数据。 - 不希望单元测试太“白盒”的时候,每层逻辑都按照API定义的输入输出严格做黑盒测试,不需要
mock
细节。
mock类实例方法
mock
类的实例方法有多种方法,例如:
-mock
类的实例方法(见:test_call_take_1
)。
-mock
类本身,将生成的类实例替换为mock.MagicMock
(见:test_call_take_2
)。
1class Lower:
2 def take(self, item):
3 print(self)
4
5def call_take(a):
6 Lower().take(a)
7
8def test_call_take_1():
9 with mock.patch.object(Lower, "take") as take_func:
10 call_take(1)
11 take_func.assert_called_once_with(1)
12
13def test_call_take_2():
14 with mock.patch(__name__ + ".Lower") as Lower_class:
15 Lower_class.return_value = mock.MagicMock()
16 call_take(1)
17 Lower_class.return_value.take.assert_called_once_with(1)
但是你会意识到,无论哪一种方法,在调用take
时,都“忽略”了self
参数。
Python
中有bound method
和unbound method
的区别。bound method
就是类的实例方法,方法中第一个参数为self
;unbound method
就是类的静态方法。当mock
类实例方法时,无形中把bound method
变为了unbound method
(patch
和patch.object
隐藏了self
)。
大部分时候,这样的“忽略”对测试目标没有影响。不过如果你的确需要测试bound method
, 比如下面这种情况:你需要确认take
函数接收到的self
的a
已经赋值(这属于要验证的call_take
的逻辑部分)。
1class Lower:
2 def __init__(self, default_a):
3 self.a = default_a
4
5 def take(self, new_a):
6 self.a = new_a
7
8def call_take(a):
9 default_a = fetch_default_a_via_remove_config_server()
10 Lower(default_a).take(a)
可以使用mock.patch.object
的autospec=True设置,将mock
出的方法的签名改为和原始方法一致(即:变为bound method
,调用时传入self
作为第一个参数)
1def test_call_take_1():
2 with mock.patch.object(Lower, "take", autospec=True) as take_func:
3 call_take(1)
4 take_func.assert_called_once_with(1)
执行起来,take_func.assert_called_once_with(1)
测试失败,因为take_func
收到的第一个参数是self
而不是1
。
1E AssertionError: expected call not found.
2E Expected: take(1)
3E Actual: take(<test.Lower object at 0x7f661e83c760>, 1)
4E
5E pytest introspection follows:
6E
7E Args:
8E assert (<test.Lower object at 0x7f661e83c760>, 1) == (1,)
9E At index 0 diff: <test.Lower object at 0x7f661e83c760> != 1
10E Left contains one more item: 1
11E Full diff:
12E - (1,)
13E + (<test.Lower object at 0x7f661e83c760>, 1)
正确的测试如下:
1def test_call_take_1():
2 default_a = 10
3 with mock.patch.object(Lower, "take", autospec=True) as take_func, \
4 mock.patch(__name__ + ".fetch_default_a_via_remove_config_server", return_value=default_a):
5 call_take(1)
6 take_func.assert_called_once()
7 args, _ = take_func.call_args
8 assert args[0].a == default_a # 验证self.a已经被赋值
9 assert args[1] == 1
通过获取mock
对象的call_args
,拿到第一个参数(即self
),检查是否和default_a
一致。
mock类的property
假设类定义如下:
1class A:
2 @property
3 def f(self):
4 return "a"
5
6 def foo(self):
7 return "aa"
如果希望mock
属性f
, 可否直接将f
换为mock.Mock
? 毕竟property
本质上是一个方法,如果foo
可以直接被替换,理论上f
也应该可以。
1def test_property():
2 a = A()
3 a.foo = mock.Mock(return_value="bb")
4 assert a.foo() == "bb"
5 a.f = mock.Mock(return_value="b")
6 assert a.f == "b"
很遗憾foo
的替换成功了,但是f
的替换由于f
是个只读属性失败了(AttributeError: can't set attribute
)。那是不是把f
改为写属性就可以了?
1class A:
2 @property
3 def f(self):
4 return "a"
5
6 @f.setter
7 def f(self, v):
8 pass
9
10def test_property():
11 a = A()
12 a.f = mock.Mock(return_value="b")
13 assert a.f == "b"
依旧行不通。mock.Mock
被当作值v
被赋值给A
的实例,而并没有替换整个函数f
。正确的做法是通过mock.patch
将f
替换为一个mock.Mock
使用。下面是正确的测试代码:
1def test_property2():
2 with mock.patch(__name__ + ".A.f", new_callable=mock.Mock(return_value="b")):
3 assert A().f == "b"
mock asyncio
介绍mock asyncio
之前,需要提一下pytest
不能直接测试asyncio
代码。需要安装插件pytest-asyncio <https://github.com/pytest-dev/pytest-asyncio>
_。pytest-asyncio
的具体使用,可参看相关文档。下面是一段最基础的async
函数测试示例:
1# -*- encoding: utf-8 -*-
2import asyncio
3import pytest
4
5async def async_foo():
6 await asyncio.sleep(0.1)
7 return "a"
8
9@pytest.mark.asyncio
10async def test_mock_foo():
11 res = await async_foo()
12 assert "a" == res
Python 3.8
之后,unittest.mock
提供了AsyncMock
对象,让mock``async
方法和普通方法几乎一样:
1@pytest.mark.asyncio
2async def test_mock_foo():
3 with mock.patch(__name__ + ".async_foo", side_effect=mock.AsyncMock(return_value="b")):
4 res = await async_foo()
5 assert "b" == res
对于老版本的Python
, 可以安装asyncmock
包使用AsyncMock
。