美文网首页test
自动化测试框架pytest教程2-测试函数

自动化测试框架pytest教程2-测试函数

作者: python测试开发 | 来源:发表于2023-01-13 10:52 被阅读0次

    简介

    在本章中,你将学习如何在测试Python包的情况下编写测试函数。如果你用 pytest 来测试 Python 包以外的东西,本章的大部分内容仍然适用。

    我们将为一个简单的任务跟踪命令行程序写测试,这个程序叫做 Cards。我们将研究如何在测试中使用 assert,测试如何处理意外的异常,以及如何测试预期的异常。

    最终,我们会有很多的测试。因此,我们将研究如何将测试组织成类、模块和目录。

    安装示例应用

    我们编写的测试代码需要能够运行应用程序的代码。应用程序代码 "是我们正在验证的代码,它有很多名字。你可能会听到它被称为生产代码、应用程序、被测代码(CUT:code under test )、被测系统(SUT:system under test)、被测设备(DUT:device under test),我们将使用 "应用代码 "这个术语。

    测试代码 "是我们为了测试应用代码而编写的代码。具有讽刺意味的是,"测试代码 "是相当明确的,除了 "测试代码 "之外,没有太多的名字。

    在我们的例子中,Cards 项目就是应用程序代码。它是一个可安装的 Python 包,我们需要安装它以测试它。安装它也将允许我们在命令行上玩弄 Cards 项目。如果你要测试的代码不是可以安装的 Python 包,你就必须用其他方法让你的测试看到你的代码。(一些替代方法在第 12 章 测试脚本和应用程序中讨论。)

    • 安装
    $ pip install cards_proj/
    Processing d:\code\pytest_quick\cards_proj
      Installing build dependencies: started
      Installing build dependencies: finished with status 'done'
      Getting requirements to build wheel: started
      Getting requirements to build wheel: finished with status 'done'
      Preparing metadata (pyproject.toml): started
      Preparing metadata (pyproject.toml): finished with status 'done'
    Collecting typer==0.3.2
      Downloading typer-0.3.2-py3-none-any.whl (21 kB)
    Collecting tinydb==4.5.1
      Downloading tinydb-4.5.1-py3-none-any.whl (23 kB)
    Collecting rich==10.7.0
      Downloading rich-10.7.0-py3-none-any.whl (209 kB)
         ------------------------------------ 209.6/209.6 kB 706.8 kB/s eta 0:00:00
    Requirement already satisfied: colorama<0.5.0,>=0.4.0 in d:\programdata\anaconda3\lib\site-packages (from rich==10.7.0->cards==1.0.0) (0.4.5)
    Collecting commonmark<0.10.0,>=0.9.0
      Downloading commonmark-0.9.1-py2.py3-none-any.whl (51 kB)
         ---------------------------------------- 51.1/51.1 kB 1.3 MB/s eta 0:00:00
    Requirement already satisfied: pygments<3.0.0,>=2.6.0 in d:\programdata\anaconda3\lib\site-packages (from rich==10.7.0->cards==1.0.0) (2.11.2)
    Collecting click<7.2.0,>=7.1.1
      Downloading click-7.1.2-py2.py3-none-any.whl (82 kB)
         ---------------------------------------- 82.8/82.8 kB 1.5 MB/s eta 0:00:00
    Building wheels for collected packages: cards
      Building wheel for cards (pyproject.toml): started
      Building wheel for cards (pyproject.toml): finished with status 'done'
      Created wheel for cards: filename=cards-1.0.0-py3-none-any.whl size=5011 sha256=61c1601a2053682eeccbafcf120c2606604175b8730f7681ca5a0a521b83c6c5
      Stored in directory: D:\Temp\pip-ephem-wheel-cache-5y22fhi5\wheels\0a\19\39\ed9dee4c4704cf05c72119386093ac231b9fe1c12faf5da0cc
    Successfully built cards
    Installing collected packages: commonmark, tinydb, rich, click, typer, cards
      Attempting uninstall: click
        Found existing installation: click 8.0.4
        Uninstalling click-8.0.4:
          Successfully uninstalled click-8.0.4
    ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
    black 22.6.0 requires click>=8.0.0, but you have click 7.1.2 which is incompatible.
    Successfully installed cards-1.0.0 click-7.1.2 commonmark-0.9.1 rich-10.7.0 tinydb-4.5.1 typer-0.3.2
    
    • 演示
    
    $cards add "联系作者:钉钉、抖音或微信pythontesting" --owner Andrew
    
    $cards
    
      ID   state   owner    summary
     ───────────────────────────────────────────────────────────────
      1    todo    Andrew   联系作者:钉钉、抖音或微信pythontesting
    
    
    
    $cards add "抢火车票" --owner Bob
    
    $cards
    
      ID   state   owner    summary
     ───────────────────────────────────────────────────────────────
      1    todo    Andrew   联系作者:钉钉、抖音或微信pythontesting
      2    todo    Bob      抢火车票
    
    
    
    $cards update 2 --owner Ted
    
    $cards
    
      ID   state   owner    summary
     ───────────────────────────────────────────────────────────────
      1    todo    Andrew   联系作者:钉钉、抖音或微信pythontesting
      2    todo    Ted      抢火车票
    
    
    
    $cards start 1
    
    $cards finish 1
    
    $cards
    
      ID   state   owner    summary
     ───────────────────────────────────────────────────────────────
      1    done    Andrew   联系作者:钉钉、抖音或微信pythontesting
      2    todo    Ted      抢火车票
    
    
    
    $cards add "testing" --owner Bob
    
    $cards
    
      ID   state   owner    summary
     ───────────────────────────────────────────────────────────────
      1    done    Andrew   联系作者:钉钉、抖音或微信pythontesting
      2    todo    Ted      抢火车票
      3    todo    Bob      testing
    
    
    
    $cards delete 3
    
    $cards
    
      ID   state   owner    summary
     ───────────────────────────────────────────────────────────────
      1    done    Andrew   联系作者:钉钉、抖音或微信pythontesting
      2    todo    Ted      抢火车票
    

    cards可以用添加、更新、开始、结束和删除等动作来操作,而运行cards会列出卡片。

    很好。现在我们准备写一些测试了。

    了解被测系统

    Cards的源代码被分成三层。CLI、 API, 和 DB. CLI处理与用户的交互。CLI调用API,它处理应用程序的大部分逻辑。API调用DB层(数据库),用于保存和检索应用数据。我们将在《软件架构》中更多地了解卡片的结构。

    有一个数据结构用来在ClI和API之间传递信息,这个数据类叫做Card。

    @dataclass
    class Card:
        summary: str = None
        owner: str = None
        state: str = "todo"
        id: int = field(default=None, compare=False)
    
        @classmethod
        def from_dict(cls, d):
            return Card(**d)
        def to_dict(self):
            return asdict(self)
    

    Data class在 3.7 版本中被添加到 Python 中,但它们对一些人来说可能仍然很陌生。卡片结构有三个字符串字段:摘要、所有者和状态,以及一个整数字段:ID。摘要、所有者和id字段默认为无。状态字段默认为 "todo"。id字段也使用字段方法来利用compare=False,这应该是告诉代码,当比较两个Card对象是否相等时,不要使用id字段。我们一定会测试这一点,以及其他方面。为了方便和清晰起见,还增加了几个方法:from_dict和to_dict,因为Card(**d)或dataclasses.asdict()不是很容易读。

    当面对新的数据结构时,快速测试往往是很有帮助的,这样你就可以了解数据结构是如何工作的。所以,让我们从一些测试开始,验证我们对这个东西应该如何工作的理解。

    ch2/test_card.py

    from cards import Card
    
    
    def test_field_access():
        c = Card("something", "brian", "todo", 123)
        assert c.summary == "something"
        assert c.owner == "brian"
        assert c.state == "todo"
        assert c.id == 123
    
    
    def test_defaults():
        c = Card()
        assert c.summary is None
        assert c.owner is None
        assert c.state == "todo"
        assert c.id is None
    
    
    def test_equality():
        c1 = Card("something", "brian", "todo", 123)
        c2 = Card("something", "brian", "todo", 123)
        assert c1 == c2
    
    
    def test_equality_with_diff_ids():
        c1 = Card("something", "brian", "todo", 123)
        c2 = Card("something", "brian", "todo", 4567)
        assert c1 == c2
    def test_inequality():
        c1 = Card("something", "brian", "todo", 123)
        c2 = Card("completely different", "okken", "done", 123)
        assert c1 != c2
    
    
    def test_from_dict():
        c1 = Card("something", "brian", "todo", 123)
        c2_dict = {
            "summary": "something",
            "owner": "brian",
            "state": "todo",
            "id": 123,
        }
        c2 = Card.from_dict(c2_dict)
        assert c1 == c2
    
    
    def test_to_dict():
        c1 = Card("something", "brian", "todo", 123)
        c2 = c1.to_dict()
        c2_expected = {
            "summary": "something",
            "owner": "brian",
            "state": "todo",
            "id": 123,
        }
        assert c2 == c2_expected
    
    
    • 执行
    $ pytest test_card.py
    ============================= test session starts =============================
    platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
    rootdir: D:\code\pytest_quick, configfile: pytest.ini
    plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
    collected 7 items
    
    test_card.py .......                                                     [100%]
    
    ============================== 7 passed in 0.14s ==============================
    
    

    这些测试的重点是检查对架构的理解,并可能为其他人甚至为未来的我记录这些知识。这种检查我自己的理解的用法,以及真正把测试当作玩弄应用程序代码的小游乐场,是超级强大的,我认为如果更多的人从这种心态开始,他们会更喜欢测试。

    还要注意的是,所有这些测试都使用普通的断言语句。

    断言assert

    当你写测试函数时,普通的Python assert语句是你沟通测试失败的主要工具。这在pytest中的简单性是非常好的。这也是促使很多开发者使用pytest而不是其他框架的原因。

    如果你使用过任何其他测试框架,你可能已经看到了各种断言辅助函数。例如,下面是unittest的一些断言形式和断言辅助函数的列表。

    在pytest中,你可以对任何表达式使用assert <expression>。如果该表达式转换为bool的话,会评估为False,测试会失败。

    pytest包括 "断言重写 "的功能,它可以拦截断言调用,并将其替换为可以告诉你更多关于断言失败原因的内容。让我们通过查看一个断言的失败来看看这种重写有多大帮助。

    ch2/test_card_fail.py

    def test_equality_fail():
        c1 = Card("sit there", "brian")
        c2 = Card("do something", "okken")
        assert c1 == c2
    

    这个测试会失败,但有趣的是追踪信息。

    $ pytest test_card_fail.py
    ============================= test session starts =============================
    platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
    rootdir: D:\code\pytest_quick, configfile: pytest.ini
    plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
    collected 1 item
    
    test_card_fail.py F                                                      [100%]
    
    ================================== FAILURES ===================================
    _____________________________ test_equality_fail ______________________________
    
        def test_equality_fail():
            c1 = Card("sit there", "brian")
            c2 = Card("do something", "okken")
    >       assert c1 == c2
    E       AssertionError: assert Card(summary=...odo', id=None) == Card(summary=...odo', id=None)
    E
    E         Omitting 1 identical items, use -vv to show
    E         Differing attributes:
    E         ['summary', 'owner']
    E
    E         Drill down into differing attribute summary:
    E           summary: 'sit there' != 'do something'...
    E
    E         ...Full output truncated (8 lines hidden), use '-vv' to show
    
    test_card_fail.py:7: AssertionError
    =========================== short test summary info ===========================
    FAILED test_card_fail.py::test_equality_fail - AssertionError: assert Card(su...
    ============================== 1 failed in 0.22s ==============================
    
    

    这是很大的信息量。对于每个失败的测试,失败的确切行被显示出来,并有一个 > 指向失败。E行显示了关于断言失败的额外信息,以帮助你找出出错的原因。

    我故意在test_equality_fail()中放了两个不匹配,但在前面的代码中只显示了第一个。让我们按照错误信息中的建议,用-vv标志再试一下。

    $ pytest -vv test_card_fail.py
    ============================= test session starts =============================
    platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0 -- D:\ProgramData\Anaconda3\python.exe
    cachedir: .pytest_cache
    rootdir: D:\code\pytest_quick, configfile: pytest.ini
    plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
    collecting ... collected 1 item
    
    test_card_fail.py::test_equality_fail FAILED                             [100%]
    
    ================================== FAILURES ===================================
    _____________________________ test_equality_fail ______________________________
    
        def test_equality_fail():
            c1 = Card("sit there", "brian")
            c2 = Card("do something", "okken")
    >       assert c1 == c2
    E       AssertionError: assert Card(summary='sit there', owner='brian', state='todo', id=None) == Card(summary='do something', owner='okken', state='todo', id=None)
    E
    E         Matching attributes:
    E         ['state']
    E         Differing attributes:
    E         ['summary', 'owner']
    E
    E         Drill down into differing attribute summary:
    E           summary: 'sit there' != 'do something'
    E           - do something
    E           + sit there
    E
    E         Drill down into differing attribute owner:
    E           owner: 'brian' != 'okken'
    E           - okken
    E           + brian
    
    test_card_fail.py:7: AssertionError
    =========================== short test summary info ===========================
    FAILED test_card_fail.py::test_equality_fail - AssertionError: assert Card(su...
    ============================== 1 failed in 0.21s ==============================
    

    pytest明确列出了哪些属性是匹配的,哪些是不匹配的,并强调了确切的不匹配。

    在pytest.org网站上可以找到更多种类的断言语句,并有很好的跟踪调试信息。

    断言失败是测试代码导致测试失败的主要方式。然而,这并不是唯一的方式。

    通过pytest.fail()和异常失败用例

    如果有任何未捕获的异常,测试就会失败。这可能发生在

    • 断言语句失败,这将引发AssertionError异常。
    • 测试代码调用pytest.fail(),这将引发一个异常,或任何其他异常被引发。

    虽然任何异常都可以使测试失败,但我更喜欢使用assert。在极少数情况下,如果assert不合适,可以使用pytest.fail()。

    下面是使用pytest的fail()函数来明确地使测试失败的例子。

    ch2/test_alt_fail.py

    $ cat test_alt_fail.py
    import pytest
    from cards import Card
    
    
    def test_with_fail():
        c1 = Card("sit there", "brian")
        c2 = Card("do something", "okken")
        if c1 != c2:
            pytest.fail("they don't match")
    
    
    • 执行
    $ pytest test_alt_fail.py
    ============================= test session starts =============================
    platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
    rootdir: D:\code\pytest_quick, configfile: pytest.ini
    plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
    collected 1 item
    
    test_alt_fail.py F                                                       [100%]
    
    ================================== FAILURES ===================================
    _______________________________ test_with_fail ________________________________
    
        def test_with_fail():
            c1 = Card("sit there", "brian")
            c2 = Card("do something", "okken")
            if c1 != c2:
    >           pytest.fail("they don't match")
    E           Failed: they don't match
    
    test_alt_fail.py:9: Failed
    =========================== short test summary info ===========================
    FAILED test_alt_fail.py::test_with_fail - Failed: they don't match
    ============================== 1 failed in 0.21s ==============================
    
    

    当调用pytest.fail()或直接引发异常时,我们不会得到pytest提供的奇妙的断言重写。然而,也有合理的时候使用pytest.fail(),比如在断言帮助器中。

    编写断言助手函数

    断言助手是用来包装复杂的断言检查的函数。举个例子,Cards数据类的设置是这样的:两张ID不同的卡片仍然会报告相等。如果我们想有更严格的检查,我们可以写一个叫assert_identical的辅助函数,像这样。

    from cards import Card
    import pytest
    
    
    def assert_identical(c1: Card, c2: Card):
        __tracebackhide__ = True
        assert c1 == c2
        if c1.id != c2.id:
            pytest.fail(f"id's don't match. {c1.id} != {c2.id}")
    
    
    def test_identical():
        c1 = Card("foo", id=123)
        c2 = Card("foo", id=123)
        assert_identical(c1, c2)
    
    
    def test_identical_fail():
        c1 = Card("foo", id=123)
        c2 = Card("foo", id=456)
        assert_identical(c1, c2)
    

    assert_identical函数设置tracebackhide = True。这是可选的。其效果是,失败的测试不会在回溯中包括这个函数。然后,正常的 assert c1 == c2 被用来检查除 ID 之外的所有内容是否相等。

    最后,检查ID,如果它们不相等,就用pytest.fail()来拒绝测试,并希望有一个有用的信息。

    让我们看看运行时是什么样子的。

    $ pytest test_helper.py
    ============================= test session starts =============================
    platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
    rootdir: D:\code\pytest_quick, configfile: pytest.ini
    plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
    collected 2 items
    
    test_helper.py .F                                                        [100%]
    
    ================================== FAILURES ===================================
    _____________________________ test_identical_fail _____________________________
    
        def test_identical_fail():
            c1 = Card("foo", id=123)
            c2 = Card("foo", id=456)
    >       assert_identical(c1, c2)
    E       Failed: id's don't match. 123 != 456
    
    test_helper.py:21: Failed
    =========================== short test summary info ===========================
    FAILED test_helper.py::test_identical_fail - Failed: id's don't match. 123 !=...
    ========================= 1 failed, 1 passed in 0.21s =========================
    

    如果我们没有加入 tracebackhide = True,assert_identical 代码就会被包含在跟踪回溯中,在这种情况下,不会增加任何清晰度。我也可以使用 assert c1.id == c2.id, "id's don't match." 达到同样的效果,但我想展示一个使用 pytest.fail() 的例子。

    注意,断言重写只适用于conftest.py文件和测试文件。

    测试预期的异常

    我们已经研究了任何异常如何导致测试失败。但是如果你正在测试的一段代码应该引发一个异常呢?你如何测试呢?

    你可以使用 pytest.raises() 来测试预期的异常。

    卡片API有CardsDB类,需要一个路径参数。如果我们不传入路径会发生什么?让我们来试试。

    import cards
    
    
    def test_no_path_fail():
        cards.CardsDB()
    
    • 执行
    $ pytest --tb=short test_experiment.py
    ============================= test session starts =============================
    platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
    rootdir: D:\code\pytest_quick, configfile: pytest.ini
    plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
    collected 1 item
    
    test_experiment.py F                                                     [100%]
    
    ================================== FAILURES ===================================
    ______________________________ test_no_path_fail ______________________________
    test_experiment.py:5: in test_no_path_fail
        cards.CardsDB()
    E   TypeError: __init__() missing 1 required positional argument: 'db_path'
    =========================== short test summary info ===========================
    FAILED test_experiment.py::test_no_path_fail - TypeError: __init__() missing ...
    ============================== 1 failed in 0.20s ==============================
    

    这里我使用了 --tb=short 较短的回溯格式,因为我们不需要看到完整的回溯来发现哪个异常被引发。

    TypeError异常是合理的,因为错误发生在试图初始化自定义的CardsDB类型时。我们可以写一个测试来确保这个异常被抛出,像这样。

    import pytest
    import cards
    
    
    def test_no_path_raises():
        with pytest.raises(TypeError):
            cards.CardsDB()
    
    
    def test_raises_with_info():
        match_regex = "missing 1 .* positional argument"
        with pytest.raises(TypeError, match=match_regex):
            cards.CardsDB()
    
    
    def test_raises_with_info_alt():
        with pytest.raises(TypeError) as exc_info:
            cards.CardsDB()
        expected = "missing 1 required positional argument"
        assert expected in str(exc_info.value)
    
    
    • 执行
    $ pytest test_exceptions.py
    ============================= test session starts =============================
    platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
    rootdir: D:\code\pytest_quick, configfile: pytest.ini
    plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
    collected 3 items
    
    test_exceptions.py ...                                                   [100%]
    
    ============================== 3 passed in 0.15s ==============================
    

    with pytest.raises(TypeError): 的下一个代码块中的任何内容都应该引发一个TypeError异常。如果没有产生异常,测试就会失败。如果测试引发了一个不同的异常,则失败。

    我们刚刚在test_no_path_raises()中检查了异常的类型。我们还可以检查以确保消息是正确的,或者异常的任何其他方面,比如额外的参数。

    匹配参数需要一个正则表达式并与异常信息相匹配。如果是一个自定义的异常,你也可以使用 exc_info 或任何其他的变量名来询问异常的额外参数。exc_info对象将是ExceptionInfo类型的。关于ExceptionInfo的完整参考,请参见pytest文档。

    相关文章

      网友评论

        本文标题:自动化测试框架pytest教程2-测试函数

        本文链接:https://www.haomeiwen.com/subject/upzicdtx.html