查看原文
其他

7k Star 的 Python 测试框架入门指南

The following article is from Python开发精选 Author felixGuo26

Pytest 是一款 Python 测试框架及测试运行器。在本指南中,我们将会介绍 pytest 最有用和常见的配置和用法,以及几个 pytest 插件和外部库。尽管 Python 的标准库中已经自带了 unittest 模块,并且也还有其他 Python 测试框架(例如 nose2 或 Ward),但 pytest 仍然是我的最爱。使用简单函数而不是类层次结构,一个简单断言(assert)而不是许多不同的 assert 函数,内置的参数化测试,完善的 fixture 系统以及一定量的可用插件,这些特性让 Pytest 用起来很顺手。

Pytest 源码主页

https://github.com/pytest-dev/pytest

本文的示例代码

https://github.com/stribny/python-pytest

pytest基础

在安装 pytest 后,例如用poetry执行poetry add pytest --dev,我们可以通过执行 pytest 或执行python -m pytest来运行我们的测试套件,这还会将当前目录添加到 sys.path 中。

Pytest将自动在我们的代码库中扫描测试模块,会查找符合文件名称格式test_*.py*_test.py的文件,扫描它们并查找名为test_*()的函数。如果要运行类内部方法的测试函数,则需要在类名前面加上 Test。Pytest 还将自动识别使用 unittest 模块编写的所有测试。

通常,我们都想要将应用程序测试代码放在像tests/文件夹这样定义好的路径上。这样的话,最优办法是使用pytest.ini配置文件通知pytest去哪里寻找测试代码:

[pytest] 
testpaths = tests

这可以大大加快我们的测试运行速度,尤其是在大型代码库上。如果想要进一步了解修改pytest定位测试代码的方式,请参阅官方指南 Changing standard test discovery。

最后要考虑的是如何让 pytest 加载测试模块。Pytest提供了不同的导入模式,具体取决于我们是否将测试放在了模块(带有__init__.py文件的文件夹)中,是否给测试文件定义了唯一名称以及是否需要使用不同版本的Python来测试应用程序或库。默认的prepend模式对于只使用一个特定Python版本测试应用程序并且所有测试都放在tests/文件夹中的常见场景来说已经足够了。为了避免名称冲突,我们只需要通过添加一个__init__.py文件将内部测试文件夹升级为模块即可。

标准测试功能和测试运行

让我们看一下一个简单的函数测试,该函数add应该能够将两个数字相加:

from python_pytest.basics import add

def test_add_can_add_numbers():
    # given
    num = 3
    num2 = 45

    # when
    result = add(num, num2)

    # then
    assert result == 48

想要编写一个像这样的简单测试,我们只需要做两件事:定义一个带有test_前缀的函数,并使用Python的assert来检查函数的结果是否符合我们的预期。assert是Python标准库的一部分,其行为与我们所知的一致。唯一的区别是pytest在运行之前会在后台完全重写测试,以便在测试失败时可以提供有用的报告。这就是pytest真正的魔力:测试不同的断言时,我们不必记住任何特殊功能。任何结果为 True 或 False 的Python代码都可以测试断言。

你会注意到我使用了given,when和then的注释将测试用例划分为测试需求,测试过程和期望结果。我这样做是为了提高可读性,并在编写链接中的文章时详细解释了这一点。

===================================== test session starts =====================================
platform linux -- Python 3.7.9, pytest-5.4.3, py-1.10.0, pluggy-0.13.1
rootdir: /home/pstribny/projects/python-pytest, inifile: pytest.ini
collected 1 item                                                                              

tests/test_basics.py .                                                                  [100%]

====================================== 1 passed in 0.04s ======================================

可以运行pytest以获取有关该add函数是否运行正确的报告:

===================================== test session starts =====================================
platform linux -- Python 3.7.9, pytest-5.4.3, py-1.10.0, pluggy-0.13.1
rootdir: /home/pstribny/projects/python-pytest, inifile: pytest.ini
collected 1 item                                                                              

tests/test_basics.py .                                                                  [100%]

====================================== 1 passed in 0.04s ======================================

