pytest fixtures: explicit, modular, scalable¶

测试装置帮助搭建测试平台,满足不同测试用例的测试要求。在pytest中,测试装置是通过函数参数的形式传给测试用例函数。

pytest的测试装置机制比xUnit的更加灵活,但是于此同时,pytest也兼容xUnit风格的测试装置搭建。

测试装置通过@pytest.fixture修饰符来定义。下面是pytest自带的测试装置:

  • capfd,捕捉文本输出到文件描述符1和2
  • capfdbinay,捕捉二进制输出文件描述符1和2
  • caplog,控制跟踪记录
  • capsys,捕捉文本输出到sys.stdout和sys.stderr
  • capsysbinday, 同上,但输出二进制
  • cache,缓存运行时候的值
  • doctest_namespace,把一个字典注入到doctests的命名空间
  • monkeypatch,临时修改类型、函数、字典、os.environ以及其他
  • pytestconfig,访问pytest的配置信息、插件管理器以及插件钩子
  • record_property,为测试用例添加额外的部属
  • record_testsuite_proper,为测试套装添加额外的部属
  • recwarn,记录测试用例产生的告警信息
  • request,提供测试用例的信息
  • testdir,提供一个临时的目录供测试用例允许
  • tmp_path, 为测试用例提供一个唯一的pathlib.path目录对象
  • temp_path_factory,在会话范围提供临时的pathlib.path目录
  • tmpdir, 被tmp_path替代了,原本的用意是为测试用例提供一个唯一的py.path.local对象
  • tmpdir_factory,被temp_path_factory替代了

Fixtures as Function arguments

使用测试装置的一个例子:

# content of ./test_smtpsimple.py
import pytest


@pytest.fixture
def smtp_connection():
    import smtplib

    return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)


def test_ehlo(smtp_connection):
    response, msg = smtp_connection.ehlo()
    assert response == 250
    assert 0  # for demo purposes

通过pytest --fixtures test_simplefactory.py可以查看脚本中提供的测试装置

测试装置可以用来做测试注入(https://en.wikipedia.org/wiki/Dependency_injection)。也就是把测试装置对象作为被测对象。

conftest.py中书写的测试装置会被自动识别并导入。pytest会先查找测试用例,然后是测试模块,然后是conftest.py,然后是自带的或者第三方插件。不同目录可以指定不同的conftest.py来自定义测试。

测试装置可以用来在不同用例间分享测试数据。另一个办法是把测试数据放在tests目录。pytest-datadir和pytest-datafiles可以帮助你组织测试数据。

如果想在测试模块范围内使用同一个测试装置,可以在@pytest.fixture上使用scope=“module"参数:

import pytest
import smtplib


@pytest.fixture(scope="module")
def smtp_connection():
    return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)

scope参数的取值可以是function, class, module, package或者是session。

pytest 5.2开始允许动态决定scope的值:

def determine_scope(fixture_name, config):
    if config.getoption("--keep-containers", None):
        return "session"
    return "function"


@pytest.fixture(scope=determine_scope)
def docker_container():
    yield spawn_container()

范围越大的装置越早被初始化。范围相同的装置按照定义顺序来初始化。Autouse的装置在显示使用的装置之前初始化。

在装置中使用yield语句的话,yield语句之后的代码会被作为teardown代码:

# content of conftest.py

import smtplib
import pytest


@pytest.fixture(scope="module")
def smtp_connection():
    smtp_connection = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
    yield smtp_connection  # provide the fixture value
    print("teardown smtp")
    smtp_connection.close()

也可以在with语句中使用yield:

import smtplib
import pytest


@pytest.fixture(scope="module")
def smtp_connection():
    with smtplib.SMTP("smtp.gmail.com", 587, timeout=5) as smtp_connection:
        yield smtp_connection  # provide the fixture value

如果setup代码出异常,那么yield语句以后的代码不会被执行。但是无论setup代码是否抛出异常,contextlib.ExitStack 的清理函数保证会被调用:

import contextlib

import pytest


@contextlib.contextmanager
def connect(port):
    ...  # create connection
    yield
    ...  # close connection


@pytest.fixture
def equipments():
    with contextlib.ExitStack() as stack:
        yield [stack.enter_context(connect(port)) for port in ("C1", "C3", "C28")]

另一种teardown的方法是使用request-context的addfinalizer:

