­

【pytest系列】- parametrize參數化

​ 前面已經提到,pytest和unittest是兼容的,但是它也有不兼容的地方,比如ddt數據驅動,測試夾具fixtures(即setup、teardown)這些功能在pytest中都不能使用了,因為pytest已經不再繼承unittest了。

​ 不使用ddt數據驅動那pytest是如何實現參數化的呢?答案就是mark里自帶的一個參數化標籤。

源碼解讀

​ 關鍵代碼:@pytest.mark.parametrize

​ 我們先看下源碼:def parametrize(self,argnames, argvalues, indirect=False, ids=None, scope=None): ,按住ctrl然後點擊對應的函數名就可查看源碼。

    def parametrize(self,argnames, argvalues, indirect=False, ids=None, scope=None):
        """ Add new invocations to the underlying test function using the list
        of argvalues for the given argnames.  Parametrization is performed
        during the collection phase.  If you need to setup expensive resources
        see about setting indirect to do it rather at test setup time.

        :arg argnames: a comma-separated string denoting one or more argument
                       names, or a list/tuple of argument strings.

        :arg argvalues: The list of argvalues determines how often a
            test is invoked with different argument values.  If only one
            argname was specified argvalues is a list of values.  If N
            argnames were specified, argvalues must be a list of N-tuples,
            where each tuple-element specifies a value for its respective
            argname.

        :arg indirect: The list of argnames or boolean. A list of arguments'
            names (self,subset of argnames). If True the list contains all names from
            the argnames. Each argvalue corresponding to an argname in this list will
            be passed as request.param to its respective argname fixture
            function so that it can perform more expensive setups during the
            setup phase of a test rather than at collection time.

        :arg ids: list of string ids, or a callable.
            If strings, each is corresponding to the argvalues so that they are
            part of the test id. If None is given as id of specific test, the
            automatically generated id for that argument will be used.
            If callable, it should take one argument (self,a single argvalue) and return
            a string or return None. If None, the automatically generated id for that
            argument will be used.
            If no ids are provided they will be generated automatically from
            the argvalues.

        :arg scope: if specified it denotes the scope of the parameters.
            The scope is used for grouping tests by parameter instances.
            It will also override any fixture-function defined scope, allowing
            to set a dynamic scope using test context or configuration.
        """

​ 我們來看下主要的四個參數:

🍊參數1-argnames:一個或多個參數名,用逗號分隔的字符串,如”arg1,arg2,arg3″,或參數字符串的列表/元組。需要注意的是,參數名需要與用例的入參一致

🍊參數2-argvalues:參數值,必須是列表類型;如果有多個參數,則用元組存放值,一個元組存放一組參數值,元組放在列表。(實際上元組包含列表、列表包含列表也是可以的,可以動手試一下)

# 只有一個參數username時,列表裡都是這個參數的值:
@pytest.mark.parametrize("username", ["user1", "user2", "user3"])
# 有多個參數username、pwd,用元組存放參數值,一個元組對應一組參數:
@pytest.mark.parametrize("username, pwd", [("user1", "pwd1"), ("user2", "pwd2"), ("user3", "pwd3")])

🍊參數3-indirect:默認為False,設置為Ture時會把傳進來的參數(argnames)當函數執行。後面會進行詳解。

🍊參數4-ids:用例的ID,傳字符串列表,它可以標識每一個測試用例,自定義測試數據結果顯示,增加可讀性;需要注意的是ids的長度需要與測試用例的數量一致。

單個參數化

​ 下面我們來看下常用的參數化:

import pytest


data = [(1, 2, 3), (4, 5, 9)]


@pytest.mark.parametrize('a, b, expect', data)
def test_param(a, b, expect):
    print('\n測試數據:{}+{}'.format(a, b))
    assert a+b == expect

​ 運行結果:

Testing started at 14:10 ...

============================= test session starts =============================
platform win32 -- Python 3.8.1, pytest-5.4.3, py-1.9.0, pluggy-0.13.1 -- C:\software\python\python.exe
cachedir: .pytest_cache
rootdir: D:\myworkspace\test, inifile: pytest.ini
collecting ... test.py::test_param[1-2-3]
test.py::test_param[4-5-9]
collected 2 items

test.py::test_param[1-2-3] PASSED                                        [ 50%]
測試數據:1+2

test.py::test_param[4-5-9] PASSED                                        [100%]
測試數據:4+5


============================== 2 passed in 0.02s ==============================

Process finished with exit code 0

​ 如上用例參數化後,一條測試數據就會執行一遍用例。

​ 再看下列表包含字典的:

import pytest


def login(user, pwd):
    """登錄功"""
    if user == "admin" and pwd == "admin123":
        return {"code": 0, "msg": "登錄成功!"}
    else:
        return {"code": 1, "msg": "登陸失敗,賬號或密碼錯誤!"}


