最近开始用 pytest 写单元测试,发现 pytest 官方教程内容非常多,而且 pytest 里也有很多 unittest 的内容,很容易让人摸不着头脑。pytest 的 example 也很少/很抽象,因此写篇博客记录一下自己的使用。
由于 pytest 和 unittest 用法的变种很多(如 Mock.assert_has_calls
的多个变种,patch
的函数形式和装饰器形式),这里不会事无巨细地讲所有用法,只会讲我偏好的用法。
博客对应的代码可以在 Github (opens new window) 上找到。
# 初始化项目
这篇博客使用的 Python 为 3.10.4,pytest
和 pytest-mock
版本为:
pytest-mock==3.10.0
pytest==7.2.0
为了让 pytest 能够正确按照模块识别路径,记得在项目根目录放一个 pytest.ini
或 pyproject.toml
(Reference (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()
注:
- 如果测试对象
app.greetings
使用的是from x import y
的形式 import 需要 mock 的函数,mocker.patch('x.y')
会失效,需要mocker.patch('app.greetings.y')
才能生效 MockerFixture.assert_has_calls()
有很多变种,可以根据 IDE 提示选择合适的变种- 也可以使用由
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