4、pytest-fixtures:明确的、模块化的和可扩展的

pytest fixtures的目的是提供一个固定的基线,使测试可以在此基础上可靠地、重复地执行;对比xUnit经典的setup/teardown形式,它在以下方面有了明显的改进:

  • fixture拥有一个明确的名称,通过声明使其能够在函数、类、模块,甚至整个测试会话中被激活使用;
  • fixture以一种模块化的方式实现。因为每一个fixture的名字都能触发一个fixture函数,而这个函数本身又能调用其它的fixture
  • fixture的管理从简单的单元测试扩展到复杂的功能测试,允许通过配置和组件选项参数化fixture和测试用例,或者跨功能、类、模块,甚至整个测试会话复用fixture

此外,pytest继续支持经典的xUnit风格的测试。你可以根据自己的喜好,混合使用两种风格,或者逐渐过渡到新的风格。你也可以从已有的unittest.TestCase或者nose项目中执行测试;

1. fixture:作为形参使用

测试用例可以接收fixture的名字作为入参,其实参是对应的fixture函数的返回值。通过@pytest.fixture装饰器可以注册一个fixture

我们来看一个简单的测试模块,它包含一个fixture和一个使用它的测试用例:

# src/chapter-4/test_smtpsimple.py

import pytest


@pytest.fixture
def smtp_connection():
    import smtplib

    return smtplib.SMTP("smtp.163.com", 25, timeout=5)


def test_ehlo(smtp_connection):
    response, _ = smtp_connection.ehlo()
    assert response == 250
    assert 0  # 为了展示,强制置为失败

这里,test_ehlo有一个形参smtp_connection,和上面定义的fixture函数同名;

执行:

$ pipenv run pytest -q src/chapter-4/test_smtpsimple.py 
F                                                                 [100%]
=============================== FAILURES ================================
_______________________________ test_ehlo _______________________________

smtp_connection = 

    def test_ehlo(smtp_connection):
        response, _ = smtp_connection.ehlo()
        assert response == 250
>       assert 0  # 为了展示,强制置为失败
E       assert 0

src/chapter-4/test_smtpsimple.py:35: AssertionError
1 failed in 0.17s

执行的过程如下:

  • pytest收集到测试用例test_ehlo,其有一个形参smtp_connectionpytest查找到一个同名的已经注册的fixture
  • 执行smtp_connection()创建一个smtp_connection实例作为test_ehlo的实参;
  • 执行test_ehlo()

如果你不小心拼写出错,或者调用了一个未注册的fixture,你会得到一个fixture <...> not found的错误,并告诉你目前所有可用的fixture,如下:

$ pipenv run pytest -q src/chapter-4/test_smtpsimple.py 
E                                                                 [100%]
================================ ERRORS =================================
______________________ ERROR at setup of test_ehlo ______________________
file /Users/yaomeng/Private/Projects/pytest-chinese-doc/src/chapter-4/test_smtpsimple.py, line 32
  def test_ehlo(smtp_connectio):
E       fixture 'smtp_connectio' not found
>       available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, smtp_connection, smtp_connection_package, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
>       use 'pytest --fixtures [testpath]' for help on them.

/Users/yaomeng/Private/Projects/pytest-chinese-doc/src/chapter-4/test_smtpsimple.py:32
1 error in 0.02s

注意:

你也可以使用如下调用方式:

pytest --fixtures [testpath]

它会帮助你显示所有可用的 fixture;

但是,对于_开头的fixture,需要加上-v选项;

2. fixture:一个典型的依赖注入的实践

fixture允许测试用例可以轻松的接收和处理特定的需要预初始化操作的应用对象,而不用过分关心导入/设置/清理的细节;这是一个典型的依赖注入的实践,其中,fixture扮演者注入者(injector)的角色,而测试用例扮演者消费者(client)的角色;

以上一章的例子来说明:test_ehlo测试用例需要一个smtp_connection的连接对象来做测试,它只关心这个连接是否有效和可达,并不关心它的创建过程。smtp_connectiontest_ehlo来说,就是一个需要预初始化操作的应用对象,而这个预处理操作是在fixture中完成的;简而言之,test_ehlo说:“我需要一个SMTP连接对象。”,然后,pytest就给了它一个,就这么简单。

关于依赖注入的解释,可以看看Stackflow上这个问题的高票回答如何向一个5岁的孩子解释依赖注入?