# 測試數據
test_datas = [{"user": "admin", "pwd": "admin123", "expected": "登錄成功!"},
              {"user": "", "pwd": "admin123", "expected": "登陸失敗,賬號或密碼錯誤!"},
              {"user": "admin", "pwd": "", "expected": "登陸失敗,賬號或密碼錯誤!"}
              ]


@pytest.mark.parametrize("test_data", test_datas)
def test_login(test_data):
    # 測試用例
    res = login(test_data["user"], test_data["pwd"])
    # 斷言
    print(111)
    assert res["msg"] == test_data["expected"]

​ 運行結果:

Testing started at 14:13 ...

============================= test session starts =============================
platform win32 -- Python 3.8.1, pytest-5.4.3, py-1.9.0, pluggy-0.13.1 -- C:\software\python\python.exe
cachedir: .pytest_cache
rootdir: D:\myworkspace\test, inifile: pytest.ini
collecting ... test.py::test_login[test_data0]
test.py::test_login[test_data1]
test.py::test_login[test_data2]
collected 3 items

test.py::test_login[test_data0] PASSED                                   [ 33%]111

test.py::test_login[test_data1] PASSED                                   [ 66%]111

test.py::test_login[test_data2] PASSED                                   [100%]111


============================== 3 passed in 0.02s ==============================

Process finished with exit code 0

多個參數化

​ 一個函數或一個類都可以使用多個參數化裝飾器,「笛卡爾積」原理。最終生成的用例是n1*n2*n3…條,如下例子,參數一的值有2個,參數二的值有3個,那麼最後生成的用例就是2*3條。

import pytest


data1 = [1, 2]
data2 = ['a', 'b', 'c']


@pytest.mark.parametrize('test1', data1)
@pytest.mark.parametrize('test2', data2)
def test_param(test1, test2):
    print('\n測試數據:{}-{}'.format(test1, test2))

​ 運行結果:

Testing started at 14:15 ...

============================= test session starts =============================
platform win32 -- Python 3.8.1, pytest-5.4.3, py-1.9.0, pluggy-0.13.1 -- C:\software\python\python.exe
cachedir: .pytest_cache
rootdir: D:\myworkspace\test, inifile: pytest.ini
collecting ... test.py::test_param[a-1]
test.py::test_param[a-2]
test.py::test_param[b-1]
test.py::test_param[b-2]
test.py::test_param[c-1]
test.py::test_param[c-2]
collected 6 items

test.py::test_param[a-1] PASSED                                          [ 16%]
測試數據:1-a

test.py::test_param[a-2] PASSED                                          [ 33%]
測試數據:2-a

test.py::test_param[b-1] PASSED                                          [ 50%]
測試數據:1-b

test.py::test_param[b-2] PASSED                                          [ 66%]
測試數據:2-b

test.py::test_param[c-1] PASSED                                          [ 83%]
測試數據:1-c

test.py::test_param[c-2] PASSED                                          [100%]
測試數據:2-c


============================== 6 passed in 0.03s ==============================

Process finished with exit code 0

​ 從上面的例子來看,@pytest.mark.parametrize()其實跟ddt的用法很相似的,多用就好了。

標記數據

​ 在參數化中,也可以標記數據進行斷言、跳過等

# 標記參數化
@pytest.mark.parametrize("test_input,expected", [
    ("3+5", 8), ("2+4", 6),
    pytest.param("6 * 9", 42, marks=pytest.mark.xfail),
    pytest.param("6 * 6", 42, marks=pytest.mark.skip)
])
def test_mark(test_input, expected):
    assert eval(test_input) == expected

​ 運行結果,可以看到2個通過,1個斷言失敗的,1個跳過的。

Testing started at 14:17 ...

============================= test session starts =============================
platform win32 -- Python 3.8.1, pytest-5.4.3, py-1.9.0, pluggy-0.13.1 -- C:\software\python\python.exe
cachedir: .pytest_cache
rootdir: D:\myworkspace\test, inifile: pytest.ini
collecting ... test.py::test_mark[3+5-8]
test.py::test_mark[2+4-6]
test.py::test_mark[6 * 9-42]
test.py::test_mark[6 * 6-42]
collected 4 items

test.py::test_mark[3+5-8] 
test.py::test_mark[2+4-6] 
test.py::test_mark[6 * 9-42] 
test.py::test_mark[6 * 6-42] 

=================== 2 passed, 1 skipped, 1 xfailed in 0.14s ===================

