Skip to content

Latest commit

 

History

History
851 lines (604 loc) · 36.1 KB

File metadata and controls

851 lines (604 loc) · 36.1 KB

三、夹具

在上一章中,我们学习了如何有效地使用标记和参数化来跳过测试,将它们标记为预期失败,并将它们参数化,以避免重复。

现实世界中的测试通常需要创建要处理的资源或数据:用于输出某些文件的临时目录、用于测试应用程序 I/O 层的数据库连接、用于集成测试的 web 服务器。这些都是在更复杂的测试场景中需要的资源示例。更复杂的资源通常需要在测试会话结束时清理:删除临时目录,清理数据库并断开与数据库的连接,关闭 web 服务器。此外,这些资源应该在测试中轻松共享,因为在测试期间,我们经常需要为不同的测试场景重用资源。有些资源创建成本很高,但由于它们是不可变的或可以恢复到原始状态,因此只应创建一次,并与所有需要它的测试共享,只有在需要它们的最后一次测试完成时才被销毁

pytest 最重要的功能之一涵盖了前面的所有要求和更多要求:夹具。

以下是我们将在本章中介绍的内容:

  • 介绍固定装置
  • 与conftest.py文件共享设备
  • 范围
  • 自动使用
  • 参数化
  • 使用夹具上的标记
  • 内置固定装置概述
  • 提示/讨论

介绍固定装置

大多数测试需要某种数据或资源来操作:

def test_highest_rated():
    series = [
        ("The Office", 2005, 8.8),
        ("Scrubs", 2001, 8.4),
        ("IT Crowd", 2006, 8.5),
        ("Parks and Recreation", 2009, 8.6),
        ("Seinfeld", 1989, 8.9),
    ]
    assert highest_rated(series) == "Seinfeld"