import smtplib
import pytest


@pytest.fixture(scope="module")
def smtp_connection(request):
    smtp_connection = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)

    def fin():
        print("teardown smtp_connection")
        smtp_connection.close()

    request.addfinalizer(fin)
    return smtp_connection  # provide the fixture value

测试装置可以通过request参数来检视测试用例信息:

@pytest.fixture(scope="module")
def smtp_connection(request):
    server = getattr(request.module, "smtpserver", "smtp.gmail.com")
    smtp_connection = smtplib.SMTP(server, 587, timeout=5)
    yield smtp_connection
    print("finalizing {} ({})".format(smtp_connection, server))
    smtp_connection.close()

测试装置还可以返回一个对象工厂,下面是一个例子:

@pytest.fixture
def make_customer_record():

    created_records = []

    def _make_customer_record(name):
        record = models.Customer(name=name, orders=[])
        created_records.append(record)
        return record

    yield _make_customer_record

    for record in created_records:
        record.destroy()


def test_customer_records(make_customer_record):
    customer_1 = make_customer_record("Lisa")
    customer_2 = make_customer_record("Mike")
    customer_3 = make_customer_record("Meredith")

可以对测试装置进行参数化,这样这个测试装置会被调用多次:

import pytest
import smtplib


@pytest.fixture(scope="module", params=["smtp.gmail.com", "mail.python.org"])
def smtp_connection(request):
    smtp_connection = smtplib.SMTP(request.param, 587, timeout=5)
    yield smtp_connection
    print("finalizing {}".format(smtp_connection))
    smtp_connection.close()

上面的装置会有两个版本,一个是以smtp.gmail.com为参数;另一个是mail.python.org。如果有一个测试用例test_ehlo使用到了这个fixture,那么这个用例也会有两份,一个test_ehlo[smtp.gmail.com],另一个是test_ehlo[mail.python.org]。通过pytest的--collect-only选项可以查看生成的名字。

test ID会被处理成字符串形式。Numbers, strings, booleans以及None都会被转为相应的字符串形式。也可以通过ids来显示指定其他ID:

import pytest


@pytest.fixture(params=[0, 1], ids=["spam", "ham"])
def a(request):
    return request.param


def test_a(a):
    pass


def idfn(fixture_value):
    if fixture_value == 0:
        return "eggs"
    else:
        return None


@pytest.fixture(params=[0, 1], ids=idfn)
def b(request):
    return request.param


def test_b(b):
    pass

上面的例子还展示了ids是函数的情况。

使用pytest.param()可以往参数化的测试装置上添加mark:

import pytest


@pytest.fixture(params=[0, 1, pytest.param(2, marks=pytest.mark.skip)])
def data_set(request):
    return request.param


def test_data(data_set):
    pass

在一个测试装置里面可以引用另一个测试装置:

import pytest


class App:
    def __init__(self, smtp_connection):
        self.smtp_connection = smtp_connection


@pytest.fixture(scope="module")
def app(smtp_connection):
    return App(smtp_connection)


def test_smtp_connection_exists(app):
    assert app.smtp_connection

pytest会保证创建尽量少的测试装置实例。的测试装置用完就会被回收。

一个测试用例可能只需要知道一个测试装置存在,但并不需要直接使用这个装置,这种情况可以使用usefixtures标记:

import os
import pytest


@pytest.mark.usefixtures("cleandir")
class TestDirectoryInit:
    def test_cwd_starts_empty(self):
        assert os.listdir(os.getcwd()) == []
        with open("myfile", "w") as f:
            f.write("hello")

    def test_cwd_again_starts_empty(self):
        assert os.listdir(os.getcwd()) == []

也可以在pytest.ini中指定:

# content of pytest.ini
[pytest]
usefixtures = cleandir

注意usefixtures不能再测试装置上使用。

可以指定自动使用某个测试装置:

class TestClass:
    @pytest.fixture(autouse=True)
    def transact(self, request, db):
        db.begin(request.function.__name__)
        yield
        db.rollback()

    def test_method1(self, db):
        assert db.intransaction == ["test_method1"]

    def test_method2(self, db):
        assert db.intransaction == ["test_method2"]

使用autouse有一些规则需要遵守,章节中有介绍。

一个小范围的测试装置可以覆盖以及访问同名的大范围的测试装置。

(本篇完)