如果我们把add的代码中的计算结果改成错误的,pytest将告诉我们:

========================================== test session starts ===========================================
platform linux -- Python 3.7.9, pytest-5.4.3, py-1.10.0, pluggy-0.13.1
rootdir: /home/pstribny/projects/python-pytest, inifile: pytest.ini
collected 1 item                                                                                         

tests/test_basics.py F                                                                             [100%]

================================================ FAILURES ================================================
________________________________________ test_add_can_add_numbers ________________________________________

    def test_add_can_add_numbers():
        # given
        num = 3
        num2 = 45

        # when
        result = add(num, num2)

        # then
>       assert result == 48
E       assert 49 == 48

tests/test_basics.py:13: AssertionError
======================================== short test summary info =========================================
FAILED tests/test_basics.py::test_add_can_add_numbers - assert 49 == 48
=========================================== 1 failed in 0.08s ============================================

我们可以看到pytest将打印互相比较的两个值,并指出这个失败的断言。

选择要运行的测试

我们有几个选项可以控制到底执行哪些测试:

  • 正如我们已经讨论过的,配置测试代码定位方式
  • 告诉pytest运行哪个测试模块或测试函数
  • 使用pytest标记装饰器将测试分组

让我们看一些示例,假设我们的测试用例test_add_can_add_numbers在tests/test_basics.py中。

想要运行某个测试模块中的所有测试用例,我们只需要提供文件的路径即可:

pytest tests/test_basics.py

要测试某个路径下的所有测试用例,需要提供该目录的路径:

pytest tests/

要运行指定的某个测试用例,我们需要其ID,包括测试模块和名称:

pytest tests/test_basics.py::test_add_can_add_numbers

我们还可以使用-k参数来通过关键字表达式选择运行的测试用例。在表达式中可以使用运算符and、or、not和圆括号,来修饰文件名、类名和函数名。搜索方式是模糊查询,而且文档也不太全,例如我们可以运行模块中的所有测试,而不提供其完整路径,或者运行除某个测试以外的所有其他测试:

pytest -k "basics.py"
pytest -k "not basics.py"

函数名称的写法也类似:

pytest -k "can_add_numbers"

如果我们经常需要运行一组特定的测试,则最好为它们创建自定义pytest标记装饰器。标记是可以与测试函数一起使用的Python装饰器。某些特殊标记是pytest预先定义好的,并且具有自己的特殊功能,我们也可以自己定义别的标记。让我们看一个简单的测试用例:

import pytest

def test_1():
    ...

@pytest.mark.slow
def test_2():
    ...

@pytest.mark.skip
def test_3():
    ...

@pytest.mark.后面的函数名称指定了具体的标记。slow是一个内置标记,可用于标记慢速测试。同时skip是一个特殊的标记:pytest将自动跳过这个测试用例。

要运行用我们的自定义标记装饰的测试,我们可以使用-m关键字:

pytest -m slow

要运行所有非慢速的测试,我们可以再次在表达式中使用布尔运算符:

pytest -m "not slow"

要使用自定义标记,需要在pytest.ini中定义它们:

[pytest]
markers =
    slow: marks tests as slow

pytest --markers可以列出所有标记装饰器。在我们的例子中,slow标记连同其描述会一起在内置标记中列出。

仅运行更改的测试

在更改现有测试或开发具有新测试覆盖率的新代码时,我们可能希望忽略所有其他测试。这可以通过一个有趣的插件pytest-picked来实现。

pytest --picked将收集所有新创建或已更改但尚未在git仓库中提交的测试模块,并执行它们。

自定义pytest输出

可以通过使用-v或-vv参数让pytest以详细模式在输出中显示更多信息。例如,-vv将显示对所有失败的断言比较而不是任由其被截断。

有时,输出会被各种警告所干扰。使用旧版本库输出是很有可能会发生这种情况。我们可以用--disable-pytest-warnings来隐藏它们。

测试通过时,pytest捕获标准输出并将之隐藏。如果我们使用例如print()函数来查看测试中的某些变量状态,这时我们又希望可以看到标准输出。这就可以通过-s禁用此功能来实现此操作。

