How Ark Use Pytest

今天上班时随手丢了上篇日志给毅总, 被给了差评, 今晚决定好好介绍下这个工具.

我们为什么用pytest

这是个很好解答的问题. 在组内强推这货的不是我, 是歪叔. 搜索了下历史, 我来稍微整理复盘下这段测试环境的演进.

Ark早期是跟随豆厂, 使用nose的. 在实践中遇到了一个问题: nose-randomize 会导致生成的 xml report 一片空白, pytest-random 则没有这个问题. 而且 pytest-random 还可以跨 test case 随机 shuffle, 而 nose-randomize 只能在 test suite 范围内 shuffle.

更多理由:

  • pytest backtrace 的输出更友好, 还支持 -l 显示各个 call frame 中的局部变量, 这在用例排查时非常非常省事, 所有过程列明, 不需要插桩, 也不需要pdb, 一遍测试, 认真看下输出就能找到大部分问题.
  • py.test 对纯粹 assert 语句的测试支持更好(体现在 backtrace 输出更友好). 出于个人偏好, 我认为 assert a == b, 不管从少码字, 编代码舒服程度, 还是美观程度, 都要比 self.assertEqual(a, b) 来得好, 何况 assert 在 backtrace 时的输出也很友好.1
  • 提供 conftest.py 钩子支持, 这点在推行时没有做什么定制, 近期得到了比较多的应用.
  • --pdb--capture 不冲突 - -
  • 歪叔写了这个 vim 插件用于简化 vim 与 pytest 的整合: pytest-vim-compiler; nosetest 的等价物要额外装一个 pypi 上没有的插件.

在比较 pytest 与 nose 后, 得出了这么几个结论:

暂时看不到 py.test 有什么劣势. nose 1.x 已经进入不活跃状态, nose2 还太年轻. 以及, nose驱动的测试基本上pytest可以平滑的接管. 大部分使用nose的测试都能用pytest无缝切换. 一切不兼容的特性, 修改起来也很容易, 事实上Ark在一两个PR内就完成了测试载体的切换.

pytest 是什么

它是运行测试的工具, 自动查找并运行测试用例. 他能基本兼容 nose, 有很多实用便捷的特性, 以及丰富的社区及强大的可定制化空间.

大概就是这样.

以上介绍有点水, 如果想要深入了解pytest, 还是去看看文档吧2

我们怎么用pytest

如何调用

基本用法:

py.test . # 运行当前目录所有的 testcases
py.test tests # 运行 tests 目录下的所有 cases
py.test tests/test_model.py # 运行 当个测试文件
py.test tests/test_model.py -k save # 运行 测试函数名中带 save 的测试用例
py.test tests -s # 输出stdout, 另外, 如果case出错, 会自动把stdout/stderr都输出来.

以上是我比较常用的方法.

我们没有从代码里面触发 pytest.main (Calling pytest from Python code)

调用参数

除了可以使用时附带参数, 这些参数可以放在 ./pytest.ini ./tests/pytest.ini 里面, 执行目录的用例时会自动带上这些参数. 具体是格式像这样:

[pytest]
addopts = -q --random

关于ini的寻找路径/优先级/发现首个配置即停/ini文件名, 参见: How test configuration is read from configuration INI-files)

插件也会提供一些参数. 自己也能定制一些参数.

assert

关于 assert , 能讲的不多. 前面讲过了 assert 写法舒心, 输出美观. 现在新写的case, 基本上会使用 assert 的写法. 至今还未有 Defining own assertion comparison 的需求.

fixture

这是 pytest 的当家特性. 它是 xUnit 风格测试中 setUp/tearDown 的强化版. 强化点在于:

  • 不像 setUp/tearDown 一锅端, 每个 fixture 都有显式的名称, 便于管理
  • fixture 粒度更细, 组合方便, 依赖注入.
  • fixture scope 可选度更高, 可将作用域限定于整个会话/单个模块/单个类/单个函数
  • fixture params 特性可以正交测试组合.

所以, 要想用好 pytest, 这页文档 是必读的. 我就不翻译文档了, 介绍 Ark 里是怎么使用的.


@pytest.fixture
def domain(table, app):
    return Domain.create(rel='rel', foo='bar')

def test_get_domain_model_by_id(domain):
    assert Domain.find(domain.id) == domain

这是代码库里一个比较简单的例子: 定义一个名叫 domain 的fixture, 并把 domain 作为 test_get_domain_model_by_id 的参数, pytest会自动注入 domain 这个fixture.


@pytest.fixture(scope="session")
def database(request):
    db_name = "{}.db".format(time())
    deferred_db.init(db_name)
    def finalizer():
        if os.path.exists(db_name):
            os.remove(db_name)
    request.addfinalizer(finalizer)
    return deferred_db

这个例子展现了scope和finalizer的基本用法, database 这个fixture会在整个session运行期间有效, 并在整个session结束时做一些销毁数据库tmp文件.


UNSIGNED_COMMON_PAGES = [
    '/',
    '/reader/',
]
@pytest.fixture(params=UNSIGNED_COMMON_PAGES)
def unsigned_page(request):
    return request.param

def test_fetch_common_pages_success_in_unsigend(request, app, session, unsigned_page):
    resp = fetch_page(session, unsigned_page)
    assert resp.status_code < 300

这个例子说明了 params 的用法, 只需要一个配置列表, 用fixture做个桥接, 使用一个测试函数, 就能驱动着Ark许多页面的访问性测试.

$ py.test checkpage/test_local.py -vv
checkpage/test_local.py:104: test_fetch_common_pages_success_in_unsigend[/] PASSED
checkpage/test_local.py:100: test_fetch_common_pages_success_in_signed[/reader/] PASSED

