单元测试的概念可能多数读者都有接触过。作为开发人员,我们编写一个个测试用例,测试框架发现这些测试用例,将它们组装成测试 suite 并运行,收集测试报告,并且提供测试基础设施(断言、mock、setup 和 teardown 等)。Python 当中最主流的单元测试框架有三种,Pytest, nose 和 Unittest,其中 Unittest 是标准库,其它两种是第三方工具。在 ppw 向导生成的项目中,就使用了 Pytest 来驱动测试。
这里主要比较一下 pytest 和 unittest。多数情况下,当我们选择单元测试框架时,选择二者之一就好了。unitttest 基于类来组织测试用例,而 pytest 则是函数式的,基于模块来组织测试用例,同时它也提供了 group 概念来组织测试用例。pytest 的 mock 是基于第三方的 pytest-mock,而 pytest-mock 实际上只是对标准库中的 mock 的简单封装。单元测试都会有 setup 和 teardown 的概念,unittest 直接使用了 setUp 和 tearDown 作为测试入口和结束的 API,在 pytest 中,则是通过 fixture 来实现,这方面学习曲线可能稍微陡峭一点。在断言方面,pytest 使用 python 的关键字 assert 进行断言,比 unittest 更为简洁,不过断言类型上没有 unittest 丰富。
另外一个值得一提的区别是,unittest 从 python 3.8 起就内在地支持 asyncio,而在 pytest 中,则需要插件 pytest-asyncio 来支持。但两者在测试的兼容性上并没有大的不同。
pytest 的主要优势是有:
- pytest 的测试用例更简洁。由于测试用例并不是正式代码,开发者当然希望少花时间在这些代码上,因此代码的简洁程度很重要。
- 提供了命令行工具。如果我们仅使用 unittest,则执行单元测试必须要使用
python -m unittest
来执行;而通过 pytest 来执行单元测试,我们只需要调用pytest .
即可。 - pytest 提供了 marker,可以更方便地决定哪些用例执行或者不执行。
- pytest 提供了参数化测试。
这里我们简要地举例说明一下什么是参数化测试,以便读者理解为什么参数化测试是一个值得一提的优点。
python
# 示例 7 - 1
import pytest
from datetime import datetime
from src.example import get_time_of_day
@pytest.mark.parametrize(
"datetime_obj, expect",
[
(datetime(2016, 5, 20, 0, 0, 0), "Night"),
(datetime(2016, 5, 20, 1, 10, 0), "Night"),
(datetime(2016, 5, 20, 6, 10, 0), "Morning"),
(datetime(2016, 5, 20, 12, 0, 0), "Afternoon"),
(datetime(2016, 5, 20, 14, 10, 0), "Afternoon"),
(datetime(2016, 5, 20, 18, 0, 0), "Evening"),
(datetime(2016, 5, 20, 19, 10, 0), "Evening"),
],
)
def test_get_time_of_day(datetime_obj, expect, mocker):
mock_now = mocker.patch("src.example.datetime")
mock_now.now.return_value = datetime_obj
assert get_time_of_day() == expect
在这个示例中,我们希望用不同的时间参数,来测试 get_time_of_day 这个方法。如果使用 unittest,我们需要写一个循环,依次调用 get_time_of_day(),然后对比结果。而在 pytest 中,我们只需要使用 parametrize 这个注解,就可以传入参数数组(包括期望的结果),进行多次测试,不仅代码量要少不少,更重要的是,这种写法更加清晰。
基于以上原因,在后面的内容中,我们将以 pytest 为例进行介绍。
1. 测试代码的组织
我们一般将所有的测试代码都归类在项目根目录下的 tests 文件夹中。每个测试文件的名字,要么使用 test_.py,要么使用 _test.py。这是测试框架的要求。如此以来,当我们执行命令如pytest tests
时,测试框架就能从这些文件中发现测试用例,并组合成一个个待执行的 suite。
在 test_*.py 中,函数名一样要遵循一定的模式,比如使用 test_xxx。不遵循规则的测试函数,不会被执行。
一般来说,测试文件应该与功能模块文件一一对应。如果被测代码有多重文件夹,对应的测试代码也应该按同样的目录来组织。这样做的目的,是为了将商业逻辑与其测试代码对应起来,方便我们添加新的测试用例和对测试用例进行重构。
比如在 ppw 生成的示例工程中,我们有:
sample
├── sample
│ ├── __init__.py
│ ├── app.py
│ └── cli.py
├── tests
│ ├── __init__.py
│ ├── test_app.py
│ └── test_cli.py
注意这里面的__init__.py 文件,如果缺少这个文件的话,tests 就不会成为一个合法的包,从而导致 pytest 无法正确导入测试用例。
2. PYTEST
使用 pytest 写测试用例很简单。假设 sample\app.py 如下所示:
python
# 示例 7 - 2
def inc(x:int)->int:
return x + 1
则我们的 test_app.py 只需要有以下代码即可完成测试:
python
# 示例 7 - 3
import pytest
from sample.app import inc
def test_inc():
assert inc(3) == 4
这比 unittest 下的代码要简洁很多。
2.1. 测试用例的组装
在 pytest 中,pytest 会按传入的文件(或者文件夹),搜索其中的测试用例并组装成测试集合 (suite)。除此之外,它还能通过 pytest.mark 来标记哪些测试用例是需要执行的,哪些测试用例是需要跳过的。
python
# 示例 7 - 4
import pytest
@pytest.mark.webtest
def test_send_http():
pass # perform some webtest test for your app
def test_something_quick():
pass
def test_another():
pass
class TestClass:
def test_method(self):
pass
然后我们就可以选择只执行标记为 webtest 的测试用例:
shell
$ pytest -v -m webtest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 4 items / 3 deselected / 1 selected
test_server.py::test_send_http PASSED [100%]
===================== 1 passed, 3 deselected in 0.12s ======================
从输出可以看出,只有 test_send_http 被执行了。
这里的 webtest 是自定义的标记。pytest 还内置了这些标记,有的也可以用来筛选用例:
- pytest.mark.filterwarnings, 给测试用例添加 filterwarnings 标记,可以忽略警告信息。
- pytest.mark.skip,给测试用例添加 skip 标记,可以跳过测试用例。
- pytest.mark.skipif, 给测试用例添加 skipif 标记,可以根据条件跳过测试用例。
- pytest.mark.xfail, 在某些条件下(比如运行在某个 os 上),用例本应该失败,此时就应使用此标记,以便在测试报告中标记出来。
- pytest.mark.parametrize, 给测试用例添加参数化标记,可以根据参数化的参数执行多次测试用例。
这些标记可以用 pytest --markers 命令查看。
2.2. pytest 断言
在测试时,当我们调用一个方法之后,会希望将其返回结果与期望结果进行比较,以决定该测试是否通过。这被称之为测试断言。
pytest 中的断言巧妙地拦截并复用了 python 内置的函数 assert,由于您很可能已经接触过 assert 了,因而使得这一部分的学习成本变得非常低。
python
# 示例 7 - 5
def test_assertion():
# 判断基本变量相等
assert "loud noises".upper() == "LOUD NOISES"
# 判断列表相等
assert [1, 2, 3] == list((1, 2, 3))
# 判断集合相等
assert set([1, 2, 3]) == {1, 3, 2}
# 判断字典相等
assert dict({
"one": 1,
"two": 2
}) == {
"one": 1,
"two": 2
}
# 判断浮点数相等
# 缺省地, ORIGIN ± 1E-06
assert 2.2 == pytest.approx(2.2 + 1e-6)
assert 2.2 == pytest.approx(2.3, 0.1)
# 如果要判断两个浮点数组是否相等,我们需要借助 NUMPY.TESTING
import numpy
arr1 = numpy.array([1., 2., 3.])
arr2 = arr1 + 1e-6
numpy.testing.assert_array_almost_equal(arr1, arr2)
# 异常断言:有些用例要求能抛出异常
with pytest.raises(ValueError) as e:
raise ValueError("some error")
msg = e.value.args[0]
assert msg == "some error"
上面的代码分别演示了如何判断内置类型、列表、集合、字典、浮点数和浮点数组是否相等。这部分语法跟标准 python 语法并无二致。pytest 与 unittest 一样,都没有提供如何判断两个浮点数数组是否相等的断言,如果有这个需求,我们可以求助于 numpy.testing,正如例子中第 25~30 行所示。
有时候我们需要测试错误处理,看函数是否正确地抛出了异常,代码 32~37 演示了异常断言的使用。注意这里我们不应该这么写:
python
# 示例 7 - 6
try:
# CALL SOME_FUNC WILL RAISE VALUEERROR
except ValueError as e:
assert str(e) == "some error":
else:
assert False
上述代码看上去逻辑正确,但它混淆了异常处理和断言,使得他人一时难以分清这段代码究竟是在处理测试代码中的异常呢,还是在测试被调用函数能否正确抛出异常,明显不如异常断言那样清晰。
2.3. pytest fixture
一般而言,我们的测试用例很可能需要依赖于一些外部资源,比如数据库、缓存、第三方微服务等。这些外部资源的初始化和销毁,我们希望能够在测试用例执行前后自动完成,即自动完成 setup 和 teardown 的操作。这时候,我们就需要用到 pytest 的 fixture。
假定我们有一个测试用例,它需要连接数据库,代码如下(参见 code/chap07/sample/app.py)
python
# 示例 7 - 7
import asyncpg
import datetime
async def add_user(conn: asyncpg.Connection, name: str, date_of_birth: datetime.date)->int:
# INSERT A RECORD INTO THE CREATED TABLE.
await conn.execute('''
INSERT INTO users(name, dob) VALUES($1, $2)
''', name, date_of_birth)
# SELECT A ROW FROM THE TABLE.
row: asyncpg.Record = await conn.fetchrow(
'SELECT * FROM users WHERE name = $1', 'Bob')
# *ROW* NOW CONTAINS
# ASYNCPG.RECORD(ID=1, NAME='BOB', DOB=DATETIME.DATE(1984, 3, 1))
return row["id"]
我们先展示测试代码(参见 code/chap07/sample/test_app.py),再结合代码讲解 fixture 的使用:
python
# 示例 7 - 8
import pytest
from sample.app import add_user
import pytest_asyncio
import asyncio
# PYTEST-ASYNCIO 已经提供了一个 EVENT_LOOP 的 FIXTURE, 但它是 FUNCTION 级别的
# 这里我们需要一个 SESSION 级别的 FIXTURE,所以我们需要重新实现
@pytest.fixture(scope="session")
def event_loop():
policy = asyncio.get_event_loop_policy()
loop = policy.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope='session')
async def db():
import asyncpg
conn = await asyncpg.connect('postgresql://zillionare:123456@localhost/bpp')
yield conn
await conn.close()
@pytest.mark.asyncio
async def test_add_user(db):
import datetime
user_id = await add_user(db, 'Bob', datetime.date(2022, 1, 1))
assert user_id == 1
我们的功能代码很简单,就是往 users 表里插入一条记录,并返回它在表中的 id。测试代码调用 add_user 这个函数,然后检测返回值是否为 1(如果每次测试前都新建数据库或者清空表的话,那么返回的 ID 就应该是 1)。
这个测试显然需要连接数据库,因此我们需要在测试前创建一个数据库连接,然后在测试结束后关闭连接。并且,我们还会有多个测试用例需要连接数据库,因此我们希望数据库连接是一个全局的资源,可以在多个测试用例中共享。这就是 fixture 的用武之地。
fixture 是一些函数,pytest 会在执行测试函数之前(或之后)加载运行它们。但与 unitest 中的 setup 和 teardown 不同,pytest 中的 fixture 依赖是显式声明的。比如,在上面的 test_add_user 显式依赖了 db 这个 fixture(通过在函数声明中传入 db 作为参数),而 db 则又显示依赖 event_loop 这个 fixture。即使文件中还存在其它 fixture, test_add_user 也不会依赖到这些 fixture,因为依赖必须显式声明。
上面的代码中,我们演示的是对异步函数 add_user 的测试。显然,异步函数必须在某个 event loop 中执行,并且相关的初始化 (setup) 和退出操作 (teardown) 也必须在同一个 loop 中执行。这里是分别通过 pytest.mark.asyncio, pytest_asyncio 等 fixture 来实现的:
首先,我们需要将测试用例标注为异步执行,即上面的代码第 21 行。其次,test_add_user 需要一个数据库连接,该连接由 fixture db
来提供。这个连接的获得也是异步的,因此,我们不能使用 pytest.fixutre 来声明该函数,而必须使用@pytest_asyncio.fixture 来声明该函数。
最后,我们还必须提供一个 event_loop 的 fixture,它是一切的关键。当某个函数被 pytest.mark.asyncio 装饰时,该函数将在 event_loop 提供的 event loop 中执行。
我们还要介绍一下出现在第 6 行和第 13 行中的 scope='session'。这个参数表示 fixture 的作用域,它有四个可选值:function, class, module 和 session。默认值是 function,表示 fixture 只在当前测试函数中有效。在上面的示例中,我们希望这个 event loop 在一次测试中都有效,所以将 scope 设置为 session。
上面的例子是关于异步模式下的测试的。对普通函数的测试更简单一些。我们不需要 pytest.mark.asynio 这个装饰器,也不需要 event_loop 这个 fixture。所有的 pytest_asyncio.fixture 都换成 pytest.fixture 即可(显然,它必须、也只能装饰普通函数,而非由 async 定义的函数)。
我们通过上面的例子演示了 fixture。与 markers 类似,要想知道我们的测试环境中存在哪些 fixtures,可以通过 pytest --fixtures 来显示当前环境中所有的 fixture。
shell
$ pytest --fixtures
------------- fixtures defined from faker.contrib.pytest.plugin --------------
faker -- .../faker/contrib/pytest/plugin.py:24
Fixture that returns a seeded and suitable ``Faker`` instance.
------------- fixtures defined from pytest_asyncio.plugin -----------------
event_loop -- .../pytest_asyncio/plugin.py:511
Create an instance of the default event loop for each test case.
...
------------- fixtures defined from tests.test_app ----------------
event_loop [session scope] -- tests/test_app.py:45
db [session scope] -- tests/test_app.py:52
这里我们看到 faker.contrib 提供了一个名为 faker 的 fixture, 我们之前安装的、支持异步测试的 pytest_asyncio 也提供了名为 event_loop 的 fixture(为节省篇幅,其它几个省略了),以及我们自己测试代码中定义的 event_loop 和 db 这两个 fixture。
Pytest 还提供了一类特别的 fixture,即 pytest-mock。为了讲解方便,我们先安装 pytest-mock 这个插件,看看它提供的 fixture。
shell
$ pip install pytest-mock
pytest --fixture
------- fixtures defined from pytest_mock.plugin --------
class_mocker [class scope] -- .../pytest_mock/plugin.py:419
Return an object that has the same interface to the `mock` module, but
takes care of automatically undoing all patches after each test method.
mocker -- .../pytest_mock/plugin.py:419
Return an object that has the same interface to the `mock` module, but
takes care of automatically undoing all patches after each test method.
module_mocker [module scope] -- .../pytest_mock/plugin.py:419
Return an object that has the same interface to the `mock` module, but
takes care of automatically undoing all patches after each test method.
package_mocker [package scope] -- .../pytest_mock/plugin.py:419
Return an object that has the same interface to the `mock` module, but
takes care of automatically undoing all patches after each test method.
session_mocker [session scope] -- .../pytest_mock/plugin.py:419
Return an object that has the same interface to the `mock` module, but
takes care of automatically undoing all patches after each test method.
可以看到 pytest-mock 提供了 5 个不同级别的 fixture。关于什么是 mock,这是下一节的内容。
本文摘自《Python能做大项目》,将由机械工业出版社出版。全书已发表在大富翁量化上,欢迎提前阅读!