最近开始用 pytest 写单元测试,发现 pytest 官方教程内容非常多,而且 pytest 里也有很多 unittest 的内容,很容易让人摸不着头脑。pytest 的 example 也很少/很抽象,因此写篇博客记录一下自己的使用。

由于 pytest 和 unittest 用法的变种很多(如 Mock.assert_has_calls 的多个变种,patch 的函数形式和装饰器形式),这里不会事无巨细地讲所有用法,只会讲我偏好的用法。

博客对应的代码可以在 Github (opens new window) 上找到。

# 初始化项目

这篇博客使用的 Python 为 3.10.4,pytestpytest-mock 版本为:

pytest-mock==3.10.0
pytest==7.2.0

为了让 pytest 能够正确按照模块识别路径,记得在项目根目录放一个 pytest.inipyproject.tomlReference (opens new window)):

# pytest.ini
[pytest]
pythonpath = .

# 对库函数和类进行 mock

这种常见的场景是测试对象会调用库函数,可能是外部的库函数(如 os.getlogin()time.time()),也可能是项目中编写的库函数。

我们需要 mock 库函数以保证相同的、不依赖外部环境、快速的输出(stub),同时监测库函数被调用的次数、每次调用的参数等(spy)。

这种场景下,我们可以使用 unittest.mock.patch 装饰器,也可以使用 pytest 提供的 mocker.patch 方法。

# 对库函数进行 mock (mocker.patch)

实际使用中发现 mocker.patch.object() 也可以 mock 库函数,并且语法更优雅,因此更推荐下面的 mocker.patch.object 方案

测试对象在 app/greetings.py,测试函数在 test/test_mocking_lib_function.py

# app/greetings.py
from os import getlogin

def get_greeting_string_from_import_function():
    user = getlogin()
    return f'Greetings, {user}'


# test/test_mocking_lib_function.py
from pytest_mock import MockerFixture

def test_get_greeting_string_from_import_function(mocker: MockerFixture):
    # you should mock `app.greetings.getlogin` instead of `os.login`
    mocked_getlogin = mocker.patch('app.greetings.getlogin', return_value='user')
    assert get_greeting_string_from_import_function() == 'Greetings, user'

    mocked_getlogin.assert_has_calls([
        mocker.call(),
    ])
    # you can also use assert_called_with
    mocked_getlogin.assert_called_with()

注:

  1. 如果测试对象 app.greetings 使用的是 from x import y 的形式 import 需要 mock 的函数,mocker.patch('x.y') 会失效,需要 mocker.patch('app.greetings.y') 才能生效
  2. MockerFixture.assert_has_calls() 有很多变种,可以根据 IDE 提示选择合适的变种
  3. 也可以使用由 unittest 提供的、装饰器风格的 mock.patch。函数对应的 Mock 会作为第一个参数传入测试函数
from unittest.mock import Mock, patch, call

# decorator-style of mocker.patch
@patch('app.greetings.getlogin', return_value='user')
def test_get_greeting_string_from_import_function__decorator_style(mocked_get_login: Mock):
    assert get_greeting_string_from_import_function() == 'Greetings, user'

    mocked_get_login.assert_has_calls([
        call(),
    ])

# 对库函数进行 mock (mocker.patch.object)

这里使用 mocker.patch.object 来 mock 库函数,它的用法和 mocker.patch 类似,只是参数从函数的完整路径变为了 module + 方法名。它的返回值也是类方法对应的 Mock,可以使用 assert_has_calls 进行断言。

这种写法的优点在于,可以 import 需要 mock 的 module,而不需要将整个 module 的路径作为字符串传给 mocker.patch()。因此在实际使用中,我更偏好这种风格的 mock。

import os

from pytest_mock import MockerFixture

def test_get_greeting_string_from_import_function(mocker: MockerFixture):
    # mocked_getlogin = mocker.patch('app.greetings.getlogin', return_value='user')
    mocked_getlogin = mocker.patch.object(os, 'getlogin', return_value='user')

# 对库中的类进行 mock

对库中的类可以使用 mocker.patch.object 进行 mock,用法也类似,只是参数从函数名变为了类+方法名。它的返回值也是类方法对应的 Mock,可以使用 assert_has_calls 进行断言。

如果需要对一个类的多个方法进行 mock,对同一个类执行多次 patch.object 即可。