# Modular fixture manager

class App(object):

    def __init__(self, request):
        self.request = request


@pytest.fixture
def app(request,
        monkeypatch,
        mc_logger,
        db_logger,
        shire_mc_logger,
        fs_logger,
        store_logger,
        ):
    return App(request)

使用一个小技巧, 使分散的小fixture, 聚集成一个大规模的fixture.


Example code:

@pytest.mark.usefixtures('works')
def test_This_is_an_unexposed_endpoint(self):
    """This endpoint should be always attached with token.

    Example:

    .. sourcecode:: http

        HTTP/1.1 404 NOT FOUND

        {"message": "Unexposed endpoint"}
    """
    self.set_anon()
    resp = self.post_purchase(self.works)
    self.assert_unexposed(resp)

这是 Ark API 的测试. 要说明的问题是, 继承自 unittest.TestCase 的 xUnit 风格, 也能使用fixture. 通过 @pytest.mark.usefixtures('fixtureA', 'fixtureB') 使用指定fixture.

需要注意的是, 这并不是一个好主意. 在 Support for unittest.TestCase / Integration of fixtures 文档中已经指明: 可以, 但不推荐. 一个很大的原因是, pytest 的依赖注入会破坏 unittest.TestCase 风格测试的执行. 文档中介绍了两种在 xUnit-style 测试用例中编写fixture的方法. 但是Ark使用了文档没有介绍的第三种:

@pytest.fixture
def purchase(request):
    self = request.instance
    self.author= UserFactory()
    self.works = WorksFactory(author_id=self.author.id)
    return self.works

我们可以使 fixture 中生成的对象实例挂到 request.instance 上, 而这个 instance, 就是上面 test_This_is_an_unexposed_endpoint 后面的 self, 通过这种方法, 我们不是从参数注入 fixture, 而是从 self 注入了 fixture, 这也是上面测试用例中 self.works 的来源.

plugin

fixture 的位置

pytest 可以在测试文件中定义一个fixture, 但是更多时候, 我们想要共享fixture. 准确的说, 这个小节该谈论的主题应该叫 plugin.

我们需要知道, 使用 py.test --fixture 可以列出当前可用的所有fixture. 首先, pytest 有内置了几个fixture, 比如monkeypatch 这种逆天的fixture, tmpdir 这种好用的fixture, capsys 这种强悍的fixture.

其次, 我们也使用 pytest 第三方插件, 比如上文已经提过的 pytest-random, 用于乱序执行case; 还使用了 pytest-cov, 用于输出测试覆盖率报告.

再次, 我们也编写自定义插件. 将一堆 fixture 聚集到一个文件中, 之后再具体类型的测试中具体说明该类型测试要使用哪些插件.

ark [master] % cat tests/conftest.py
# -*- coding: utf-8 -*-

pytest_plugins = "suites.isolated_cases"
ark [master] % cat teleport/tests/conftest.py
# -*- coding: utf-8 -*-

pytest_plugins = "suites.isolated_cases", "suites.xunit_fixtures",

自定义参数

以 suites.isolated_cases 为例:

def pytest_addoption(parser):
    group = parser.getgroup("isolated_cases", "Every case is data-isolation.")
    group._addoption(
        '--with-data-service',
        action="store_true",
        default=False,
        dest='with_data_service',
        help=(
            "with MySQL/beansdb/memcached up at the beginning of session"
            "and down at the end of session."
        )
    )

def pytest_configure(config):
    if config.option.with_data_service:
        build_tables()
        stop_kvstore()
        sleep(1)
        start_kvstore()

def pytest_sessionfinish(session, exitstatus):
    if session.config.option.with_data_service:
        stop_kvstore()

def pytest_runtest_teardown(item, nextitem):
    if KEY_CACHED['mc']:
        destroy_cached_key(mc, 'mc')
    if KEY_CACHED['shire_mc']:
        destroy_cached_key(shire_mc, 'shire_mc')
    if KEY_CACHED['db']:
        destroy_cached_key(db, 'db')
    if KEY_CACHED['fs']:
        destroy_cached_key(fs, 'fs')
    if KEY_CACHED['store']:
        truncate_cached_tables()
  • pytest_addoption 添加 --with-data-service
  • pytest_configure 使添加此标识的session在最开始可以建表/起mc/db服务
  • pytest_sessionfinish 使添加此标识的session在最后可以杀掉db/mc服务(是否要摧毁所有的表?)
  • pytest_runtest_teardown 每个case结束时判断是否有修改过数据服务, 如果有, 刷掉.

但你需要知道, pytest 的 hook 非常多, 你可以在 plugin 中查阅这些hook. 你还需要知道, pytest 的 hook 至多, 文档甚至列不全, 像 pytest_sessionfinish 是我在源代码里挖到的 hook. 我想, 这么多hook, 应该可以满足你那变态的需求吧(在这位少年对PM的吐槽面前你竟然敢称自己的需求很变态?

trivial

小知识: pytest 也提供了 setup/teardown, 不过 setup_methodsetUp 是可以同时存在的哟. 具体顺序有兴趣的看官自己可以动手试试看. classic xunit-style setup. Anyway, 作者还是会不厌其烦的跟你说: 请你使用 fixture.

关于skip: 一个 pitfall 是 skip 必须传入一个字符串作为参数. 不能用作 @unittest.skip. 而必须是 @pytest.skip("... reason"). 有个xfail也挺好用: 用于用例在某种场景下一定要挂掉.

138:    @pytest.mark.xfail(reason="response 500")

总结

以上只是把仓库里的代码随手抽些拿出来讲了讲pytest的一些特性, 希望你会对pytest风格的测试感兴趣.