When you go and get things out of the refrigerator for yourself, you can cause problems. You might leave the door open, you might get something Mommy or Daddy doesn't want you to have. You might even be looking for something we don't even have or which has expired.

What you should be doing is stating a need, "I need something to drink with lunch," and then we will make sure you have something when you sit down to eat.

更详细的资料可以看看维基百科Dependency injection

3. conftest.py:共享fixture实例

如果你想在多个测试模块中共享同一个fixture实例,那么你可以把这个fixture移动到conftest.py文件中。在测试模块中你不需要手动的导入它,pytest会自动发现,fixture的查找的顺序是:测试类、测试模块、conftest.py、最后是内置和第三方的插件;

你还可以利用conftest.py文件的这个特性为每个目录实现一个本地化的插件

4. 共享测试数据

如果你想多个测试共享同样的测试数据文件,我们有两个好方法实现这个:

  • 把这些数据加载到fixture中,测试中再使用这些fixture
  • 把这些数据文件放到tests文件夹中,一些第三方的插件能帮助你管理这方面的测试,例如:pytest-datadirpytest-datafiles

5. 作用域:在跨类的、模块的或整个测试会话的用例中,共享fixture实例

需要使用到网络接入的fixture往往依赖于网络的连通性,并且创建过程一般都非常耗时;

我们来扩展一下上述示例(src/chapter-4/test_smtpsimple.py):在@pytest.fixture装饰器中添加scope='module'参数,使每个测试模块只调用一次smtp_connection(默认每个用例都会调用一次),这样模块中的所有测试用例将会共享同一个fixture实例;其中,scope参数可能的值都有:function(默认值)、classmodulepackagesession

首先,我们把smtp_connection()提取到conftest.py文件中:

# src/chapter-4/conftest.py


import pytest
import smtplib


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

然后,在相同的目录下,新建一个测试模块test_module.py,将smtp_connection作为形参传入每个测试用例,它们共享同一个smtp_connection()的返回值:

# src/chapter-4/test_module.py


def test_ehlo(smtp_connection):
    response, _ = smtp_connection.ehlo()
    assert response == 250
    smtp_connection.extra_attr = 'test'
    assert 0  # 为了展示,强制置为失败


def test_noop(smtp_connection):
    response, _ = smtp_connection.noop()
    assert response == 250
    assert smtp_connection.extra_attr == 0  # 为了展示,强制置为失败

最后,让我们来执行这个测试模块:

pipenv run pytest -q src/chapter-4/test_module.py 
FF                                                                [100%]
=============================== FAILURES ================================
_______________________________ test_ehlo _______________________________

smtp_connection = 

    def test_ehlo(smtp_connection):
        response, _ = smtp_connection.ehlo()
        assert response == 250
        smtp_connection.extra_attr = 'test'
>       assert 0  # 为了展示,强制置为失败
E       assert 0

src/chapter-4/test_module.py:27: AssertionError
_______________________________ test_noop _______________________________

smtp_connection = 

    def test_noop(smtp_connection):
        response, _ = smtp_connection.noop()
        assert response == 250
>       assert smtp_connection.extra_attr == 0
E       AssertionError: assert 'test' == 0
E        +  where 'test' = .extra_attr

src/chapter-4/test_module.py:33: AssertionError
2 failed in 0.72s

可以看到:

  • 两个测试用例使用的smtp_connection实例都是,说明smtp_connection只被调用了一次;
  • 在前一个用例test_ehlo中修改smtp_connection实例(上述例子中,为smtp_connection添加extra_attr属性),也会反映到test_noop用例中;

如果你期望拥有一个会话级别作用域的fixture,可以简单的将其声明为:

@pytest.fixture(scope='session')
def smtp_connection():
  return smtplib.SMTP("smtp.163.com", 25, timeout=5)

注意:

pytest每次只缓存一个fixture实例,当使用参数化的fixture时,pytest可能会在声明的作用域内多次调用这个fixture

5.1. package作用域(实验性的)

在 pytest 3.7 的版本中,正式引入了package作用域。

package作用域的fixture会作用于包内的每一个测试用例:

首先,我们在src/chapter-4目录下创建如下的组织:

chapter-4/
└── package_expr
    ├── __init__.py
    ├── test_module1.py
    └── test_module2.py

然后,在src/chapter-4/conftest.py中声明一个package作用域的fixture

@pytest.fixture(scope='package')
def smtp_connection_package():
    return smtplib.SMTP("smtp.163.com", 25, timeout=5)

