pytest中使用mock

为什么要用mock?

单元测试的条件有限,在测试过程中,有时会遇到难以准备的环境。 比如,与服务器的网络交互、对数据库的读写等。

传统思路,是利用fixture进行测试环境准备。 这种做法的优点是,与真实环境非常相似,测试效果好。 但缺点是,测试代码开发时间长,测试执行时间也很长。

另一种思路是,准备一个虚假的沙箱,对代码的执行效果进行模拟。 这样虽然不能测试真正的最终效果,但是却更容易保证100%测试覆盖率,并且避免重复测试,降低测试执行时间。

mock是什么?

mock的意思是虚假的、模拟的。 在Python的单元测试中,由于一切都是对象(object)。 而mock的技术,就是在测试时,不修改源码的前提下,替换某些对象,模拟测试环境。

比方说,一个函数里,调用了三个函数。 只需要测试这三个函数是否被依次调用即可,而无需测试真实的调用修改。

def put_elephent_into_fridge(elepent, fridge):
    fridge.open()
    fridge.put(elepent)
    fridge.close()

假设fridge这个类已经完全被测试覆盖了。 这里如果用传统的测试方法,只能让这三个方法再被测试一遍。 而如果把fridge换成一个mock,那么就可以避免重复测试,并且达到测试目的。

在Python标准库中,有unittest这个库。 在Python 3.3以后,其中包含一个unittest.mock,就是Python最常用的mock库。 此外,PyPI上还有一个mock库,是进入标准库前的mock,可以在旧的版本使用。

虽然可以直接在pytest的测试中,直接使用mock,但是并不方便。 所以,在此直接推荐pytest-mock

pytest-mock

pytest-mock是一个pytest的插件,安装即可使用。 它提供了一个名为mocker的fixture,仅在当前测试function或method生效,而不用自行包装。

object

mock一个object,是最常见的需求。 由于function也是一个object,以下以function举例。

import os


def rm(filename):
    os.remove(filename)


def test_rm(mocker):
    filename = 'test.file'
    mocker.patch('os.remove')
    rm(filename)
    os.remove.assert_called_once_with(filename)

这里在给os.remove打了一个patch,让它变成了一个MagicMock。 然后利用assert_called_once_with,查看它是否被调用一次,并且参数为filename

注意:只能对已经存在的东西使用mock。

method

有时,仅仅需要mock一个object里的method,而无需mock整个object。 例如,在对当前object的某个method进行测试时。 这时,可以用patch.object

class ForTest:
    field = 'origin'

    def method():
        pass


def test_for_test(mocker):
    test = ForTest()
    mock_method = mocker.patch.object(test, 'method')
    test.method()
    assert mock_method.called

    assert 'origin' == test.field
    mocker.patch.object(test, 'field', 'mocked')
    assert 'mocked' == test.field

上例中,分别对field和method进行了mock。 当然,对一个给定module的function,也能使用。

def test_patch_object_listdir(mocker):
    mock_listdir = mocker.patch.object(os, 'listdir')
    os.listdir()
    assert mock_listdir.called

用spy包装

如果只是想用MagicMock包装一个东西,而又不想改变其功能,可以用spy

def test_spy_listdir(mocker):
    mock_listdir = mocker.spy(os, 'listdir')
    os.listdir()
    assert mock_listdir.called

与上例中的patch.object不同的是,上例的os.listdir()不会真的执行,而本例中则会真的执行。

MagicMock

即使使用pytest-mock简化使用过程,对mock本身还是要有基本的了解,尤其是MagicMock

MagicMock是属于unittest.mock中的一个类,是Mock这个类的一个默认实现。 在构造时,还常用return_valueside_effectwraps这三个参数。 (当然,还有其它不常用参数,详见Mock。)

import os
import pytest


def name_length(filename):
    if not os.path.isfile(filename):
        raise ValueError('{} is not a file!'.format(filename))
    print(filename)
    return len(filename)


def test_name_length0(mocker):
    isfile = mocker.patch('os.path.isfile', return_value=True)
    assert 4 == name_length('test')
    isfile.assert_called_once()

    isfile.return_value = False
    with pytest.raises(ValueError):
        name_length('test')
    assert 2 == isfile.call_count


def test_name_length1(mocker):
    mocker.patch('os.path.isfile', side_effect=TypeError)
    with pytest.raises(TypeError):
        name_length('test')


def test_name_length2(mocker):
    mocker.patch('os.path.isfile', return_value=True)
    mock_print = mocker.patch('builtins.print', wraps=print)
    mock_len = mocker.patch(__name__ + '.len', wraps=len)
    assert 4 == name_length('test')
    assert mock_print.called
    assert mock_len.called

以上展示了return_valueside_effectwraps的用法。 不仅可以在构造MagicMock时作为参数传入,可以在那之后调整。 return_value修改了os.path.isfile的返回值,控制程序执行流,而无需在文件系统中生成文件。 side_effect可以令某些函数抛出指定的异常。 wraps可以既把某些函数包装成MagicMock,又不改变它的执行效果(这一点类似spy);当然,也完全可以替换成另一个函数。

在Python 3中,内置函数可以通过builtins.*来进行mock。 然而某些内置函数牵涉甚广,比如len,不适合在Builtin作用域进行mock,可以在被测试的函数所在的Global作用域进行mock。 如本例中,就对当前module的Global作用域里的len进行了mock。

此外,上例中还展示了MagicMock中的一些属性,如assert_called_oncecall_countcalled等,详见Mock

总结

无论是pytest-mock这层薄薄的封装,还是unittest.mock本身,都还有很多未介绍的细节。 但以上介绍的内容,应该已经足够满足绝大部分使用场景。

在懂了mock之后,Python的单元测试功力,终于算是大成了。

参考


相关笔记