在这里,我们有一个(series name、year、rating元组列表,用于测试highest_rated函数。像我们在这里所做的那样,将数据内联到测试代码中对于独立的测试非常有效,但通常您有一个可由多个测试使用的数据集。一种解决方案是将数据集复制到每个测试:

def test_highest_rated():
    series = [
        ("The Office", 2005, 8.8),
        ...,
    ]
    assert highest_rated(series) == "Seinfeld"

def test_oldest():
    series = [
        ("The Office", 2005, 8.8),
        ...,
    ]
    assert oldest(series) == "Seinfeld"

但这很快就会过时,而且从长远来看,复制和粘贴东西会影响可维护性,例如,如果数据布局发生变化(例如,向元组或转换大小添加新项)。

输入装置

Pytest 对此问题的解决方案是固定装置。fixture 用于提供测试我们需要执行的函数和方法的资源。

它们是使用普通 Python 函数和@pytest.fixture装饰器创建的:

@pytest.fixture
def comedy_series():
    return [
        ("The Office", 2005, 8.8),
        ("Scrubs", 2001, 8.4),
        ("IT Crowd", 2006, 8.5),
        ("Parks and Recreation", 2009, 8.6),
        ("Seinfeld", 1989, 8.9),
    ]

在这里,我们正在创建一个名为comedy_series的 fixture,它返回与我们在上一节中使用的相同的列表。

测试可以通过在其参数列表中声明设备名称来访问设备。然后,测试函数接收 fixture 函数的返回值作为参数。以下是comedy_series夹具的作用:

def test_highest_rated(comedy_series):
    assert highest_rated(comedy_series) == "Seinfeld"

def test_oldest(comedy_series):
    assert oldest(comedy_series) == "Seinfeld"

以下是工作原理:

  • Pytest 在调用之前查看测试函数参数。这里,我们有一个参数:comedy_series。
  • 对于每个参数,pytest 获取相同名称的 fixture 函数并执行它。
  • 每个 fixture 函数的返回值将成为一个命名参数,并调用 test 函数。

请注意,test_highest_rated和test_oldest都有自己的喜剧系列列表副本,因此,如果他们在测试中更改列表,就不会有相互干扰的风险。

也可以使用以下方法在类中创建装置:

class Test:

    @pytest.fixture
    def drama_series(self):
        return [
            ("The Mentalist", 2008, 8.1),
            ("Game of Thrones", 2011, 9.5),
            ("The Newsroom", 2012, 8.6),
            ("Cosmos", 1980, 9.3),
        ]

测试类中定义的夹具只能通过类或子类的测试方法访问:

class Test:
    ...

    def test_highest_rated(self, drama_series):
        assert highest_rated(drama_series) == "Game of Thrones"

    def test_oldest(self, drama_series):
        assert oldest(drama_series) == "Cosmos"

请注意,测试类可能有其他非测试方法,就像任何其他类一样。

安装/拆卸

正如我们在介绍中所看到的,测试中使用的资源在测试完成后需要进行某种清理是非常常见的。

在我们前面的示例中,我们有一个非常小的数据集,所以将其内联到 fixture 中就可以了。但是,假设我们有一个更大的数据集(比如 1000 个条目),那么在代码中编写它会损害可读性。通常,数据集位于外部文件中,例如 CSV 格式,因此将其移植到 Python 代码中是一件痛苦的事情。

解决方案是将包含 series 数据集的 CSV 文件提交到存储库中,并使用内置的csv模块在测试中读取它;有关更多详细信息,请访问https://docs.python.org/3/library/csv.html 。

我们可以更改comedy_series夹具来实现这一点:

@pytest.fixture
def comedy_series():
    file = open("series.csv", "r", newline="")
    return list(csv.reader(file))

这是可行的,但作为勤奋的开发人员,我们希望能够正确地关闭该文件。我们怎样才能用固定装置做到这一点?

夹具清理通常被称为拆卸,使用yield语句很容易支持:

@pytest.fixture
def some_fixture():
    value = setup_value()
    yield value
    teardown_value(value)

通过使用yield而不是return,情况如下:

  • 调用 fixture 函数
  • 它一直执行到 yield 语句,在该语句中暂停并生成 fixture 值
  • 测试将执行,并接收 fixture 值作为参数
  • 无论测试通过还是失败,函数都将恢复,以便可以执行其拆卸操作

对于熟悉它的人来说,这非常类似于上下文管理器(https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager ),但您不需要在 yield 语句周围加上 try/except 子句,以确保 yield 后的块被执行,即使发生异常。

让我们回到我们的例子;我们现在可以使用yield代替return并关闭文件:

@pytest.fixture
def comedy_series():
    file = open("series.csv", "r", newline="")
    yield list(csv.reader(file))
    file.close()

这很好,但请注意,因为yield与 file 对象的with语句配合得很好,所以我们可以编写以下代码:

@pytest.fixture
def comedy_series():
    with open("series.csv", "r", newline="") as file:
        return list(csv.reader(file))

测试完成后,with语句将自动关闭该文件,该语句较短,被认为更具 python 风格

令人惊叹的

可组合性

假设我们收到一个新的 series.csv 文件,该文件现在包含大量的电视剧,包括我们之前的喜剧系列以及许多其他类型的电视剧。我们希望将此新数据用于其他一些测试,但我们希望保持现有测试像以前一样工作。

pytest 中的 fixture 可以很容易地依赖于其他 fixture,只需将它们声明为参数即可。使用此属性,我们可以创建一个新的系列装置,用于读取series.csv(现在包含更多类型)中的所有数据,并将comedy_series装置更改为仅过滤喜剧系列:

@pytest.fixture
def series():
    with open("series.csv", "r", newline="") as file:
        return list(csv.reader(file))

@pytest.fixture
def comedy_series(series):
    return [x for x in series if x[GENRE] == "comedy"]

使用comedy_series的测试没有变化:

def test_highest_rated(comedy_series):
    assert highest_rated(comedy_series) == "Seinfeld"

def test_oldest(comedy_series):
    assert oldest(comedy_series) == "Seinfeld"

请注意,由于这些特性,fixture 是依赖项注入的一个主要示例,依赖项注入是一种技术,其中函数或对象声明其依赖项,但不知道或不关心这些依赖项将如何创建,或由谁创建。这使得它们非常模块化和可重用。

与 conftest.py 文件共享装置

假设我们需要在其他测试模块中使用上一节中的comedy_series夹具。在 pytest 中,只需将 fixture 代码移动到一个conftest.py文件,就可以轻松地共享 fixture。

conftest.py文件是一个普通的 Python 模块,只是 pytest 会自动加载它,其中定义的任何 fixture 都可以自动用于同一目录下的测试模块。考虑这个测试模块的层次结构:

tests/
    ratings/
        series.csv
        test_ranking.py
    io/
        conftest.py
        test_formats.py 
    conftest.py

tests/conftest.py文件位于层次结构的根目录下,因此在此文件上定义的任何夹具都将自动可用于此项目中的所有其他测试模块。tests/io/conftest.py中的固定装置将仅适用于tests/io及其以下的模块,因此目前仅适用于test_formats.py。

这看起来可能没什么大不了的,但它让共享固定装置变得轻而易举:在编写测试模块时,能够从少量固定装置开始,这是一件非常解放的事情,因为我们知道,如果这些固定装置对将来的其他测试有用,那么将固定装置移动到conftest.py以重用它们只是一件小事。这避免了复制和粘贴测试数据的诱惑,或者从一开始就花太多时间考虑如何组织支持测试的代码,以避免以后不得不进行大量重构。

范围

fixture 总是在测试函数请求它们时创建的,方法是在参数列表中声明它们,正如我们已经看到的那样。默认情况下,每个测试完成时,每个夹具都会被销毁。

如本章开头所述,创建或设置某些固定装置的成本可能会很高,如果能够创建尽可能少的固定装置实例,以节省时间,这将很有帮助。以下是一些例子:

  • 初始化数据库表
  • 从磁盘读取缓存数据,例如,大型 CSV 数据
  • 启动外部服务

为了帮助解决这个问题,pytest 中的 fixture 可以有不同的范围。夹具的范围定义了清洁夹具的时间。未清理夹具时,请求夹具的测试将收到相同的夹具值。

@pytest.fixture decorator 的 scope 参数用于设置设备的范围:

@pytest.fixture(scope="session")
def db_connection():
    ...

以下范围可用:

  • scope="session":当所有测试完成时,夹具被拆卸。
  • scope="module":当模块的最后一次测试功能完成时,夹具被拆卸。
  • scope="class":当一个类的最后一个测试方法完成时,夹具被拆卸。
  • scope="function":当请求夹具的测试功能完成时,夹具被拆卸。这是默认设置。

需要强调的是,无论范围如何,每个夹具都将仅在测试功能需要时创建。例如,会话范围的固定装置不一定在会话开始时创建,而是仅在请求它的第一个测试即将被调用时创建。当您认为并非所有的测试都需要一个会话范围内的固定程序时,这是有意义的,并且有多种形式只运行一个子集测试,正如我们在前面章节中所看到的。

作用域

为了显示作用域中的范围,让我们来看看当测试涉及某种形式的数据库时使用的一个通用模式。在接下来的示例中,不要关注数据库 API(无论如何都是由它组成的),而要关注所涉及的装置的概念和设计。

通常,与数据库的连接和表的创建都很慢。如果数据库支持事务,即能够执行一组可以原子化应用或放弃的更改,那么可以使用以下模式。

对于初学者,我们可以使用一个会话范围的装置来连接和初始化数据库以及我们需要的表:

@pytest.fixture(scope="session")
def db():
    db = connect_to_db("localhost", "test") 
    db.create_table(Series)
    db.create_table(Actors)
    yield db
    db.prune()
    db.disconnect()

注意,我们修剪测试数据库并在 fixture 结束时断开与它的连接,这将在会话结束时发生

通过db夹具,我们可以在所有测试中共享相同的数据库。这很好,因为它节省了时间。但它也有一个缺点,即现在的测试可能会更改数据库并影响其他测试。为了解决这个问题,我们创建了一个事务固定装置,它在测试开始之前启动一个新事务,并在测试结束时回滚该事务,确保数据库返回到其以前的状态:

@pytest.fixture(scope="function")
def transaction(db):
    transaction = db.start_transaction()
    yield transaction
    transaction.rollback()

请注意,我们的交易固定装置取决于db。现在,测试可以使用事务装置随意读取和写入数据库,而不用担心为其他测试清理数据库:

def test_insert(transaction):
    transaction.add(Series("The Office", 2005, 8.8))
    assert transaction.find(name="The Office") is not None

有了这两个工具,我们就有了一个非常坚实的基础来编写我们的数据库测试:需要事务处理的第一个测试将通过 AutoT0E.夹具自动初始化数据库,并且从现在开始执行需要进行事务处理的每个测试都将从原始数据库中进行。

不同范围的夹具之间的这种可组合性非常强大,能够在真实世界的测试套件中实现各种巧妙的设计。

自动使用

通过将autouse=True传递给@pytest.fixture装饰器,可以将夹具应用于层次结构中的所有测试,即使测试没有明确请求夹具。当我们需要在每次测试之前和/或之后无条件地应用副作用时,这非常有用。

@pytest.fixture(autouse=True)
def setup_dev_environment():
    previous = os.environ.get('APP_ENV', '')
    os.environ['APP_ENV'] = 'TESTING'
    yield
    os.environ['APP_ENV'] = previous

自动使用夹具应用于夹具可用于以下各项的所有测试:

  • 与夹具相同的模块
  • 与 fixture 相同的类,如果 fixture 是由方法定义的
  • 如果夹具在conftest.py文件中定义,则在同一目录或以下目录中进行测试

换句话说,如果测试可以通过在参数列表中声明来访问 autouse fixture,则该测试将自动使用 autouse fixture。请注意,如果测试函数对夹具的返回值感兴趣,则可以将 autouse 夹具添加到其参数列表中,这是正常的。

@pytest.mark.usefixtures

@pytest.mark.usefixtures标记可用于将一个或多个夹具应用于测试,就像它们在参数列表中声明了夹具名称一样。在您希望组中的所有测试始终使用非autouse的夹具的情况下,这是一种替代方法。

例如,下面的代码将确保TestVirtualEnv类中的所有测试方法在全新的虚拟环境中执行:

@pytest.fixture
def venv_dir():
    import venv

    with tempfile.TemporaryDirectory() as d:
        venv.create(d)
        pwd = os.getcwd()
        os.chdir(d)
        yield d
        os.chdir(pwd)

@pytest.mark.usefixtures('venv_dir')
class TestVirtualEnv:
    ...

如名称所示,可以将多个装置名称传递给装饰器:

@pytest.mark.usefixtures("venv_dir", "config_python_debug")
class Test:
    ...

参数化夹具

夹具也可以直接参数化。将夹具参数化后,使用该夹具的所有测试现在将运行多次,每个参数一次。这是一个很好的工具,当我们有一个固定装置的变体,并且使用该固定装置的每个测试也应该与所有变体一起运行时使用。

在上一章中,我们看到了使用序列化程序的多个实现进行参数化的示例:

@pytest.mark.parametrize(
    "serializer_class",
    [JSONSerializer, XMLSerializer, YAMLSerializer],
)
class Test:

    def test_quantity(self, serializer_class):
        serializer = serializer_class()
        quantity = Quantity(10, "m")
        data = serializer.serialize_quantity(quantity)
        new_quantity = serializer.deserialize_quantity(data)
        assert new_quantity == quantity

    def test_pipe(self, serializer_class):
        serializer = serializer_class()
        pipe = Pipe(
            length=Quantity(1000, "m"), diameter=Quantity(35, "cm")
        )
       data = serializer.serialize_pipe(pipe)
       new_pipe = serializer.deserialize_pipe(data)
       assert new_pipe == pipe

我们可以将示例更新为夹具上的参数化:

class Test:

 @pytest.fixture(params=[JSONSerializer, XMLSerializer,
 YAMLSerializer])
 def serializer(self, request):
 return request.param()

    def test_quantity(self, serializer):
        quantity = Quantity(10, "m")
        data = serializer.serialize_quantity(quantity)
        new_quantity = serializer.deserialize_quantity(data)
        assert new_quantity == quantity

    def test_pipe(self, serializer):
        pipe = Pipe(
            length=Quantity(1000, "m"), diameter=Quantity(35, "cm")
        )
        data = serializer.serialize_pipe(pipe)
        new_pipe = serializer.deserialize_pipe(data)
        assert new_pipe == pipe

注意以下几点:

  • 我们将一个params参数传递给夹具定义。
  • 我们使用特殊request对象的param属性访问夹具内部的参数。当夹具参数化时,此内置夹具提供对请求测试功能和参数的访问。我们将在本章后面看到更多关于request夹具的信息。
  • 在本例中,我们在装置内部实例化序列化程序,而不是在每个测试中显式地实例化序列化程序。

可以看出,对夹具进行参数化与对测试进行参数化非常相似,但有一个关键区别:通过对夹具进行参数化,我们可以使使用该夹具的所有测试针对所有参数化实例运行,从而使它们成为conftest.py文件中共享的夹具的最佳解决方案

当您向现有设备添加新参数时,看到许多新测试自动执行是非常有益的。

使用夹具上的标记

我们可以使用request夹具访问应用于测试功能的标记。

假设我们有一个autouse装置,它总是将当前区域设置初始化为英语:

@pytest.fixture(autouse=True)
def setup_locale():
    locale.setlocale(locale.LC_ALL, "en_US")
    yield
    locale.setlocale(locale.LC_ALL, None)

def test_currency_us():
    assert locale.currency(10.5) == "$10.50"

但是,如果我们只想在几个测试中使用不同的语言环境,该怎么办?

一种方法是使用自定义标记,并从夹具中访问mark对象:

@pytest.fixture(autouse=True)
def setup_locale(request):
    mark = request.node.get_closest_marker("change_locale")
    loc = mark.args[0] if mark is not None else "en_US"
    locale.setlocale(locale.LC_ALL, loc)
    yield
    locale.setlocale(locale.LC_ALL, None)

@pytest.mark.change_locale("pt_BR")
def test_currency_br():
    assert locale.currency(10.5) == "R$ 10,50"

标记可以通过这种方式将信息传递给设备。因为它有点含蓄,所以我建议谨慎使用它,因为它可能导致代码难以理解。

内置固定装置概述

让我们来看看一些内置的 PyTestC 夹具。

tmpdir

tmpdir夹具提供一个空目录,在每次测试结束时自动删除:

def test_empty(tmpdir):
    assert os.path.isdir(tmpdir)
    assert os.listdir(tmpdir) == []

作为一个function范围的固定装置,每个测试都有自己的目录,所以他们不必担心清理或生成唯一的目录。

夹具提供一个py.local对象(http://py.readthedocs.io/en/latest/path.html ),来自py库(http://py.readthedocs.io ),提供了方便的文件路径处理方法,如加入、读取、写入、获取扩展名等;它在哲学上类似于pathlib.Path对象(https://docs.python.org/3/library/pathlib.html 标准库中的:

def test_save_curves(tmpdir):
    data = dict(status_code=200, values=[225, 300])
    fn = tmpdir.join('somefile.json')
    write_json(fn, data)
    assert fn.read() == '{"status_code": 200, "values": [225, 300]}'

Why pytest use py.local instead of pathlib.Path? Pytest had been around for years before pathlib.Path came along and was incorporated into the standard library, and the py library  was one the best solutions for path-like objects at the time. Core pytest developers are looking into how to adapt pytest to the now-standard pathlib.Path API.

tmpdir_ 工厂

tmpdir夹具非常方便,但它仅在function*-*范围内:这有一个缺点,即它只能由其他function范围内的夹具使用。

tmpdir_factoryfixture 是一个会话范围的fixture,允许在任何范围内创建空的和唯一的目录。当我们需要将数据存储到其他作用域(例如,session作用域缓存或数据库文件)的固定装置中的磁盘上时,这非常有用。

为了在操作中显示它,下面显示的images_dir夹具使用tmpdir_factory为整个测试会话创建一个唯一的目录,其中包含一系列示例图像文件:

@pytest.fixture(scope='session')
def images_dir(tmpdir_factory):
    directory = tmpdir_factory.mktemp('images')
    download_images('https://example.com/samples.zip', directory)
    extract_images(directory / 'samples.zip')
    return directory

因为每个会话只执行一次,所以在运行测试时可以节省大量时间。

然后,测试可以使用images_dir夹具测试轻松访问样本图像文件:

def test_blur_filter(images_dir):
    output_image = apply_blur_filter(images_dir / 'rock1.png')
    ...

但是请记住,由该装置创建的目录是共享的,并且只会在测试会话结束时删除。这意味着测试不应该修改目录的内容;否则,它们可能会影响其他测试。

猴头贴

在某些情况下,测试需要复杂或难以在测试环境中设置的功能,例如:

  • 客户端访问外部资源(例如 GitHub 的 API),在测试期间访问该资源可能不切实际或成本过高
  • 强制一段代码在另一个平台上运行,例如错误处理
  • 难以在本地或 CI 中复制的复杂条件或环境

monkeypatchfixture 允许您使用其他对象和函数干净地覆盖正在测试的系统的函数、对象和字典条目,从而撤消测试拆卸期间的所有更改。例如:

import getpass

def user_login(name):
    password = getpass.getpass()
    check_credentials(name, password)
    ...

在此代码中,user_login使用getpass.getpass()功能(https://docs.python.org/3/library/getpass.html 标准库中的),以系统中可用的最安全的方式提示用户输入密码。测试过程中很难模拟实际输入的密码,因为getpass如果可能,尝试直接从终端读取(与从sys.stdin相反)

我们可以使用monkeypatchfixture 在测试中绕过对getpass的调用,透明且不更改应用程序代码:

def test_login_success(monkeypatch):
    monkeypatch.setattr(getpass, "getpass", lambda: "valid-pass")
    assert user_login("test-user")

def test_login_wrong_password(monkeypatch):
    monkeypatch.setattr(getpass, "getpass", lambda: "wrong-pass")
    with pytest.raises(AuthenticationError, match="wrong password"):
        user_login("test-user")

在测试中,我们使用monkeypatch.setattr将getpass模块的真实getpass()功能替换为虚拟lambda,该虚拟lambda返回硬编码密码。在test_login_success中,我们返回一个已知的、良好的密码,以确保用户能够成功进行身份验证;而在test_login_wrong_password中,我们使用一个错误的密码,以确保正确处理身份验证错误。如前所述,原始的getpass()功能在测试结束时自动恢复,确保我们不会将更改泄露给系统中的其他测试。

如何以及在何处进行修补

monkeypatch夹具的工作原理是用另一个对象(通常称为模拟)替换一个对象的属性,在测试结束时恢复原始对象。使用此设备时的一个常见问题是修补错误的对象,这会导致调用原始函数/对象而不是模拟函数/对象。

为了理解这个问题,我们需要了解import和import from在 Python 中是如何工作的。

考虑一个名为“YouTyt0”的模块:

import subprocess

def start_service(service_name):
    subprocess.run(f"docker run {service_name}")

在这段代码中,我们正在导入subprocess模块,并将subprocess模块对象引入services.py名称空间。这就是我们调用subprocess.run的原因:我们正在访问services.py命名空间中subprocess对象的run函数。

现在考虑前面写的代码略有不同:

from subprocess import run

def start_service(service_name):
    run(f"docker run {service_name}")

这里,我们正在导入subprocess模块,但将run函数对象引入service.py名称空间。这就是为什么在start_service中可以直接调用run,而subprocess名称甚至不可用(如果您尝试调用subprocess.run,您将得到一个NameError异常)。

我们需要意识到这种差异,正确地monkeypatch使用services.py中的subprocess.run。

在第一种情况下,我们需要替换subprocess模块的run功能,因为start_service就是这样使用它的:

import subprocess
import services

def test_start_service(monkeypatch):
    commands = []
    monkeypatch.setattr(subprocess, "run", commands.append)
    services.start_service("web")
    assert commands == ["docker run web"]

在此代码中,services.py和test_services.py都引用了相同的subprocess模块对象。

然而,在第二种情况下,services.py在其自己的名称空间中引用了原始run函数。因此,第二种情况的正确方法是替换services.py名称空间中的run函数:

import services

def test_start_service(monkeypatch):
    commands = []
    monkeypatch.setattr(services, "run", commands.append)
    services.start_service("web")
    assert commands == ["docker run web"]

被测试的代码如何导入需要修补的代码是人们经常被这种情况绊倒的原因,因此请确保首先查看代码。

capsys/capfd

capsys夹具捕获写入sys.stdout和sys.stderr的所有文本,并使其在测试期间可用。

假设我们有一个小的命令行脚本,并希望在无参数调用脚本时检查使用说明是否正确:

from textwrap import dedent

def script_main(args):
    if not args:
        show_usage()
        return 0
    ...

def show_usage():
    print("Create/update webhooks.")
    print(" Usage: hooks REPO URL")

在测试期间,我们可以使用capsys夹具访问捕获的输出。此夹具有一个返回一个namedtuple(的capsys.readouterr()方法 https://docs.python.org/3/library/collections.html#collections.namedtuple 具有out和err属性的,分别包含sys.stdout和sys.stderr捕获的文本:

def test_usage(capsys):
    script_main([])
    captured = capsys.readouterr()
    assert captured.out == dedent("""\
        Create/update webhooks.
          Usage: hooks REPO URL
    """)

还有一个capfdfixture,其工作原理与capsys类似,只是它还捕获了文件描述符1和2的输出。这使得捕获标准输出和标准错误成为可能,即使对于扩展模块也是如此。

二进制模式

capsysbinary和capfdbinary与capsys和capfd相同,只是它们以二进制模式捕获输出,并且它们的readouterr()方法返回原始字节而不是文本。它在特殊情况下可能很有用,例如,当运行产生二进制输出的外部进程时,例如tar。

要求

request夹具是一个内部 pytest 夹具,提供有关请求测试的有用信息。它可以在测试函数和装置中声明,并提供如下属性:

  • function:Pythontest函数对象,可用于function范围的装置。

  • cls/instance:一个test方法对象的 Python 类/实例,可用于函数范围和class范围的装置。如果夹具是从test功能而不是测试方法请求的,则可以是None。

  • module:请求测试方法的 Python 模块对象,可用于module-、function-和class范围的夹具。

  • session:pytest 的内部Session对象,它是测试会话的单例,表示集合树的根。它适用于所有范围的固定装置。

  • node:pytest 集合节点,它封装了与 fixture 作用域匹配的一个 Python 对象

  • addfinalizer(func):添加一个new finalizer函数,该函数将在测试结束时调用。finalizer 函数是在没有参数的情况下调用的。addfinalizer是最初在 fixture 中执行 teardown 的方法,但此后被yield语句取代,主要用于向后兼容。

fixture 可以使用这些属性根据正在执行的测试定制它们自己的行为。例如,我们可以创建一个 fixture,该 fixture 使用当前测试名称作为临时目录的前缀,提供一个临时目录,有点类似于内置的tmpdirfixture:

@pytest.fixture
def tmp_path(request) -> Path:
    with TemporaryDirectory(prefix=request.node.name) as d:
        yield Path(d)

def test_tmp_path(tmp_path):
    assert list(tmp_path.iterdir()) == []

此代码在我的系统上执行时创建了以下目录:

C:\Users\Bruno\AppData\Local\Temp\test_tmp_patht5w0cvd0

requestfixture 可以在您希望根据正在执行的测试的属性定制 fixture 时使用,或者访问应用于测试功能的标记时使用,正如我们在前面的部分中所看到的。

提示/讨论

以下是一些简短的主题和技巧,不适合前面的章节,但我认为值得一提

何时使用夹具,而不是简单的功能

有时,您所需要的只是为测试构造一个简单的对象,可以说这可以在一个普通函数中完成,而不一定需要作为一个 fixture 来实现。假设我们有一个WindowManager类,它不接收任何参数:

class WindowManager:
    ...

在我们的测试中使用它的一种方法是编写一个夹具:

@pytest.fixture
def manager():
 return WindowManager()

def test_windows_creation(manager):
    window = manager.new_help_window("pipes_help.rst")
    assert window.title() == "Pipe Setup Help"

或者,您可以争辩说,对于这样简单的使用,fixture 是一种过分的方法,而使用普通函数:

def create_window_manager():
    return WindowManager()

def test_windows_creation():
    manager = create_window_manager()
    window = manager.new_help_window("pipes_help.rst")
    assert window.title() == "Pipe Setup Help"

或者,您甚至可以在每个测试中显式创建管理器:

def test_windows_creation():
    manager = WindowManager()
    window = manager.new_help_window("pipes_help.rst")
    assert window.title() == "Pipe Setup Help"

这非常好,尤其是在单个模块中的一些测试中使用。

但是,请记住,固定装置抽象了对象的构造和拆卸过程的细节。当决定放弃固定装置以支持正常功能时,记住这一点至关重要。

假设我们的WindowManager现在需要显式关闭,或者需要一个本地目录用于日志记录:

class WindowManager:

    def __init__(self, logging_directory):
        ...

    def close(self):
        """
        Close the WindowManager and all associated resources. 
        """
        ...

如果我们一直在使用第一个示例中给出的夹具,我们只需更新夹具功能,根本不需要更改任何测试:

@pytest.fixture
def manager(tmpdir):
    wm = WindowManager(str(tmpdir))
    yield wm
 wm.close()

但是如果我们选择使用普通函数,现在我们必须更新所有调用我们函数的地方:我们需要通过一个日志目录,并保证在测试结束时调用.close():

def create_window_manager(tmpdir, request):
    wm = WindowManager(str(tmpdir))
    request.addfinalizer(wm.close)
    return wm

def test_windows_creation(tmpdir, request):
    manager = create_window_manager(tmpdir, request)
    window = manager.new_help_window("pipes_help.rst")
    assert window.title() == "Pipe Setup Help"

根据这个函数在我们的测试中使用的次数,这可能是一个相当复杂的重构过程。

信息是:当底层对象很简单且不太可能更改时,可以使用普通函数,但请记住,fixture 抽象了对象创建/销毁的细节,将来可能需要更改。另一方面,使用 fixture 会创建另一个间接级别,这稍微增加了代码的复杂性。最后,这是一个你应该权衡的平衡。

重命名装置

@pytest.fixture装饰器接受一个name参数,该参数可用于指定夹具的名称,与夹具功能不同:

@pytest.fixture(name="venv_dir")
def _venv_dir():
    ...

这是很有用的,因为当使用与使用它们的测试函数在同一模块中声明的 fixture 时,用户可能会遇到一些麻烦:

  • 如果用户忘记在测试函数的参数列表中声明 fixture,他们将得到一个NameError而不是 fixture 函数对象(因为他们在同一个模块中)。
  • 一些 linter 抱怨 test function 参数隐藏了 fixture 函数。

如果以前的烦恼经常发生,您可以在团队中将此作为一种良好的做法。请记住,这些问题只会发生在测试模块中定义的夹具上,而不会发生在conftest.py文件中。

首选 conftest 文件中的本地导入

conftest.py文件是在收集过程中导入的,因此它们直接影响您从命令行运行测试时的体验。出于这个原因,我建议尽可能多地在conftest.py文件中使用本地导入,以保持较低的导入时间。

所以,不要用这个:

import pytest
import tempfile
from myapp import setup

@pytest.fixture
def setup_app():
    ...

喜欢本地进口产品:

import pytest

@pytest.fixture
def setup_app():
 import tempfile
 from myapp import setup
    ...

这种做法对大型测试套件中的测试启动有显著影响。

夹具作为测试支持代码

您不仅应该将 fixture 看作是提供资源的一种手段,还应该将 fixture 看作是为测试提供支持代码的一种手段。通过支持代码,我指的是为测试提供高级功能的类。

例如,bot 框架可能会提供一个夹具,用于将您的 bot 作为黑盒进行测试:

def test_hello(bot):
    reply = bot.say("hello")
    assert reply.text == "Hey, how can I help you?"

def test_store_deploy_token(bot):
    assert bot.store["TEST"]["token"] is None
    reply = bot.say("my token is ASLKM8KJAN")
    assert reply.text == "OK, your token was saved"
    assert bot.store["TEST"]["token"] == "ASLKM8KJAN"

botfixture 允许开发人员与 bot 对话、验证响应、检查框架处理的内部存储的内容等。它提供了一个高级接口,使测试更易于编写和理解,即使对于那些不了解框架内部结构的人来说也是如此。

这种技术对于应用程序很有用,因为它将使开发人员添加新的测试变得容易和愉快。它对库也很有用,因为它们将为库的用户提供高级测试支持。

总结

在本章中,我们深入探讨了 pytest 最著名的特性之一:夹具。我们已经了解了如何使用它们来提供资源和测试功能,以及如何简洁地表达设置/拆卸代码。我们学习了如何使用conftest.py文件共享固定装置;使用夹具范围,避免为每次测试创建昂贵的资源;以及自动使用为同一模块或层次结构中的所有测试执行的装置。然后,我们学习了如何参数化夹具并使用它们的标记。我们对各种内置装置进行了概述,并在本章结束时对装置进行了一些简短的讨论。我希望你旅途愉快!

在下一章中,我们将探索一点可供您使用的庞大 pytest 插件生态系统。