接着,在src/chapter-4/package_expr/test_module1.py中添加如下测试用例:

def test_ehlo_in_module1(smtp_connection_package):
    response, _ = smtp_connection_package.ehlo()
    assert response == 250
    assert 0  # 为了展示,强制置为失败


def test_noop_in_module1(smtp_connection_package):
    response, _ = smtp_connection_package.noop()
    assert response == 250
    assert 0  # 为了展示,强制置为失败

同样,在src/chapter-4/package_expr/test_module2.py中添加如下测试用例:

def test_ehlo_in_module2(smtp_connection_package):
    response, _ = smtp_connection_package.ehlo()
    assert response == 250
    assert 0  # 为了展示,强制置为失败

最后,执行src/chapter-4/package_expr下所有的测试用例:

$ pipenv run pytest -q src/chapter-4/package_expr/
FFF                                                               [100%]
=============================== FAILURES ================================
_________________________ test_ehlo_in_module1 __________________________

smtp_connection_package = 

    def test_ehlo_in_module1(smtp_connection_package):
        response, _ = smtp_connection_package.ehlo()
        assert response == 250
>       assert 0  # 为了展示,强制置为失败
E       assert 0

src/chapter-4/package_expr/test_module1.py:26: AssertionError
_________________________ test_noop_in_module1 __________________________

smtp_connection_package = 

    def test_noop_in_module1(smtp_connection_package):
        response, _ = smtp_connection_package.noop()
        assert response == 250
>       assert 0
E       assert 0

src/chapter-4/package_expr/test_module1.py:32: AssertionError
_________________________ test_ehlo_in_module2 __________________________

smtp_connection_package = 

    def test_ehlo_in_module2(smtp_connection_package):
        response, _ = smtp_connection_package.ehlo()
        assert response == 250
>       assert 0  # 为了展示,强制置为失败
E       assert 0

src/chapter-4/package_expr/test_module2.py:26: AssertionError
3 failed in 0.45s

可以看到:

  • 虽然这三个用例在不同的模块中,但是使用相同的fixture实例,即

注意:

  • chapter-4/package_expr可以不包含__init__.py文件,因为pytest发现测试用例的规则没有强制这一点;同样,package_expr/的命名也不需要符合test_*或者*_test的规则;

  • 这个功能标记为实验性的,如果在其实际应用中发现严重的bug,那么这个功能很可能被移除;

6. fixture的实例化顺序

多个fixture的实例化顺序,遵循以下原则:

  • 高级别作用域的(例如:session)先于低级别的作用域的(例如:class或者function)实例化;
  • 相同级别作用域的,其实例化顺序遵循它们在测试用例中被声明的顺序(也就是形参的顺序),或者fixture之间的相互调用关系;
  • 使能autousefixture,先于其同级别的其它fixture实例化;

我们来看一个具体的例子:

# src/chapter-4/test_order.py

import pytest

order = []


@pytest.fixture(scope="session")
def s1():
    order.append("s1")


@pytest.fixture(scope="module")
def m1():
    order.append("m1")


@pytest.fixture
def f1(f3):
    order.append("f1")


@pytest.fixture
def f3():
    order.append("f3")


@pytest.fixture(autouse=True)
def a1():
    order.append("a1")


@pytest.fixture
def f2():
    order.append("f2")


def test_order(f1, m1, f2, s1):
    assert order == ["s1", "m1", "a1", "f3", "f1", "f2"]
  • s1拥有最高级的作用域(session),即使在测试用例test_order中最后被声明,它也是第一个被实例化的(参照第一条原则)

  • m1拥有仅次于session级别的作用域(module),所以它是第二个被实例化的(参照第一条原则)

  • f1 f2 f3 a1同属于function级别的作用域:

    • test_order(f1, m1, f2, s1)形参的声明顺序中,可以看出,f1f2先实例化(参照第二条原则)
    • f1的定义中又显式的调用了f3,所以f3f1先实例化(参照第二条原则)
    • a1的定义中使能了autouse标记,所以它会在同级别的fixture之前实例化,这里也就是在f3 f1 f2之前实例化(参照第三条原则)
  • 所以这个例子fixture实例化的顺序为:s1 m1 a1 f3 f1 f2

注意:

  • 除了autousefixture,需要测试用例显示声明(形参),不声明的不会被实例化;

  • 多个相同作用域的autouse fixture,其实例化顺序遵循fixture函数名的排序;

7. fixture的清理操作