总结报告可以按照文档中的描述进行自定义,更多详细信息,请参见Detailed summary report。

还有一些有用的插件可以进一步增强输出结果。pytest sugar是一个一旦安装就会自动更改pytest标准输出格式的插件,其可以显示运行测试套件时的进度。

要更好的查看字典,数组等对象的差异,我们可以使用pytest-claritypytest-icdiff插件。

自动将选项传递给pytest

通过在pytest.ini中使用addopts配置选项,可以自动传递-v之类的选项和标志. 例如,如果我们不想在输出中看到警告且不想每次都在命令行上指定,我们可以这样编写ini文件:

[pytest]

addopts = --disable-pytest-warnings

快速失败

我们可以使用-x在第一次失败时停止测试套件的执行,或者使用--maxfail=n在n次失败后停止测试流程的执行。这样可以在运行大型测试流程时节省相当多时间。

另一个选择是使用pytest-instafail插件。它将立即输出运行失败的相关信息,而无需等待整套测试流程运行完成。

在错误上使用调试器

Pytest允许我们在测试失败时使用--pdb进入标准的交互式调试器pdb。我们还可以调用Python的标准breakpoint()函数在任何地方放入调试器。

在另一篇“调试Python程序”中,我有一个小的pdb调试示例。

参数化测试

Pytest的装饰器标记@pytest.mark.parametize可用于将不同的数据输入到一个测试函数中,并将其转换为多个测试。第一个参数是一个列出所有参与测试用例参数的字符串,第二个参数是一个元组,其中包含匹配的数据。

import pytest
from python_pytest.basics import add

@pytest.mark.parametrize("num,num2,result", [
    (102030),
    (252550),
    (111930)
])
def test_add_can_add_numbers(num, num2, result):
    assert add(num, num2) == result

测试异常

测试系统抛出异常也很简单。Pytest为此提供了一个上下文管理器pytest.raises。在此示例中,我将对测试函数进行参数化,设置不同的异常,然后由raise_exc函数引发并由上下文管理器捕获:

import pytest
from python_pytest import exceptions

@pytest.mark.parametrize("exc", [(ValueError), (ImportError), (IndexError)])
def test_raise_exc(exc):
    with pytest.raises(exc):
        exceptions.raise_exc(exc)

测试日志记录

我们还可以测试特定代码是否将消息写入日志。这时需要引入一个函数,该函数将通过logger.info()记录成功的迭代并用logger.exception()来记录错误异常。

我们如何测试呢?

我们可以利用一款pytest名为caplog的fixture。稍后我们将讨论fixture,但fixture本质上是一个参数,它会自动通过名称传递给我们的测试函数。因此,我们需要做的就是将caplog定义为函数的参数,然后使用它检查从logger捕获的消息:

import logging
import pytest
from python_pytest.logging import log_iterations

def test_iterations_logged(caplog):
    # given
    n_iterations = 10
    caplog.set_level(logging.DEBUG)

    # when
    log_iterations(n_iterations)

    # then
    assert len(caplog.records) == n_iterations
    assert caplog.records[0].message == "Iteration i=0"
    assert caplog.records[0].levelname == "INFO"
    assert caplog.records[9].message == "Iteration i=9"
    assert caplog.records[9].levelname == "INFO"

