【pytest系列】- parametrize引數化

miki_peng發表於2021-05-07

​ 前面已經提到,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

相關文章