Python测试开发2 - mock

Posted on | 3130 words | ~7 mins

上篇文章介绍了python测试开发的第一大神器fixture, 本篇则聚焦另一神器mock

关于mock

业务逻辑会依赖第三方API,或者特定数据库内容。如果单元测试把这些依赖都囊括进来,逻辑将会十分复杂。运行测试也会消耗大量时间和计算资源。这种情况下,使用unittest.mock模拟外部依赖,提供“设计”好的结果给测试逻辑用,会起到事半功倍的效果。

好的系统设计层次分明,每层都将细节封装,抽象出一组API供上层逻辑调用。因此,下层API都可以视作上层逻辑的“外部依赖”。对上层逻辑进行测试时,可使用mock模拟下层API的返回值。这样做优点有三:

  • 首要优点涉及“分层系统测试原则”,即:分层系统测试,每一层的测试重点应该放在当前层的逻辑(如何调用各下层API,并将结果组装为本层结果);而不应该分散精力测试下层API对错 (下层API对错应由下层API的单元测试保证,无需在当前层重复验证)。使用mock模拟下层API是上述测试原则的体现。
  • 其次,系统分层的目的就是隐藏细节,防止底层细节变化对上层逻辑造成冲击。单元测试在验证上层逻辑时,也应该遵循同样的思想:避免依赖底层“细节”准备测试数据。否则底层细节一旦变化,测试用例不得不调整。
  • 最后,无需每层测试都从底层开始准备测试数据,减少单元测试的复杂度。

举个例子,假设系统有两层:数据层MySQLProviderCacheCombinedDataProvider

 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(而不是MySQLProviderbug)。

使用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 methodunbound method的区别。bound method就是类的实例方法,方法中第一个参数为selfunbound method就是类的静态方法。当mock类实例方法时,无形中把bound method变为了unbound methodpatchpatch.object隐藏了self)。

大部分时候,这样的“忽略”对测试目标没有影响。不过如果你的确需要测试bound method, 比如下面这种情况:你需要确认take函数接收到的selfa已经赋值(这属于要验证的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.objectautospec=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.patchf替换为一个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