Process finished with exit code 0
PASSED                                         [ 25%]PASSED                                         [ 50%]XFAIL                                       [ 75%]
test_input = '6 * 9', expected = 42

    @pytest.mark.parametrize("test_input,expected", [
        ("3+5", 8), ("2+4", 6),
        pytest.param("6 * 9", 42, marks=pytest.mark.xfail),
        pytest.param("6 * 6", 42, marks=pytest.mark.skip)
    ])
    def test_mark(test_input, expected):
>       assert eval(test_input) == expected
E       AssertionError

test.py:89: AssertionError
SKIPPED                                     [100%]
Skipped: unconditional skip

用例ID

​ 前面源碼分析說到ids可以標識每一個測試用例;有多少組數據,就要有多少個id,然後組成一個id的列表;現在來看下實例。

import pytest


def login(user, pwd):
    """登錄功"""
    if user == "admin" and pwd == "admin123":
        return {"code": 0, "msg": "登錄成功!"}
    else:
        return {"code": 1, "msg": "登陸失敗,賬號或密碼錯誤!"}


# 測試數據
test_datas = [{"user": "admin", "pwd": "admin123", "expected": "登錄成功!"},
             {"user": "", "pwd": "admin123", "expected": "登陸失敗,賬號或密碼錯誤!"},
             {"user": "admin", "pwd": "", "expected": "登陸失敗,賬號或密碼錯誤!"}
             ]


@pytest.mark.parametrize("test_data", test_datas, ids=["輸入正確賬號、密碼,登錄成功",
                                                      "賬號為空,密碼正確,登錄失敗",
                                                      "賬號正確,密碼為空,登錄失敗",
                                                      ])
def test_login(test_data):
    # 測試用例
    res = login(test_data["user"], test_data["pwd"])
    # 斷言
    print(111)
    assert res["msg"] == test_data["expected"]

​ 運行結果:

Testing started at 10:34 ...

============================= test session starts =============================
platform win32 -- Python 3.8.1, pytest-5.4.3, py-1.9.0, pluggy-0.13.1 -- C:\software\python\python.exe
cachedir: .pytest_cache
rootdir: D:\myworkspace\test, inifile: pytest.ini
collecting ... collected 3 items

test.py::test_login[\u8f93\u5165\u6b63\u786e\u8d26\u53f7\u3001\u5bc6\u7801\uff0c\u767b\u5f55\u6210\u529f] PASSED [ 33%]111

test.py::test_login[\u8d26\u53f7\u4e3a\u7a7a\uff0c\u5bc6\u7801\u6b63\u786e\uff0c\u767b\u5f55\u5931\u8d25] PASSED [ 66%]111

test.py::test_login[\u8d26\u53f7\u6b63\u786e\uff0c\u5bc6\u7801\u4e3a\u7a7a\uff0c\u767b\u5f55\u5931\u8d25] PASSED [100%]111


============================== 3 passed in 0.02s ==============================

Process finished with exit code 0

注意: [\u8f93\u5165\u6b63 …] 這些並不是亂碼,是unicode 編碼,因為我們輸入的是中文,指定一下編碼就可以。在項目的根目錄的 conftest.py 文件,加以下代碼:

def pytest_collection_modifyitems(items):
    """
    測試用例收集完成時,將收集到的item的name和nodeid的中文顯示在控制台上
    :return:
    """
    for item in items:
        item.name = item.name.encode("utf-8").decode("unicode_escape")
        print(item.nodeid)
        item._nodeid = item.nodeid.encode("utf-8").decode("unicode_escape")

​ 再運行一遍就可以了。

Testing started at 10:38 ...

============================= test session starts =============================
platform win32 -- Python 3.8.1, pytest-5.4.3, py-1.9.0, pluggy-0.13.1 -- C:\software\python\python.exe
cachedir: .pytest_cache
rootdir: D:\myworkspace\test, inifile: pytest.ini
collecting ... test.py::test_login[\u8f93\u5165\u6b63\u786e\u8d26\u53f7\u3001\u5bc6\u7801\uff0c\u767b\u5f55\u6210\u529f]
test.py::test_login[\u8d26\u53f7\u4e3a\u7a7a\uff0c\u5bc6\u7801\u6b63\u786e\uff0c\u767b\u5f55\u5931\u8d25]
test.py::test_login[\u8d26\u53f7\u6b63\u786e\uff0c\u5bc6\u7801\u4e3a\u7a7a\uff0c\u767b\u5f55\u5931\u8d25]
collected 3 items

test.py::test_login[輸入正確賬號、密碼,登錄成功] PASSED                 [ 33%]111

test.py::test_login[賬號為空,密碼正確,登錄失敗] PASSED                 [ 66%]111

test.py::test_login[賬號正確,密碼為空,登錄失敗] PASSED                 [100%]111


============================== 3 passed in 0.02s ==============================

Process finished with exit code 0