Python pytest 参数化进阶

dongfanger · 2020年09月28日 · 最后由 dongfanger 回复于 2020年10月05日 · 30 次阅读

用过 unittest 的朋友,肯定知道可以借助 DDT 实现参数化。用过 JMeter 的朋友,肯定知道 JMeter 自带了 4 种参数化方式。pytest 同样支持参数化,而且很简单很实用。

语法

在《pytest 精通 fixture》和《pytest 内置和自定义 marker》两篇文章中,都提到了 pytest 参数化。那么本文就趁着热乎,赶紧聊一聊 pytest 的参数化是怎么玩的。

@pytest.mark.parametrize

@user1ize("test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)])
def test_eval(test_input, expected):
    assert eval(test_input) == expected
  • 可以自定义变量,test_input 对应的值是"3+5" "2+4" "6*9",expected 对应的值是 8 6 42,多个变量用 tuple,多个 tuple 用 list

  • 参数化的变量是引用而非复制,意味着如果值是 list 或 dict,改变值会影响后续的 test

  • 重叠产生笛卡尔积

import pytest


@user2ize("x", [0, 1])
@user3ize("y", [2, 3])
def test_foo(x, y):
    pass

@pytest.fixture()

@pytest.fixture(scope="module", params=["smtp.gmail.com", "mail.python.org"])
def smtp_connection(request):
    smtp_connection = smtplib.SMTP(request.param, 587, timeout=5)
  • 只能使用 request.param 来引用

  • 参数化生成的 test 带有 ID,可以使用-k来筛选执行。默认是根据函数名[参数名]来的,可以使用 ids 来定义

// list
@pytest.fixture(params=[0, 1], ids=["spam", "ham"])
// function
@pytest.fixture(params=[0, 1], ids=idfn)

使用--collect-only命令行参数可以看到生成的 IDs。

参数添加 marker

我们知道了参数化后会生成多个 tests,如果有些 test 需要 marker,可以用 pytest.param 来添加

marker 方式

# content of test_expectation.py
import pytest


@user7ize(
    "test_input,expected",
    [("3+5", 8), ("2+4", 6), pytest.param("6*9", 42, marks=pytest.mark.xfail)],
)
def test_eval(test_input, expected):
    assert eval(test_input) == expected

fixture 方式

# content of test_fixture_marks.py
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

pytest_generate_tests

用来自定义参数化方案。使用到了 hook,hook 的知识我会写在《pytest hook》中,欢迎关注公众号 dongfanger 获取最新文章。

# content of conf.py


def pytest_generate_tests(metafunc):
    if "test_input" in metafunc.fixturenames:
        metafunc.parametrize("test_input", [0, 1])
# content of test.py


def test(test_input):
    assert test_input == 0
  • 定义在 conftest.py 文件中
  • metafunc 有 5 个属性,fixturenames,module,config,function,cls
  • metafunc.parametrize() 用来实现参数化
  • 多个 metafunc.parametrize() 的参数名不能重复,否则会报错

参数化误区

在讲示例之前,先简单分享我的菜鸡行为。假设我们现在需要对 50 个接口测试,验证某一角色的用户访问这些接口会返回 403。我的做法是,把接口请求全部参数化了,test 函数里面只有断言,伪代码大致如下

def api():
    params = []
    def func():
        return request()
    params.append(func)
    ...


@user9ize('req', api())
def test():
    res = req()
    assert res.status_code == 403

这样参数化以后,会产生50 个 tests,如果断言失败了,会单独标记为 failed,不影响其他 test 结果。咋一看还行,但是有个问题,在回归的时候,可能只需要验证其中部分接口,就没有办法灵活的调整,必须全部跑一遍才行。这是一个相对错误的示范,至于正确的应该怎么写,相信每个人心中都有一个答案,能解决问题就是 ok 的。我想表达的是,参数化要适当,不要滥用,最好只对测试数据做参数化

实践

本文的重点来了,参数化的语法比较简单,实际应用是关键。这部分通过 11 个例子,来实践一下。示例覆盖的知识点有点多,建议留大段时间细看。

1.使用 hook 添加命令行参数--all,"param1"是参数名,带--all 参数时是 range(5) == [0, 1, 2, 3, 4],生成 5 个 tests。不带参数时是 range(2)。

# content of test_compute.py


def test_compute(param1):
    assert param1 < 4

# content of conftest.py


def pytest_addoption(parser):
    parser.addoption("--all", action="store_true", help="run all combinations")
def pytest_generate_tests(metafunc):
    if "param1" in metafunc.fixturenames:
        if metafunc.config.getoption("all"):
            end = 5
        else:
            end = 2
        metafunc.parametrize("param1", range(end))

2.testdata 是测试数据,包括 2 组。test_timedistance_v0 不带 ids。test_timedistance_v1 带 list 格式的 ids。test_timedistance_v2 的 ids 为函数。test_timedistance_v3 使用 pytest.param 同时定义测试数据和 id。

# content of test_time.py
from datetime import datetime, timedelta

import pytest

testdata = [
    (datetime(2001, 12, 12), datetime(2001, 12, 11), timedelta(1)),
    (datetime(2001, 12, 11), datetime(2001, 12, 12), timedelta(-1)),
]


@user10ize("a,b,expected", testdata)
def test_timedistance_v0(a, b, expected):
    diff = a - b
    assert diff == expected


@user11ize("a,b,expected", testdata, ids=["forward", "backward"])
def test_timedistance_v1(a, b, expected):
    diff = a - b
    assert diff == expected


def idfn(val):
    if isinstance(val, (datetime,)):
        # note this wouldn't show any hours/minutes/seconds
        return val.strftime("%Y%m%d")


@user12ize("a,b,expected", testdata, ids=idfn)
def test_timedistance_v2(a, b, expected):
    diff = a - b
    assert diff == expected


@user13ize(
    "a,b,expected",
    [
        pytest.param(
            datetime(2001, 12, 12), datetime(2001, 12, 11), timedelta(1), id="forward"
        ),
        pytest.param(
            datetime(2001, 12, 11), datetime(2001, 12, 12), timedelta(-1), id="backward"
        ),
    ],
)
def test_timedistance_v3(a, b, expected):
    diff = a - b
    assert diff == expected

3.兼容 unittest 的 testscenarios

# content of test_scenarios.py
def pytest_generate_tests(metafunc):
    idlist = []
    argvalues = []
    for scenario in metafunc.cls.scenarios:
        idlist.append(scenario[0])
        items = scenario[1].items()
        argnames = [x[0] for x in items]
        argvalues.append([x[1] for x in items])
    metafunc.parametrize(argnames, argvalues, ids=idlist, scope="class")


scenario1 = ("basic", {"attribute": "value"})
scenario2 = ("advanced", {"attribute": "value2"})


class TestSampleWithScenarios:
    scenarios = [scenario1, scenario2]

    def test_demo1(self, attribute):
        assert isinstance(attribute, str)

    def test_demo2(self, attribute):
        assert isinstance(attribute, str)

4.初始化数据库连接

# content of test_backends.py
import pytest


def test_db_initialized(db):
    # a dummy test
    if db.__class__.__name__ == "DB2":
        pytest.fail("deliberately failing for demo purposes")

# content of conftest.py
import pytest


def pytest_generate_tests(metafunc):
    if "db" in metafunc.fixturenames:
        metafunc.parametrize("db", ["d1", "d2"], indirect=True)


class DB1:
    "one database object"


class DB2:
    "alternative database object"


@pytest.fixture
def db(request):
    if request.param == "d1":
        return DB1()
    elif request.param == "d2":
        return DB2()
    else:
        raise ValueError("invalid internal test config")

5.如果不加 indirect=True,会生成 2 个 test,fixt 的值分别是"a"和"b"。如果加了 indirect=True,会先执行 fixture,fixt 的值分别是"aaa"和"bbb"。indirect=True 结合 fixture 可以在生成 test 前,对参数变量额外处理。

import pytest


@pytest.fixture
def fixt(request):
    return request.param * 3


@user16ize("fixt", ["a", "b"], indirect=True)
def test_indirect(fixt):
    assert len(fixt) == 3

6.多个参数时,indirect 赋值 list 可以指定某些变量应用 fixture,没有指定的保持原值。

# content of test_indirect_list.py
import pytest


@pytest.fixture(scope="function")
def x(request):
    return request.param * 3


@pytest.fixture(scope="function")
def y(request):
    return request.param * 2


@user19ize("x, y", [("a", "b")], indirect=["x"])
def test_indirect(x, y):
    assert x == "aaa"
    assert y == "b"

7.兼容 unittest 参数化

# content of ./test_parametrize.py
import pytest


def pytest_generate_tests(metafunc):
    # called once per each test function
    funcarglist = metafunc.cls.params[metafunc.function.__name__]
    argnames = sorted(funcarglist[0])
    metafunc.parametrize(
        argnames, [[funcargs[name] for name in argnames] for funcargs in funcarglist]
    )


class TestClass:
    # a map specifying multiple argument sets for a test method
    params = {
        "test_equals": [dict(a=1, b=2), dict(a=3, b=3)],
        "test_zerodivision": [dict(a=1, b=0)],
    }

    def test_equals(self, a, b):
        assert a == b

    def test_zerodivision(self, a, b):
        with pytest.raises(ZeroDivisionError):
            a / b

8.在不同 python 解释器之间测试对象序列化。python1 把对象 pickle-dump 到文件。python2 从文件中 pickle-load 对象。

"""
module containing a parametrized tests testing cross-python
serialization via the pickle module.
"""
import shutil
import subprocess
import textwrap

import pytest

pythonlist = ["python3.5", "python3.6", "python3.7"]


@pytest.fixture(params=pythonlist)
def python1(request, tmpdir):
    picklefile = tmpdir.join("data.pickle")
    return Python(request.param, picklefile)


@pytest.fixture(params=pythonlist)
def python2(request, python1):
    return Python(request.param, python1.picklefile)


class Python:
    def __init__(self, version, picklefile):
        self.pythonpath = shutil.which(version)
        if not self.pythonpath:
            pytest.skip("{!r} not found".format(version))
        self.picklefile = picklefile

    def dumps(self, obj):
        dumpfile = self.picklefile.dirpath("dump.py")
        dumpfile.write(
            textwrap.dedent(
                r"""
                import pickle
                f = open({!r}, 'wb')
                s = pickle.dump({!r}, f, protocol=2)
                f.close()
                """.format(
                    str(self.picklefile), obj
                )
            )
        )
        subprocess.check_call((self.pythonpath, str(dumpfile)))

    def load_and_is_true(self, expression):
        loadfile = self.picklefile.dirpath("load.py")
        loadfile.write(
            textwrap.dedent(
                r"""
                import pickle
                f = open({!r}, 'rb')
                obj = pickle.load(f)
                f.close()
                res = eval({!r})
                if not res:
                raise SystemExit(1)
                """.format(
                    str(self.picklefile), expression
                )
            )
        )
        print(loadfile)
        subprocess.check_call((self.pythonpath, str(loadfile)))


@user22ize("obj", [42, {}, {1: 3}])
def test_basic_objects(python1, python2, obj):
    python1.dumps(obj)
    python2.load_and_is_true("obj == {}".format(obj))

9.假设有个 API,basemod 是原始版本,optmod 是优化版本,验证二者结果一致。

# content of conftest.py
import pytest


@pytest.fixture(scope="session")
def basemod(request):
    return pytest.importorskip("base")


@pytest.fixture(scope="session", params=["opt1", "opt2"])
def optmod(request):
    return pytest.importorskip(request.param)

# content of base.py


def func1():
    return 1
# content of opt1.py


def func1():
    return 1.0001
# content of test_module.py
def test_func1(basemod, optmod):
    assert round(basemod.func1(), 3) == round(optmod.func1(), 3)

10.使用 pytest.param 添加 marker 和 id。

# content of test_pytest_param_example.py
import pytest


@user25ize(
    "test_input,expected",
    [
        ("3+5", 8),
        pytest.param("1+7", 8, marks=pytest.mark.basic),
        pytest.param("2+4", 6, marks=pytest.mark.basic, id="basic_2+4"),
        pytest.param(
            "6*9", 42, marks=[pytest.mark.basic, pytest.mark.xfail], id="basic_6*9"
        ),
    ],
)
def test_eval(test_input, expected):
    assert eval(test_input) == expected

11.使用 pytest.raises 让部分 test 抛出 Error。

from contextlib import contextmanager

import pytest


// 3.7+ from contextlib import nullcontext as does_not_raise
@contextmanager
def does_not_raise():
    yield


@user27ize(
    "example_input,expectation",
    [
        (3, does_not_raise()),
        (2, does_not_raise()),
        (1, does_not_raise()),
        (0, pytest.raises(ZeroDivisionError)),
    ],
)
def test_division(example_input, expectation):
    """Test how much I know division."""
    with expectation:
        assert (6 / example_input) is not None

简要回顾

本文先讲了参数化的语法,包括 marker,fixture,hook 方式,以及如何给参数添加 marker,然后重点列举了几个实战示例。参数化用好了能节省编码,达到事半功倍的效果。

参考资料

docs-pytest-org-en-stable

如果你觉得这篇文章写的还不错的话,关注公众号 “测试老树”,你的支持就是我写文章的最大动力。

共收到 4 条回复 时间 点赞
仅楼主可见
an footman 回复

谢谢

想请教下楼主,这个 request 是 pytest 的内置 fixture 么,为什么用 request.params 可以接收参数呢,一直没想明白

古一 回复

是的。
request 是 pytest 内置的 fixture。
The request fixture is a special fixture providing information of the requesting test function.
request.param,没有后缀 s,是 request 的属性,用来引用参数的。
has an optional param attribute in case the fixture is parametrized indirectly.

dongfanger Pytest 如何把浏览器参数化? 中提及了此贴 11月25日 03:37
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册