我们期望在fixture退出作用域之前,执行某些清理性操作(例如,关闭服务器的连接等);

我们有以下几种形式,实现这个功能:

7.1. 使用yield代替return

fixture函数中的return关键字替换成yield,则yield之后的代码,就是我们要的清理操作;

我们来声明一个包含清理操作的smtp_connection

# src/chapter-4/conftest.py

@pytest.fixture()
def smtp_connection_yield():
    smtp_connection = smtplib.SMTP("smtp.163.com", 25, timeout=5)
    yield smtp_connection
    print("关闭SMTP连接")
    smtp_connection.close()

再添加一个使用它的测试用例:

# src/chapter-4/test_smtpsimple.py

def test_ehlo_yield(smtp_connection_yield):
    response, _ = smtp_connection_yield.ehlo()
    assert response == 250
    assert 0  # 为了展示,强制置为失败

现在,我们来执行它:

λ pipenv run pytest -q -s --tb=no src/chapter-4/test_smtpsimple.py::test_ehlo_yield
F关闭SMTP连接

1 failed in 0.18s

我们可以看到在test_ehlo_yield执行完后,又执行了yield后面的代码;

7.2. 使用with写法

对于支持with写法的对象,我们也可以隐式的执行它的清理操作;

例如,上面的smtp_connection_yield也可以这样写:

@pytest.fixture()
def smtp_connection_yield():
    with smtplib.SMTP("smtp.163.com", 25, timeout=5) as smtp_connection:
        yield smtp_connection

7.3. 使用addfinalizer方法

fixture函数能够接收一个request的参数,表示测试请求的上下文;我们可以使用request.addfinalizer方法为fixture添加清理函数;

例如,上面的smtp_connection_yield也可以这样写:

@pytest.fixture()
def smtp_connection_fin(request):
    smtp_connection = smtplib.SMTP("smtp.163.com", 25, timeout=5)

    def fin():
        smtp_connection.close()

    request.addfinalizer(fin)
    return smtp_connection

注意:

yield之前或者addfinalizer注册之前代码发生错误退出的,都不会再执行后续的清理操作

8. fixture可以访问测试请求的上下文

fixture函数可以接收一个request的参数,表示测试用例、类、模块,甚至测试会话的上下文环境;

我们可以扩展上面的smtp_connection_yield,让其根据不同的测试模块使用不同的服务器:

# src/chapter-4/conftest.py

@pytest.fixture(scope='module')
def smtp_connection_request(request):
    server, port = getattr(request.module, 'smtp_server', ("smtp.163.com", 25))
    with smtplib.SMTP(server, port, timeout=5) as smtp_connection:
        yield smtp_connection
        print("断开 %s:%d" % (server, port))

在测试模块中指定smtp_server

# src/chapter-4/test_request.py

smtp_server = ("mail.python.org", 587)


def test_163(smtp_connection_request):
    response, _ = smtp_connection_request.ehlo()
    assert response == 250

我们来看看效果:

λ pipenv run pytest -q -s src/chapter-4/test_request.py
.断开 mail.python.org:587

1 passed in 4.03s

9. fixture返回工厂函数

如果你需要在一个测试用例中,多次使用同一个fixture实例,相对于直接返回数据,更好的方法是返回一个产生数据的工厂函数;

并且,对于工厂函数产生的数据,也可以在fixture中对其管理:

@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")

10. fixture的参数化

如果你需要在一系列的测试用例的执行中,每轮执行都使用同一个fixture,但是有不同的依赖场景,那么可以考虑对fixture进行参数化;这种方式适用于对多场景的功能模块进行详尽的测试;

在之前的章节fixture可以访问测试请求的上下文中,我们在测试模块中指定不同smtp_server,得到不同的smtp_connection实例;

现在,我们可以通过指定params关键字参数创建两个fixture实例,每个实例供一轮测试使用,所有的测试用例执行两遍;在fixture的声明函数中,可以使用request.param获取当前使用的入参;

# src/chapter-4/test_request.py

@pytest.fixture(scope='module', params=['smtp.163.com', "mail.python.org"])
def smtp_connection_params(request):
    server = request.param
    with smtplib.SMTP(server, 587, timeout=5) as smtp_connection:
        yield smtp_connection

在测试用例中使用这个fixture

# src/chapter-4/test_params.py

def test_parames(smtp_connection_params):
    response, _ = smtp_connection_params.ehlo()
    assert response == 250

执行:

$ pipenv run pytest -q -s src/chapter-4/test_params.py 
.断开 smtp.163.com:25
.断开 smtp.126.com:25

2 passed in 0.26s

可以看到:

  • 这个测试用例使用不同的SMTP服务器,执行了两次;

在参数化的fixture中,pytest为每个fixture实例自动指定一个测试ID,例如:上述示例中的test_parames[smtp.163.com]test_parames[smtp.126.com]

使用-k选项执行一个指定的用例:

$ pipenv run pytest -q -s -k 163 src/chapter-4/test_params.py 
.断开 smtp.163.com:25

1 passed, 1 deselected in 0.16s

使用--collect-only可以显示这些测试ID,而不执行用例:

$ pipenv run pytest -q -s --collect-only src/chapter-4/test_params.py 
src/chapter-4/test_params.py::test_parames[smtp.163.com]
src/chapter-4/test_params.py::test_parames[smtp.126.com]

no tests ran in 0.01s

同时,也可以使用ids关键字参数,自定义测试ID

# src/chapter-4/test_ids.py

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


def test_a(a):
    pass

执行--collect-only

$ pipenv run pytest -q -s --collect-only src/chapter-4/test_ids.py::test_a 
src/chapter-4/test_ids.py::test_a[spam]
src/chapter-4/test_ids.py::test_a[ham]

no tests ran in 0.01s

我们看到,测试ID为我们指定的值;

数字、字符串、布尔值和None在测试ID中使用的是它们的字符串表示形式:

# src/chapter-4/test_ids.py

def idfn(fixture_value):
    if fixture_value == 0:
        return "eggs"
    elif fixture_value == 1:
        return False
    elif fixture_value == 2:
        return None
    else:
        return fixture_value


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


def test_b(b):
    pass

执行--collect-only

$ pipenv run pytest -q -s --collect-only src/chapter-4/test_ids.py::test_b 
src/chapter-4/test_ids.py::test_b[eggs]
src/chapter-4/test_ids.py::test_b[False]
src/chapter-4/test_ids.py::test_b[2]
src/chapter-4/test_ids.py::test_b[3]

no tests ran in 0.01s

可以看到:

  • ids可以接收一个函数,用于生成测试ID
  • 测试ID指定为None时,使用的是params原先对应的值;

注意:

当测试params中包含元组、字典或者对象时,测试ID使用的是fixture函数名+param的下标:

# src/chapter-4/test_ids.py

class C:
    pass


@pytest.fixture(params=[(1, 2), {'d': 1}, C()])
def c(request):
    return request.param


def test_c(c):
    pass

执行--collect-only

$ pipenv run pytest -q -s --collect-only src/chapter-4/test_ids.py::test_c
src/chapter-4/test_ids.py::test_c[c0]
src/chapter-4/test_ids.py::test_c[c1]
src/chapter-4/test_ids.py::test_c[c2]

no tests ran in 0.01s

可以看到,测试IDfixture的函数名(c)加上对应param的下标(从0开始);

如果你不想这样,可以使用str()方法或者复写__str__()方法;

11. 在参数化的fixture中标记用例

fixtureparams参数中,可以使用pytest.param标记这一轮的所有用例,其用法和在pytest.mark.parametrize中的用法一样;

# src/chapter-4/test_fixture_marks.py

import pytest


@pytest.fixture(params=[('3+5', 8),
                        pytest.param(('6*9', 42),
                                     marks=pytest.mark.xfail,
                                     id='failed')])
def data_set(request):
    return request.param


def test_data(data_set):
    assert eval(data_set[0]) == data_set[1]

我们使用pytest.param(('6*9', 42), marks=pytest.mark.xfail, id='failed')的形式指定一个request.param入参,其中marks表示当用例使用这个入参时,跳过执行将用例标记为xfail;并且,我们还使用id为此时的用例指定了一个测试ID

$ pipenv run pytest -v src/chapter-4/test_fixture_marks.py::test_data
============================ test session starts ============================
platform darwin -- Python 3.7.3, pytest-5.1.3, py-1.8.0, pluggy-0.13.0 -- /Users/yaomeng/.local/share/virtualenvs/pytest-chinese-doc-EK3zIUmM/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/yaomeng/Private/Projects/pytest-chinese-doc
collected 2 items                                                           

src/chapter-4/test_fixture_marks.py::test_data[data_set0] PASSED      [ 50%]
src/chapter-4/test_fixture_marks.py::test_data[failed] XFAIL          [100%]