def test_iterations_exception_logged(caplog):
    # given
    n_iterations = -1
    caplog.set_level(logging.DEBUG)

    # when
    with pytest.raises(ValueError):
        log_iterations(n_iterations)

    # then
    assert caplog.records[0].message == "Iterations couldnt be logged"
    assert caplog.records[0].exc_info[0is ValueError

使用Faker和Mimesis生成测试数据

我们可以利用Python库的Faker和mimesis来生成常见的测试数据类型,例如名字,职位,地址等。

例如,如果我们编写一个函数来测试颜色代码是否有效,我们可以让Faker在每次测试运行时生成一个新的颜色代码,以便我们可以随时间测试各种颜色代码:

def is_color(code: str) -> bool:
    if not code:
        return False
    if not code.startswith('#'):
        return False
    if not len(code) == 7:
        return False
    return True

...

from faker import Faker

fake = Faker()

def test_is_color_accepts_valid_color_codes():
    # given
    color_code = fake.color()

    # when
    result = is_color(color_code)

    # then
    assert result == True

基于属性的测试

有一种生成测试数据的有趣方法就是使用基于属性的测试。它允许我们指定更常规的数据属性,例如,先限定所有正整数或者所有符合某个正则的字符串,然后让测试类自己生成符合要求的样例数据。在Python中,我们可以使用 Hypothesis 这个库。

举个例子,让我们看一个可以让人变老的类Person:

class Person:
    def __init__(self, age: int):
        self.age = age

    def grow_older(self):
        if self.age > 100:
            raise ValueError()
        self.age += 1

当然,我们现在可以使用100作为grow_older() 方法允许行为的边界值来进行测试 。但通过 Hypothesis 这个库,我们可以一次性对所有合法的整数进行测试,包括一些比较小的数字:

import pytest
from hypothesis import given, assume
from hypothesis.strategies import integers, text, datetimes, from_regex, builds, composite
from python_pytest.properties import is_first_name, is_before_datetime, Person

@given(integers(1, 100))
def test_person_can_grow_older(age):
    # given
    person = Person(age=age)

    # when
    person.grow_older()

    # then
    person.age == age + 1

假设使用术语“strategy”来表示数据生成器。在我们的例子中,integers()返回一个生成整数的strategy。从strategy中选择一个整数并将其作为age参数进行传递。我们不仅可以生成像int这样的标准类型,还可以使用builds来构建整个Person对象并传递参数:

@given(builds(Person, age=integers(1, 100)))
def test_person_can_grow_older_2(person):
    current_age = person.age
    person.grow_older()
    person.age == current_age + 1

让我们看另一个生成更复杂数据类型的示例。为此,我们将定义一个函数来验证first names:

def is_first_name(name: str) -> bool:
    if name is None or len(name) < 3:
        return False
    if not name.isalpha():
        return False
    if not name[0].isupper():
        return False
    if not name[1:].islower():
        return False
    return True

现在,我们需要一种使用Hypothesis库生成有效名字的方法。我们将使用@composite装饰器,它可以定义更复杂的对象的各个部分并将规则组合在一起:

@composite
def first_names(draw):
    allowed_first_letters = ['A''B''C''D''E''F''G''H''I''J''K']
    lower_letters_strategy = from_regex(regex=r"^[a-z]$", fullmatch=True)
    first_letter = draw(text(alphabet=allowed_first_letters, min_size=1, max_size=1))
    rest = draw(text(alphabet=lower_letters_strategy, min_size=2, max_size=10))
    return first_letter + rest

我们在这里使用两种不同的strategy来组成我们自己的名字strategy。第一个strategy从允许的字符列表中提取用作名字开头大写字母的字母。第二个strategy使用正则表达式,用剩下的小写字符来完成名字字符串。然后,我们从这两种strategy中抽取样本,并将最终的名字拼接起来。

接下来我们就可以随意使用这个strategy了:

@given(first_names())
def test_is_first_name(first_name):
    assert is_first_name(first_name)

这只是一个如何将多个strategy组合在一起的示例,这里也可以仅使用一个strategy进行实现

from_regex(r"^[A-K][a-z]{1,10}$", fullmatch=True

fixture

我们已经看到了用于测试日志记录的内置fixture caplog。Pytest还有一些其他的内置fixture,可以使用pytest -q --fixtures一起列出。

fixture最大的好处在于我们能够自定义。fixture可以为我们的测试提供一些可重复使用的测试数据,可以包含前后逻辑,或者可以返回有用的对象,例如,已配置的HTTP客户端或数据库会话。

我们只需要用@pytest.fixture装饰器在conftest.py文件中定义一个函数。让我们看一个简单的fixture示例,它提供了一个人允许名称的可复用数据集:

@pytest.fixture
def allowed_names():
    return ["Peter""Mark""Mary"]

要使用它,只需要为测试函数命名一个与fixture名称相同的参数即可:

def test_allowed_names(allowed_names):
    # when
    name = get_name()

    # then
    assert name in allowed_names

Pytest fixture也可以参数化(接受参数)。请参阅文档中的fixture参数化。

我们可以使用 pytest-deadfixtures fixture插件的pytest --dead-fixtures命令列出所有未使用的fixture。这可以帮助我们避免保留不必要的代码。

Mock

如果我们需要mock外部依赖关系或测试被测系统的行为,最简单的方法就是使用Python标准库中的unittest.mock模块。

为了演示,我们介绍一段简单的代码,该段代码应能够检查Google的结果页上是否存在特定的链接:

import requests

def download_google_result_page(query: str) -> str:
    results_page = requests.get(f"https://www.google.com/search?q={query}")
    return results_page.text

def is_link_on_result_page(link: str, query: str) -> bool:
    return True if link in download_google_result_page(query) else False

问题在于我们的链接可能并不总是在Google的结果页面上,从而导致测试运行不一致。我们只关心iis_link_on_result_page函数是否可以识别存在的链接(如果存在)。

unittest.mock模块提供了很多功能,但是我发现patch上下文管理器是最有用的,可以让我们简便的替换依赖项。在这个例子中,我们需要替换download_google_result_page使其返回包含我们的链接的假结果页面:

from unittest.mock import patch
from python_pytest.mocking import is_link_on_result_page

def test_check_link_in_result_can_recognize_if_link_present():
    # given
    query = "python"
    link = 'https://stribny.name'
    result_page = f'[]({link})'

    # then
    with patch('python_pytest.mocking.download_google_result_page'lambda query: result_page):
        assert is_link_on_result_page(link, query) == True

unittest.mock上的文档非常全面。

我们也会发现pytest-mock插件很有用,尽管没什么必要。

测试数据库交互

就如何与数据库系统进行交互测试,有很多不同的策略。其中最重要的部分是选择如何处理测试数据的初始化和清理。有些人可能会利用事务和回滚,有些人会重新创建和删除表,或者创建和删除单独的行(或者在使用非关系数据库时使用其他方法)。

我将演示一个简单的初始化/清理过程,该过程可以通过定义自定义fixture来实现。该fixture将在运行测试之前初始化数据表,在测试完成后清除所有记录,并释放用于测试的与数据库进行通信的数据库会话对象。但是首先让我们看一段简单的代码,它将作为我们的测试系统:

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session
from sqlalchemy import (
    Column,
    Integer,
    String,
)

SQLALCHEMY_DATABASE_URL = "sqlite:///./instance/test.db"
engine = create_engine(
    SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread"False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, autoincrement=True, primary_key=True, index=True)
    email = Column(String, unique=True, nullable=False)

def get_session() -> Session:
    return SessionLocal()

def save_user(session: Session, user: User) -> None:
    session.add(user)
    session.commit()

我们将测试 save_user() 函数,并使用get_session() 函数获得一个到本地SQLite数据库的数据库连接。

现在让我们定义fixture:

我们定义了一个fixture名为session,它可以创建一个连接,确保创建数据表并在此之后生成给测试用例。使用yield可以让我们在暴露连接对象的同时,在测试之前和之后运行一些代码。运行测试后,fixture将删除所有User行(删除行通常比删除和重新创建表快)。如果我们想并行运行此类集成测试,则可能需要使用其他方法,但这超出了本文的范围。

完成所有这些操作后,唯一缺少的就是测试本身:

from python_pytest.db_interaction import User, save_user

def test_user_can_be_saved(session):
    # given
    user_id = 1
    user_email = "example@example.com"
    user = User(id=user_id, email=user_email)

    # when
    save_user(session, user)

    # then
    saved_user = session.query(User).filter(User.id == user_id).first()
    assert saved_user.email == user_email

会话对象将根据参数的名称自动注入,我们可以放心地使用它来测试任何数据库交互。

就这样!如果您使用的是关系数据库,那么您可能还会喜欢我关于Scaling relational SQL databases.。的文章

测试网络应用

Pytest提供了插件,可以更容易地测试使用流行的python web框架编写的应用程序,比如用于Flask 的 Pytest Flask或用于Django 的 Pytest Django。在使用FastAPI时,只需使用FastAPI测试文档中描述的官方TestClient对象就足够了。

如果有兴趣使用Selenium测试Web UI,可以使用SeleniumBase。

根据规范测试Web API

测试webapi的一个好方法是利用OpenAPI形式的API规范,并使用Schemathesis直接生成属性样式的测试。Schemathesis将为我们的端点生成hypothesis strategies,并执行操作。请直接从Schemathesis progress report.的作者那里了解更多信息。

随机测试

在理想的测试不互相依赖的条件下,我们可以使用pytest-randomly进行随机测试。

并行运行测试

我们可以使用pytest-parallel和pytest-xdist插件并行运行测试。pytest-parallel的文档解释了两者的区别:

pytest-xdist非常适合运行以下测试:

  • 非线程安全的
  • 多线程时性能不佳的
  • 需要状态隔离的

pytest-parallel在以下一些用例(例如Selenium测试)中更适用:

  • 可以是线程安全的
  • 可以对http请求使用非阻塞IO以使其性能更好的
  • 在Python环境中没有或只有少量状态管理

简而言之,pytest-xdist进行并行,而pytest-parallel进行并行和并发。

在并行测试时,我们需要考虑一下是否可以并行测试以及如何进行测试。例如,在我们的数据库交互测试示例中,我们需要确保各个测试都拥有自己的数据库实例或提供某种其他类型的隔离。另外一个好主意是使用pytest标记装饰器来标记可以并行运行和不能并行运行的测试。

使用pytest-parallel,我们可以选择要运行多少个worker或每个worker可以运行多少个测试。想要2个worker就用 pytest --workers 2 ,这2个worker将自动在它们之间分配测试。

测量测试执行时间

使用pytest很容易找到慢速测试。如果我们将-vv与--durations=0选项结合使用,它将显示所有测试及其持续时间。输出将如下所示:

===================================== slowest test durations =====================================
0.53s call     tests/test_properties.py::test_person_can_grow_older_2
0.33s call     tests/test_properties.py::test_person_can_grow_older
0.27s call     tests/test_properties.py::test_is_first_name
0.24s call     tests/test_properties.py::test_is_before_datetime
0.13s call     tests/test_properties.py::test_person_cannot_grow_older
...

想要查看n个最慢的测试,请使用--durations=n,例如显示3个最慢的测试:--durations=3。

测量代码覆盖率

查看测试代码覆盖范围的最简单方法是安装pytest-cov插件。我们可以使用以下命令直接在命令行中获取摘要:

pytest --cov=<module> <testsfolder>:
pytest --cov=python_pytest tests/
Name                              Stmts   Miss  Cover
-----------------------------------------------------
python_pytest/__init__.py             1      0   100%
python_pytest/basics.py               2      0   100%
python_pytest/db_interaction.py      21      6    71%
python_pytest/exceptions.py           2      0   100%
python_pytest/fixtures.py             2      0   100%
python_pytest/gen_data.py             9      3    67%
python_pytest/logging.py             12      0   100%
python_pytest/mocking.py              6      2    67%
python_pytest/properties.py          20      4    80%
-----------------------------------------------------
TOTAL                                75     15    80%

我们可以使用带--cov-report html参数生成HTML报告来深入挖掘为什么某些模块覆盖率低:

pytest --cov-report html --cov=python_pytest tests/

结果将保存在htmlcov文件夹中。

正如我们在屏幕截图中看到的那样,我们只测试了一个函数,另一个函数是用来做 mock 的。因此,报告的覆盖率低于 100%。

最后的话

还有许多其他方式可以自定义和使用 pytest,但是这些方法相当高级,通常不太需要。例如,我们可以使用pytest-asyncio处理异步Python代码或使用pytest-benchmark进行代码基准测试。


- EOF -

推荐阅读  点击标题可跳转

1、OpenCV 如何去除图片中的阴影

2、1 小时逼疯面试者:聊聊 Python Import System?

3、利用Python做一个小姐姐词云跳舞视频


觉得本文对你有帮助?请分享给更多人

推荐关注「Python开发者」,提升Python技能

点赞和在看就是最大的支持❤️

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存