单元测试:Testing leads to failure, and failure leads to understanding

单元测试的概念可能多数读者都有接触过。作为开发人员,我们编写一个个测试用例,测试框架发现这些测试用例,将它们组装成测试 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 的主要优势是有:

  1. pytest 的测试用例更简洁。由于测试用例并不是正式代码,开发者当然希望少花时间在这些代码上,因此代码的简洁程度很重要。
  2. 提供了命令行工具。如果我们仅使用 unittest,则执行单元测试必须要使用python -m unittest来执行;而通过 pytest 来执行单元测试,我们只需要调用pytest .即可。
  3. pytest 提供了 marker,可以更方便地决定哪些用例执行或者不执行。
  4. 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 还内置了这些标记,有的也可以用来筛选用例:

  1. pytest.mark.filterwarnings, 给测试用例添加 filterwarnings 标记,可以忽略警告信息。
  2. pytest.mark.skip,给测试用例添加 skip 标记,可以跳过测试用例。
  3. pytest.mark.skipif, 给测试用例添加 skipif 标记,可以根据条件跳过测试用例。
  4. pytest.mark.xfail, 在某些条件下(比如运行在某个 os 上),用例本应该失败,此时就应使用此标记,以便在测试报告中标记出来。
  5. 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能做大项目》,将由机械工业出版社出版。全书已发表在大富翁量化上,欢迎提前阅读!

相关推荐
笨鸟先飞,勤能补拙1 小时前
AI 赋能网络安全:技术全景、成熟度评估与实战案例
人工智能·python·安全·web安全·网络安全·sqlite·github
长和信泰光伏储能2 小时前
京津冀光伏发电:绿色能源的未来之路
python·能源
浦信仿真大讲堂2 小时前
从重复操作到自动化闭环:如何让 CST 与 Python 真正协同起来
python·自动化·cst·仿真软件·达索软件
Gu Gu Study3 小时前
ScoutLoop开放域深度研究引擎(agent的初步设计想法)
人工智能·python
卷无止境3 小时前
写代码这件事,到底该讲究点什么?
后端·python
卷无止境3 小时前
循环复杂度到底在算什么,Python 代码怎么才能写得让人一看就懂
后端·python
lpfasd1234 小时前
MediaCrawler 项目深度分析
chrome·python·chrome devtools
Dxy12393102164 小时前
Python项目打包成EXE完整教程(PyInstaller实战避坑)
开发语言·python
bamb005 小时前
一个项目带你入门AI应用开发01
python
0566465 小时前
Python康复训练——常用标准库
开发语言·python·学习