======================= 1 passed, 1 xfailed in 0.08s ========================

可以看到:

  • 用例结果是XFAIL,而不是FAILED
  • 测试ID是我们指定的failed,而不是data_set1

我们也可以使用pytest.mark.parametrize实现相同的效果:

# src/chapter-4/test_fixture_marks.py

@pytest.mark.parametrize(
    'test_input, expected',
    [('3+5', 8),
     pytest.param('6*9', 42, marks=pytest.mark.xfail, id='failed')])
def test_data2(test_input, expected):
    assert eval(test_input) == expected

执行:

pipenv run pytest -v src/chapter-4/test_fixture_marks.py::test_data2
============================ test session starts ============================
platform darwin -- Python 3.7.3, pytest-5.1.3, py-1.8.0, pluggy-0.13.0 -- /Users/yaomeng/.local/share/virtualenvs/pytest-chinese-doc-EK3zIUmM/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/yaomeng/Private/Projects/pytest-chinese-doc
collected 2 items                                                           

src/chapter-4/test_fixture_marks.py::test_data2[3+5-8] PASSED         [ 50%]
src/chapter-4/test_fixture_marks.py::test_data2[failed] XFAIL         [100%]

======================= 1 passed, 1 xfailed in 0.07s ========================

12. 模块化:fixture使用其它的fixture

你不仅仅可以在测试用例上使用fixture,还可以在fixture的声明函数中使用其它的fixture;这有助于模块化的设计你的fixture,可以在多个项目中重复使用框架级别的fixture

一个简单的例子,我们可以扩展之前src/chapter-4/test_params.py的例子,实例一个app对象:

# src/chapter-4/test_appsetup.py

import pytest


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


@pytest.fixture(scope='module')
def app(smtp_connection_params):
    return App(smtp_connection_params)


def test_smtp_connection_exists(app):
    assert app.smtp_connection

我们创建一个fixture app并调用之前在conftest.py中定义的smtp_connection_params,返回一个App的实例;

执行:

$ pipenv run pytest -v src/chapter-4/test_appsetup.py 
============================ test session starts ============================
platform darwin -- Python 3.7.3, pytest-5.1.3, py-1.8.0, pluggy-0.13.0 -- /Users/yaomeng/.local/share/virtualenvs/pytest-chinese-doc-EK3zIUmM/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/yaomeng/Private/Projects/pytest-chinese-doc
collected 2 items                                                           

src/chapter-4/test_appsetup.py::test_smtp_connection_exists[smtp.163.com] PASSED [ 50%]
src/chapter-4/test_appsetup.py::test_smtp_connection_exists[smtp.126.com] PASSED [100%]

============================= 2 passed in 1.25s =============================

因为app使用了参数化的smtp_connection_params,所以测试用例test_smtp_connection_exists会使用不同的App实例执行两次,并且,app并不需要关心smtp_connection_params的实现细节;

app的作用域是模块级别的,它又调用了smtp_connection_params,也是模块级别的,如果smtp_connection_params会话级别的作用域,这个例子还是一样可以正常工作的;这是因为低级别的作用域可以调用高级别的作用域,但是高级别的作用域调用低级别的作用域会返回一个ScopeMismatch的异常;

13. 高效的利用fixture实例

在测试期间,pytest只激活最少个数的fixture实例;如果你拥有一个参数化的fixture,所有使用它的用例会在创建的第一个fixture实例并销毁后,才会去使用第二个实例;

下面这个例子,使用了两个参数化的fixture,其中一个是模块级别的作用域,另一个是用例级别的作用域,并且使用print方法打印出它们的setup/teardown流程:

# src/chapter-4/test_minfixture.py

import pytest


@pytest.fixture(scope="module", params=["mod1", "mod2"])
def modarg(request):
    param = request.param
    print("  SETUP modarg", param)
    yield param
    print("  TEARDOWN modarg", param)


@pytest.fixture(scope="function", params=[1, 2])
def otherarg(request):
    param = request.param
    print("  SETUP otherarg", param)
    yield param
    print("  TEARDOWN otherarg", param)


def test_0(otherarg):
    print("  RUN test0 with otherarg", otherarg)


def test_1(modarg):
    print("  RUN test1 with modarg", modarg)


def test_2(otherarg, modarg):
    print("  RUN test2 with otherarg {} and modarg {}".format(otherarg, modarg))

执行:

$ pipenv run pytest -q -s src/chapter-4/test_minfixture.py 
  SETUP otherarg 1
  RUN test0 with otherarg 1
.  TEARDOWN otherarg 1
  SETUP otherarg 2
  RUN test0 with otherarg 2
.  TEARDOWN otherarg 2
  SETUP modarg mod1
  RUN test1 with modarg mod1
.  SETUP otherarg 1
  RUN test2 with otherarg 1 and modarg mod1
.  TEARDOWN otherarg 1
  SETUP otherarg 2
  RUN test2 with otherarg 2 and modarg mod1
.  TEARDOWN otherarg 2
  TEARDOWN modarg mod1
  SETUP modarg mod2
  RUN test1 with modarg mod2
.  SETUP otherarg 1
  RUN test2 with otherarg 1 and modarg mod2
.  TEARDOWN otherarg 1
  SETUP otherarg 2
  RUN test2 with otherarg 2 and modarg mod2
.  TEARDOWN otherarg 2
  TEARDOWN modarg mod2

8 passed in 0.02s

可以看出:

  • mod1TEARDOWN操作完成后,才开始mod2SETUP操作;
  • 用例test_0独立完成测试;
  • 用例test_1test_2都使用到了模块级别的modarg,同时test_2也使用到了用例级别的otherarg。它们执行的顺序是,test_1先使用mod1,接着test_2使用mod1otherarg 1/otherarg 2,然后test_1使用mod2,最后test_2使用mod2otherarg 1/otherarg 2;也就是说test_1test_2共用相同的modarg实例,最少化的保留fixture的实例个数;

14. 在类、模块和项目级别上使用fixture实例

有时,我们并不需要在测试用例中直接使用fixture实例;例如,我们需要一个空的目录作为当前用例的工作目录,但是我们并不关心如何创建这个空目录;这里我们可以使用标准的tempfile模块来实现这个功能;

# src/chapter-4/conftest.py

import pytest
import tempfile
import os


@pytest.fixture()
def cleandir():
    newpath = tempfile.mkdtemp()
    os.chdir(newpath)

在测试中使用usefixtures标记声明使用它:

# src/chapter-4/test_setenv.py

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()) == []

得益于usefixtures标记,测试类TestDirectoryInit中所有的测试用例都可以使用cleandir,这和在每个测试用例中指定cleandir参数是一样的;

执行:

$ pipenv run pytest -q -s src/chapter-4/test_setenv.py 
..
2 passed in 0.02s

你可以使用如下方式指定多个fixture

@pytest.mark.usefixtures("cleandir", "anotherfixture")
def test():
    ...

你也可以使用如下方式为测试模块指定fixture

pytestmark = pytest.mark.usefixtures("cleandir")

注意:参数的名字必须pytestmark;

你也可以使用如下方式为整个项目指定fixture

# src/chapter-4/pytest.ini

[pytest]
usefixtures = cleandir

注意:

usefixtures标记不适用于fixture声明函数;例如:

@pytest.mark.usefixtures("my_other_fixture")
@pytest.fixture
def my_fixture_that_sadly_wont_use_my_other_fixture():
  ...

这并不会返回任何的错误或告警,具体讨论可以参考#3664

15. 自动使用fixture

有时候,你想在测试用例中自动使用fixture,而不是作为参数使用或者usefixtures标记;设想,我们有一个数据库相关的fixture,包含begin/rollback/commit的体系结构,现在我们希望通过begin/rollback包裹每个测试用例;

下面,通过列表实现一个虚拟的例子:

# src/chapter-4/test_db_transact.py

import pytest


class DB:
    def __init__(self):
        self.intransaction = []

    def begin(self, name):
        self.intransaction.append(name)

    def rollback(self):
        self.intransaction.pop()


@pytest.fixture(scope="module")
def db():
    return DB()


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"]

类级别作用域transact函数中声明了autouse=True,所以TestClass中的所有用例,可以自动调用transact而不用显式的声明或标记;

执行:

$ pipenv run pytest -q -s src/chapter-4/test_db_transact.py 
..
2 passed in 0.01s

autouse=Truefixture在其它级别作用域中的工作流程:

  • autouse fixture遵循scope关键字的定义:如果其含有scope='session',则不管它在哪里定义的,都将只执行一次;scope='class'表示每个测试类执行一次;
  • 如果在测试模块中定义autouse fixture,那么这个测试模块所有的用例自动使用它;
  • 如果在conftest.py中定义autouse fixture,那么它的相同文件夹和子文件夹中的所有测试模块中的用例都将自动使用它;
  • 如果在插件中定义autouse fixture,那么所有安装这个插件的项目中的所有用例都将自动使用它;

