pytest中的fixture

会写测试,和写好测试,是差距很大的两种境界。 孤大概花了半年时间来破境。 导致这么长时间的壁障,并非是学习难度,而是进入『会写测试』境界后的一种满足心态。

需要被测试内容,如果是没有副作用的数学函数,给固定输入就可以得到特定输出,那么测试样例会很容易写。 然而,『没有副作用』的情况是很少见的,比如print。 所以,在正式进行测试前,往往需要做些准备;而在测试结束后,可能也需要做些清理。

本文介绍pytest中setup与teardown的写法,算是单元测试的进阶内容吧。

setup与teardown

fixture不太好翻译,大概是『固定装置』、『测试夹具』这类的意思。 如果换一种单元测试常见的称呼,就比较好理解了——setup与teardown,也就是在测试前后,做一些准备和清理。

pytest中,支持setup_*teardown_*形式的function或method,分别在测试样例的前后回调。 共有module、function、class和method四种层级(官方文档中的名词为level,也可理解为作用域scope),大致形式如下。

def setup_module(module):
    pass

def teardown_module(module):
    pass

def setup_function(function):
    pass

def teardown_function(function):
    pass

class TestSomething:
    @classmethod
    def setup_class(cls):
        pass

    @classmethod
    def teardown_class(cls):
        pass

    def setup_method(self, method):
        pass

    def teardown_method(self, method):
        pass

pytest的3.0版本以后,上面展示的modulefunctionmethod参数,可以去掉。 当然,class的cls不能去掉。 传参的目的,是支持对这些进行调整,然而大多数情况下是无用的,可以省略。 比如,module的可以写成:

def setup_module():
    pass

def teardown_module():
    pass

顾名思义,setup_module就是在同一个module的测试执行前回调一次,teardown_module则是在之后回调。 与之相比,setup_functionteardown_function则是在每次test_*形式的function被执行的前后回调。 class与method这一组的机制类似。

这种写法比较古老,是为了兼容unittest而保留的,并非pytest推荐的写法。 它的问题是,对需要被setup和teardown的东西,分得不够细致。 假如,有一个资源——比如一个伪造的数据库——需要被3个测试function使用,而这个module共有10个。 按照这种写法,就没有一个简洁优雅的写法,令pytest仅为这3个function准备,而不会影响另外7个。

pytest独创的fixture写法,就可以完美实现这类场景。

pytest.fixture

import pytest
import smtplib


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


def test_ehlo(smtp):
    response, msg = smtp.ehlo()
    assert response == 250
    assert b"smtp.gmail.com" in msg

这是一个官方样例,很明确地展示了fixture用法的各个细节。

首先,@pytest.fixture作为装饰器(decorator),被它作用的function即可成为一个fixture。

scope="module"是指定作用域。 类似setup_*teardown_*,这里的scope支持function、class、module、session四种,默认情况下的scope是function。 去掉的method由function替代,而新增的session则是扩大到了整个测试,可以覆盖多个module。

fixture的function名称,可以直接作为参数,传给需要使用它的测试样例。 在使用时,smtp并非前面定义的function,而是function的返回值,即smtplib.SMTP。 这一点比较隐晦,稍微违背了Python的哲学(详见《蛇宗三字经》),但却很方便。

yield smtp当然也可以是return smtp,不过后面就不能再有语句。 相当于只有setup、没有teardown。 使用yield,则后面的内容就是teardown。 这样不仅方便,把同一组的预备、清理写在一起,逻辑上也更紧密。

最终,在test_ehlo中直接声明一个形式参数smtp,就可以使用这个fixture。 同一个测试function中可以声明多个这类形式参数,也可以混杂其它类型的参数。 如果那些没有使用smtp这个fixture的function被单独测试,它不会被执行。

另外,在fixture中,也可使用其它fixture作为形式参数,形成树状依赖。 这为测试环境的准备,提供了更高的抽象层级。

conftest.py

前面有提,fixture的scope中,有session,也就是整个测试过程。 这意味着,fixture可以是全局的,供多个module使用。

pytest支持在测试的路径下,存在conftest.py文件,进行全局配置。

tests
├── conftest.py
├── test_a.py
├── test_b.py
└── sub
    ├── __init__.py
    ├── conftest.py
    ├── test_c.py
    └── test_d.py

在以上目录结构下,顶层的conftest.py里的配置,可以给四个测试module使用。 而sub下面的conftest.py,只能给sub下面的两个module使用。 如果两个conftest.py中定义了名称相同的fixture,则可以被覆盖; 也就是说,在sub下面的module,使用的是sub下的conftest.py里的定义同名fixture。

内置fixture

以下命令可以列出所有可用的fixture,包括内置的、插件中的、以及当前项目定义的。

pytest --fixtures

其中不乏广泛应用的内容,比如capsystmpdir

capsys
    Enable capturing of writes to sys.stdout/sys.stderr and make
    captured output available via ``capsys.readouterr()`` method calls
    which return a ``(out, err)`` tuple.  ``out`` and ``err`` will be ``text``
    objects.
tmpdir
    Return a temporary directory path object
    which is unique to each test function invocation,
    created as a sub directory of the base temporary
    directory.  The returned object is a `py.path.local`_
    path object.

例如:

def test_print(capsys):
    print('hello')
    out, err = capsys.readouterr()
    assert 'hello' == out

def test_path(tmpdir):
    from py._path.local import LocalPath
    assert isinstance(tmpdir, LocalPath)
    from os.path import isdir
    assert isdir(str(tmpdir))

capsys可以捕捉测试function的标准输出,而tmpdir则可以自动创建临时文件夹。 它们都是常用fixture,如果没有内置,恐怕所有项目都要自行实现。

Parametrizing

有时候,测试一个function,需要测试多种情况。 而每一种情况的测试逻辑基本雷同,只是参数或环境有异。 这时就需要参数化(Parametrizing)的fixture来减少重复。

比如,前面smtp那个例子,可能需要准备多个邮箱来测试。

@pytest.fixture(params=["smtp.gmail.com", "mail.python.org"])
def smtp(request):
    smtp = smtplib.SMTP(request.param, 587, timeout=5)
    yield smtp
    print ("finalizing %s" % smtp)
    smtp.close()

通过在@pytest.fixture中,指定参数params,就可以利用特殊对象(request)来引用request.param。 使用以上带参数的smtp的测试样例,都会被执行两次。

还有另一种情况,直接对测试function进行参数化。

def add(a, b):
    return a + b

@pytest.mark.parametrize("test_input, expected", [
    ([1, 1], 2),
    ([2, 2], 4),
    ([0, 1], 1),
])
def test_add(test_input, expected):
    assert expected == add(test_input[0], test_input[1])

利用@pytest.mark.parametrize,可以无需没有实质意义的fixture,直接得到参数化的效果,测试多组值。

总结

学会fixture这个利器,pytest才算真正用到家了。 它可以省去很多重复代码,并且自动管理依赖关系。

当然,pytest用到家,不代表Python测试就可以毕业了。 毕竟,有些环境是无法准备的,有些开销是可以避免的。

参考

以下参考,都是《Full pytest documentation — pytest documentation》的子页面。


相关笔记