好得很程序员自学网

<tfoot draggable='sEl'></tfoot>

pytest封神之路第五步 参数化进阶

让pytest飞扬起来。

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

语法

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

@pytest.mark.parametrize

@pytest.mark.parametrize("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


@pytest.mark.parametrize("x",?[0,?1])
@pytest.mark.parametrize("y",?[2,?3])
def?test_foo(x,?y):
????pass

@pytest.fixture()

@pytest.fixture(scope="module",?params=["smtp.gmail测试数据",?"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


@pytest.mark.parametrize(
????"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)
????...


@pytest.mark.parametrize('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)),
]


@pytest.mark.parametrize("a,b,expected",?testdata)
def?test_timedistance_v0(a,?b,?expected):
????diff?=?a?-?b
????assert?diff?==?expected


@pytest.mark.parametrize("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")


@pytest.mark.parametrize("a,b,expected",?testdata,?ids=idfn)
def?test_timedistance_v2(a,?b,?expected):
????diff?=?a?-?b
????assert?diff?==?expected


@pytest.mark.parametrize(
????"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


@pytest.mark.parametrize("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


@pytest.mark.parametrize("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)))


@pytest.mark.parametrize("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


@pytest.mark.parametrize(
????"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


@pytest.mark.parametrize(
????"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

查看更多关于pytest封神之路第五步 参数化进阶的详细内容...

  阅读:34次