上述的示例中,我们期望只有TestClass的用例自动调用fixture transact,这样我们就不希望transact一直处于激活的状态,所以更标准的做法是,将transact声明在conftest.py中,而不是使用autouse=True

@pytest.fixture
def transact(request, db):
    db.begin()
    yield
    db.rollback()

并且,在TestClass上声明:

@pytest.mark.usefixtures("transact")
class TestClass:
    def test_method1(self):
        ...

其它类或者用例也想使用的话,同样需要显式的声明usefixtures

16. 在不同的层级上覆写fixture

在大型的测试中,你可能需要在本地覆盖项目级别的fixture,以增加可读性和便于维护;

16.1. 在文件夹(conftest.py)层级覆写fixture

假设我们有如下的测试项目:

tests/
    __init__.py

    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture
        def username():
            return 'username'

    test_something.py
        # content of tests/test_something.py
        def test_username(username):
            assert username == 'username'

    subfolder/
        __init__.py

        conftest.py
            # content of tests/subfolder/conftest.py
            import pytest

            @pytest.fixture
            def username(username):
                return 'overridden-' + username

        test_something.py
            # content of tests/subfolder/test_something.py
            def test_username(username):
                assert username == 'overridden-username'

可以看到:

  • 子文件夹conftest.py中的fixture覆盖了上层文件夹中同名的fixture
  • 子文件夹conftest.py中的fixture可以轻松的访问上层文件夹中同名的fixture

16.2. 在模块层级覆写fixture

假设我们有如下的测试项目:

tests/
    __init__.py

    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture
        def username():
            return 'username'

    test_something.py
        # content of tests/test_something.py
        import pytest

        @pytest.fixture
        def username(username):
            return 'overridden-' + username

        def test_username(username):
            assert username == 'overridden-username'

    test_something_else.py
        # content of tests/test_something_else.py
        import pytest

        @pytest.fixture
        def username(username):
            return 'overridden-else-' + username

        def test_username(username):
            assert username == 'overridden-else-username'

可以看到:

  • 模块中的fixture覆盖了conftest.py中同名的fixture
  • 模块中的fixture可以轻松的访问conftest.py中同名的fixture

16.3. 在用例参数中覆写fixture

假设我们有如下的测试项目:

tests/
    __init__.py

    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture
        def username():
            return 'username'

        @pytest.fixture
        def other_username(username):
            return 'other-' + username

    test_something.py
        # content of tests/test_something.py
        import pytest

        @pytest.mark.parametrize('username', ['directly-overridden-username'])
        def test_username(username):
            assert username == 'directly-overridden-username'

        @pytest.mark.parametrize('username', ['directly-overridden-username-other'])
        def test_username_other(other_username):
            assert other_username == 'other-directly-overridden-username-other'

可以看到:

  • fixture的值被用例的参数所覆盖;
  • 尽管用例test_username_other没有使用username,但是other_username使用到了username,所以也同样受到了影响;

16.4. 参数化的fixture覆写非参数化的fixture,反之亦然

tests/
    __init__.py

    conftest.py
        # content of tests/conftest.py
        import pytest

        @pytest.fixture(params=['one', 'two', 'three'])
        def parametrized_username(request):
            return request.param

        @pytest.fixture
        def non_parametrized_username(request):
            return 'username'

    test_something.py
        # content of tests/test_something.py
        import pytest

        @pytest.fixture
        def parametrized_username():
            return 'overridden-username'

        @pytest.fixture(params=['one', 'two', 'three'])
        def non_parametrized_username(request):
            return request.param

        def test_username(parametrized_username):
            assert parametrized_username == 'overridden-username'

        def test_parametrized_username(non_parametrized_username):
            assert non_parametrized_username in ['one', 'two', 'three']

    test_something_else.py
        # content of tests/test_something_else.py
        def test_username(parametrized_username):
            assert parametrized_username in ['one', 'two', 'three']

        def test_username(non_parametrized_username):
            assert non_parametrized_username == 'username'

可以看出:

  • 参数化的fixture和非参数化的fixture同样可以相互覆盖;
  • 在模块层级上的覆盖不会影响其它模块;

你可能感兴趣的:(4、pytest-fixtures:明确的、模块化的和可扩展的)