# lib/utils.py
from os import getlogin, curdir

class HostUtils:
    def __init__(self):
        self._username = getlogin()

    def get_username(self):
        return self._username

    def get_current_path(self):
        return curdir


# app/greetings.py
from lib.utils import HostUtils

def get_greeting_string_from_import_class_method():
    host_utils = HostUtils()
    user = host_utils.get_username()
    return f'Greetings, {user}'


# test/test_mocking_lib_function.py
from pytest_mock import MockerFixture
from lib.utils import HostUtils

def test_get_greeting_string_from_import_class_method(mocker: MockerFixture):
    mocked_get_username = mocker.patch.object(HostUtils, 'get_username', return_value='user')
    mocked_get_current_path = mocker.patch.object(HostUtils, 'get_current_path', return_value='/')
    assert get_greeting_string_from_import_class_method() == 'Greetings, user'
    assert HostUtils().get_current_path('qwerty') == '/'

    mocked_get_username.assert_has_calls([
        mocker.call(),
    ])
    mocked_get_current_path.assert_has_calls([
        mocker.call('qwerty')
    ])

实际使用中发现 mocker.call() mypy 类型检查时可能会报错,虽然不影响运行,但是红色下划线比较影响开发体验。可以改为 unittest.mock.call() 来解决报错。

同样也可以使用 unittest 提供的、装饰器风格的 mock.patch.object。不过需要注意,由于装饰器的执行顺序是由下至上(最靠近函数的最先执行),方法对应的 Mock 版本的传入顺序是和装饰器执行顺序一致。

from unittest.mock import Mock, patch, call
from lib.utils import HostUtils

# decorator-style of mocker.patch.object
@patch.object(HostUtils, 'get_username', return_value='user')
@patch.object(HostUtils, 'get_current_path', return_value='/')
def test_get_greeting_string_from_import_class_method_decorator_style(mocked_get_current_path: Mock, mocked_get_username: Mock):
    assert get_greeting_string_from_import_class_method() == 'Greetings, user'
    assert HostUtils().get_current_path('qwerty') == '/'

    mocked_get_username.assert_has_calls([
        call(),
    ])
    mocked_get_current_path.assert_has_calls([
        call('qwerty')
    ])

# 对参数中的函数和对象进行 mock

如果需要 mock 的函数和对象不是由测试对象自己 import 后使用,而是作为参数传入,我们就不需要对库函数进行 mock,只需要构造一个白纸一般的 Mock 对象,然后把它作为参数传入测试对象。

测试对象可以把 Mock 当做一个函数进行调用,也可以当做类访问其属性(属性也是一个 Mock 类型,也就是说可以把属性作为方法调用,或访问属性的属性……)。

需要注意的是,当访问 Mock 的 method 方法时,assert_has_calls 断言中的 call() 也要改为 call.method()

# lib/request_manager.py
from time import sleep

class Request:
    def get(self, index: int):
        # request data from network
        sleep(index)


def request_ten_times(request: Request):
    for i in range(10):
        request.get(i)


def call_ten_times(func: callable):
    for i in range(10):
        func(i)


# tests/test_mocking_param.py
from pytest_mock import MockerFixture
from lib.request_manager import request_ten_times, call_ten_times

def test_call_with_mock_function(mocker: MockerFixture):
    mocked_func = mocker.Mock()
    # treat Mock as a function
    call_ten_times(mocked_func)

    mocked_func.assert_has_calls([
        mocker.call(index) for index in range(10)
    ])


def test_call_with_mock_object(mocker: MockerFixture):
    mocked_request = mocker.Mock()
    # treat Mock as an object
    request_ten_times(mocked_request)

    mocked_request.assert_has_calls([
        # here you should use `call.get()` instead of `call()` to assert `mock.get()` has been called
        mocker.call.get(index) for index in range(10)
    ])

可以通过一个简单的测试证明 Mock 对象的任意属性也是一个 Mock 对象

# tests/test_mocking_param.py
def test_type_of_mock_field(mocker: MockerFixture):
    mock = mocker.Mock()
    assert isinstance(mock, mocker.Mock)
    assert isinstance(mock.get, mocker.Mock)
    assert isinstance(mock.unknown_field,  mocker.Mock)
    assert isinstance(mock.unknown_field.field2,  mocker.Mock)

# pylint 和 pytest

TODO