大家都知道,目前最流行的Python单元测试框架有三种,分别是unittest, nose和pytest。其中unittest是Python自带的测试框架,但问题是比较老了,赶不上时代发展了(哈哈哈);nose2定位是带插件的unittest,实则对unittest的扩展。长远来看,pytest属于潜力股。
通过官网介绍的特点和使用经验,可以将pytest优点总结如下:
1)支持用简单的assert语句实现丰富的断言,无需复杂的self.assert*函数
2)可以自动识别测试模块和测试函数
3)兼容unittest和nose测试集
4)支持参数化
5)支持测试用例的skip和xfail处理
6)可以很好的和jenkins集成
7)支持丰富的插件,例如报告插件pytest-html、allure-pytest、失败重试插件pytest-rerunfailures、
8)活跃的社区,遇到的问题可以高效解决。
简化样板代码
大多数功能测试遵循 Arrange-Act-Assert 模型:
设置测试前置条件
调用函数来执行测试
断言执行结果
测试框架通常会挂接到测试的断言中,以便它们可以在断言失败时提供信息。unittest,例如,提供了许多开箱即用的断言方法,但是不友好的地方是,unittest编写的用例即使是一小部分测试也需要相当数量的样板代码。
下面写一个unittest测试用例并断言在项目中正常工作。
test_with_unittest.py
from unittest import TestCase
class TryTesting(TestCase):
def test_always_passes(self):
self.assertTrue(True)
def test_always_fails(self):
self.assertTrue(False)
然后,你可以使用命令行运行这些测试:
(venv) $ python -m unittest discover
F.
======================================================================
FAIL: test_always_fails (test_with_unittest.TryTesting)
Traceback (most recent call last):
File "...\effective-python-testing-with-pytest\test_with_unittest.py",
line 10, in test_always_fails
self.assertTrue(False)
AssertionError: False is not true
Ran 2 tests in 0.006s
FAILED (failures=1)
一个测试通过,一个测试失败。OK,下面我们总结一下一个完整的测试需要写多少代码:
导入类unittest引入TestCase
创建TryTesting,一个子类TestCase
TryTesting为每个测试写一个方法
使用self.assert*方法进行断言
这是要编写的大量代码的,所以在开发测试用例时会一遍又一遍地编写相同的样板代码。而pytest则不同,它允许你直接使用普通函数和python assert关键字来简化样板代码达到相同的目的:
test_with_pytest.py
def test_always_passes():
assert True
def test_always_fails():
assert False
就是如此简单。你需要做的就是写一个带有test_前缀的函数,使用assert关键字断言期望是True/False,然后执行测试即可。
Pytest不仅消除了很多样板代码,而且还提供了更加详细和易于阅读的输出。
更好的测试输出
在项目根目录文件夹下使用pytest命令运行所有测试用例:
(venv) $ pytest
============================= test session starts =============================
platform win32 -- Python 3.10.5, pytest-7.1.2, pluggy-1.0.0
rootdir: ...\effective-python-testing-with-pytest
collected 4 items
test_with_pytest.py .F [ 50%]
test_with_unittest.py F. [100%]
================================== FAILURES ===================================
______________________________ test_always_fails ______________________________
def test_always_fails():
assert False
E assert False
test_with_pytest.py:7: AssertionError
________________________ TryTesting.test_always_fails _________________________
self = <test_with_unittest.TryTesting testMethod=test_always_fails>
def test_always_fails(self):
self.assertTrue(False)
E AssertionError: False is not true
test_with_unittest.py:10: AssertionError
=========================== short test summary info ===========================
FAILED test_with_pytest.py::test_always_fails - assert False
FAILED test_with_unittest.py::TryTesting::test_always_fails - AssertionError:...
========================= 2 failed, 2 passed in 0.20s =========================
不同于unittest,pytest的测试结果展示的更详细:
系统状态,包括 Python的版本,pytest以及你安装的任何插件
测试目录
runner发现的测试数量
这些内容显示在输出的第一部分:
============================= test session starts =============================
platform win32 -- Python 3.10.5, pytest-7.1.2, pluggy-1.0.0
rootdir: ...\effective-python-testing-with-pytest
collected 4 items
输出使用以下的语法指示每个测试的状态:
点 (.)表示测试通过。
AnF表示测试失败。
AnE表示测试引发了意外异常。
特殊字符显示在用例名称后,右侧显示测试套件的整体进度:
test_with_pytest.py .F [ 50%]
test_with_unittest.py F. [100%]
对于失败的测试,报告会详细说明失败情况。
================================== FAILURES ===================================
______________________________ test_always_fails ______________________________
def test_always_fails():
assert False
E assert False
test_with_pytest.py:7: AssertionError
________________________ TryTesting.test_always_fails _________________________
self = <test_with_unittest.TryTesting testMethod=test_always_fails>
def test_always_fails(self):
self.assertTrue(False)
E AssertionError: False is not true
test_with_unittest.py:10: AssertionError
下面这个额外的输出在调试时非常有用。最后报告给出了测试用例执行的整体状态报告:
=========================== short test summary info ===========================
FAILED test_with_pytest.py::test_always_fails - assert False
FAILED test_with_unittest.py::TryTesting::test_always_fails - AssertionError:...
========================= 2 failed, 2 passed in 0.20s =========================
与 unittest 相比,pytest输出的信息量和可读性要高得多。
强大的assert
assert关键字很强大,支持丰富多样的断言形式。以下是一些断言示例,你可以了解支持的断言类型:
test_assert_examples.py
def test_uppercase():
assert "loud noises".upper() == "LOUD NOISES"
def test_reversed():
assert list(reversed([1, 2, 3, 4])) == [4, 3, 2, 1]
def test_some_primes():
assert 37 in {
num
for num in range(2, 50)
if not any(num % div == 0 for div in range(2, num))
}
更容易管理前置参数和依赖
众所周知,测试用例通常需要依赖准备数据或者其他服务作为前置条件。
使用unittest,你可以将这些依赖项提取到方法中.setUp(),.tearDown()以便类中的每个测试都可以使用它们。使用这些特殊方法很好,但是随着你的测试类变得越来越大,你可能会不经意地使测试的依赖完全隐式。换句话说,通过孤立地查看众多测试中的一个,你可能不会立即看出它依赖于其他东西。
随着时间的推移,隐式依赖关系会导致代码变得复杂,你必须展开这些代码才能理解测试用例。事实上,测试应该有助于使你的代码更易于理解。如果测试本身很难理解,那么就有问题了!
pytest采取不同的方法。由于fixture的可用性,它会引导你进行显式的依赖声明,这些声明可以重用。fixture 是可以为测试用例创建数据、测试mock或初始化系统状态的函数。任何想要使用fixture的测试都必须显式地使用这个fixture函数作为测试函数的参数,所以依赖关系总是在前面声明:
fixture_demo.py
import pytest
@pytest.fixture
def example_fixture():
return 1
def test_with_fixture(example_fixture):
assert example_fixture == 1
查看测试函数,你可以立即看出它依赖于一个fixture,而无需检查整个文件的fixture定义。
易于过滤测试
随着测试套件的增多,你可能会有只想对某个功能运行一些测试并保存整个套件以备后用的需求。pytest提供了一些方法来实现这一点:
基于名称的过滤:你可以限制pytest只运行那些完全限定名称与特定表达式匹配的测试。你可以使用-k参数执行此操作。
目录范围:默认情况下,pytest将仅运行当前目录中/下的测试用例。
测试分类:pytest可以包含或排除你定义的特定类别的测试,可以使用-m参数执行此操作。
测试分类是一个非常强大的工具。pytest使你能够为你喜欢的任何测试创建标签或自定义标签。一个测试可能有多个标签,你可以使用它们来精细化控制要运行的测试。
丰富的插件
pytest生态是开源的,pytest用户开发了一个丰富的有用插件生态系统。
fixture
Pytest fixture是一种为测试提供数据、测试mock的方法。每个依赖于fixture的测试都必须显式地接受fixture作为参数。
何时创建fixture
假设你正在编写一个函数format_data_for_display()来处理 API 接口返回的数据。输入数据是一个人员列表,每个人都有给定的姓名、姓氏和职位。该函数应输出一个字符串列表,其中包括每个人的全名、冒号和title:
format_data.py
def format_data_for_display(people):
... # Implement this!
践行TDD模式,你需要为其编写测试用例。
test_format_data.py
def test_format_data_for_display():
people = [
{
"given_name": "Alfonsa",
"family_name": "Ruiz",
"title": "Senior Software Engineer",
},
{
"given_name": "Sayid",
"family_name": "Khan",
"title": "Project Manager",
},
]
assert format_data_for_display(people) == [
"Alfonsa Ruiz: Senior Software Engineer",
"Sayid Khan: Project Manager",
]
在开发测试用例时,你可能需要开发另一个函数来将数据转换为逗号分隔值以便在Excel中使用:
format_data.py
def format_data_for_display(people):
... # Implement this!
def format_data_for_excel(people):
... # Implement this!
你的TODO清单变长了!TDD 的优势之一是它可以帮助你规划未来的工作。format_data_for_excel()函数的测试看起来与format_data_for_display()函数非常相似:
test_format_data.py
def test_format_data_for_display():
...
def test_format_data_for_excel():
people = [
{
"given_name": "Alfonsa",
"family_name": "Ruiz",
"title": "Senior Software Engineer",
},
{
"given_name": "Sayid",
"family_name": "Khan",
"title": "Project Manager",
},
]
assert format_data_for_excel(people) == """given,family,title
Alfonsa,Ruiz,Senior Software Engineer
Sayid,Khan,Project Manager
"""
值得注意的是,这两个测试都必须重复people变量的定义,而这需要相当多的代码。
如果你正在编写多个测试,这些测试都使用相同的底层测试数据,那么使用fixture你可以将重复的数据拉入一个装饰到函数中,使用@pytest.fixture表示该函数是一个pytest fixture:
test_format_data.py
import pytest
@pytest.fixture
def example_people_data():
return [
{
"given_name": "Alfonsa",
"family_name": "Ruiz",
"title": "Senior Software Engineer",
},
{
"given_name": "Sayid",
"family_name": "Khan",
"title": "Project Manager",
},
]
...
你可以通过将函数引用作为参数添加到测试中来使用fixture,这样可以使用 fixture函数的返回值作为 fixture 函数的名称:
test_format_data.py
...
def test_format_data_for_display(example_people_data):
assert format_data_for_display(example_people_data) == [
"Alfonsa Ruiz: Senior Software Engineer",
"Sayid Khan: Project Manager",
]
def test_format_data_for_excel(example_people_data):
assert format_data_for_excel(example_people_data) == """given,family,title
Alfonsa,Ruiz,Senior Software Engineer
Sayid,Khan,
"""
每个测试的代码量明显更短,但仍然有一条清晰的路径返回它所依赖的数据。
什么时候避免使用fixture
fixture非常适合提取你在多个测试中使用的数据或对象。但是,对于需要数据有变化的测试,fixture并不总是那么好。在你的测试套件中乱用fixture,可能会导致情况更糟。
与大多数抽象一样,需要一些实践和思考才能找到合适的fixture使用场景。
尽管如此,fixture 很可能是你的测试套件不可或缺的一部分。随着项目范围的扩大,测试规模的挑战开始出现。任何类型的工具面临的挑战之一是它如何应对大规模使用,pytest具有一系列有用的功能,可以帮助你管理用例增长带来的复杂性。
如何规模化使用fixture
当你从测试中提取更多的fixture时,你可能会发现一些fixture可以从进一步的抽象中受益。在 中pytest,fixture是模块化的。模块化意味着 fixture 可以导入,可以导入其他模块,它们可以依赖和导入其他 fixture。所有这些都允许你为你的用例编写合适的fixture抽象。
例如,你可能会发现两个单独文件或模块中的fixture共享一个共同的依赖项。在这种情况下,你可以将fixture从测试模块移动到更通用的fixture相关模块中,然后就可以将它们导入任何需要它们的测试模块中。
如果你想让一个fixture在你的整个项目中可用而不必导入它,可以使用特殊配置模块conftest.py文件。pytest在每个目录中查找一个conftest.py模块。如果你将通用fixture添加到conftest.py模块中,那么你将能够在整个模块的父目录和任何子目录中使用该fixture,而无需导入它。conftest.py是放置高频率使用fixture的好地方。
conftest.py另一个有用的地方是它可以保护对资源的访问。想象一下,你已经为处理API 调用的代码编写了一个测试套件,你希望确保测试套件不会进行任何真正的网络调用,即使有人不小心编写了这样做的测试。
pytest提供一个monkeypatch fixture来替换值和行为,你可以使用它来产生很好的效果:
conftest.py
import pytest
import requests
@pytest.fixture(autouse=True)
def disable_network_calls(monkeypatch):
def stunted_get():
raise RuntimeError("Network access not allowed during testing!")
monkeypatch.setattr(requests, "get", lambda *args, **kwargs: stunted_get())
通过放置disable_network_calls()和conftest.py添加autouse=True的选项,你可以确保在整个套件的每个测试中都将禁用网络调用。任何执行代码调用的测试requests.get()都会引发一个RuntimeError指示将发生意外网络调用的错误。
marks:分类测试
在任何大型测试套件中,当你尝试快速迭代新功能时,最好避免运行所有测试,做好精细化测试。除了pytest在当前工作目录中运行所有测试的过滤功能之外,你还可以利用mark。
pytest使你能够为测试定义类别,并提供在运行套件时包含或排除类别的选项。你可以使用任意数量的类别标记测试。
标记测试对于按子系统或依赖项对测试进行分类很有用。例如,如果你的某些测试需要访问数据库,那么你可以@pytest.mark.database_access为它们创建一个标记。
当需要运行测试时,你仍然可以使用命令默认运行它们pytest。如果你只想运行那些需要访问数据库的测试,那么你可以使用pytest -m database_access. 要运行除需要访问数据库的测试之外的所有测试,你可以使用pytest -m "not database_access". 你甚至可以使用autousefixture来限制对那些标有 的测试的数据库访问database_access。
pytest-django插件提供了一个django_db标记。没有此标记的任何尝试访问数据库的测试都将失败。尝试访问数据库的第一个测试将触发 Django 测试数据库的创建。
pytest提供开箱即用的一些标记:
skip无条件跳过测试。
skipif如果传递给它的表达式的计算结果为True,则跳过测试。
xfail表示测试会失败,因此如果测试确实失败,整个套件仍会导致通过状态。
parametrize使用不同的值作为参数创建测试的多个变体。
Parametrization:组合测试
上文提到了如何使用fixture提取公共依赖项来减少代码重复。当你进行多个输入和预期输出略有不同的测试时,fixture就没有那么有用了。在这些情况下,你可以使用参数化为单个测试定义指定参数。
假设你编写了一个函数来判断一个字符串是否为回文。一组初始测试代码如下所示:
def test_is_palindrome_empty_string():
assert is_palindrome("")
def test_is_palindrome_single_character():
assert is_palindrome("a")
def test_is_palindrome_mixed_casing():
assert is_palindrome("Bob")
def test_is_palindrome_with_spaces():
assert is_palindrome("Never odd or even")
def test_is_palindrome_with_punctuation():
assert is_palindrome("Do geese see God?")
def test_is_palindrome_not_palindrome():
assert not is_palindrome("abc")
def test_is_palindrome_not_quite():
assert not is_palindrome("abab")
除了最后两个之外,所有这些测试都具有相同的样板:
def test_is_palindrome_<in some situation>():
assert is_palindrome("<some string>")
乍看很像样板文件。我们可以使用@pytest.mark.parametrize()减少测试代码:
@pytest.mark.parametrize("palindrome", [
"",
"a",
"Bob",
"Never odd or even",
"Do geese see God?",
])
def test_is_palindrome(palindrome):
assert is_palindrome(palindrome)
@pytest.mark.parametrize("non_palindrome", [
"abc",
"abab",
])
def test_is_palindrome_not_palindrome(non_palindrome):
assert not is_palindrome(non_palindrome)
第一个参数parametrize()是以逗号分隔的参数名称字符串。第二个参数是表示参数值的元组或单个值的列表。你可以将参数化进一步处理,将所有测试合并为一个:
@pytest.mark.parametrize("maybe_palindrome, expected_result", [
("", True),
("a", True),
("Bob", True),
("Never odd or even", True),
("Do geese see God?", True),
("abc", False),
("abab", False),
])
def test_is_palindrome(maybe_palindrome, expected_result):
assert is_palindrome(maybe_palindrome) == expected_result
尽管这缩短了你的代码,但重要的是要注意,在这种情况下,你实际上丢失了原始函数的一些更具描述性的特性。确保你没有将测试套件参数化到难以理解的地步之前,可以使用参数化将测试数据与测试行为分开,以便清楚测试用例在测试什么,同时也使不同的测试用例更易于阅读和维护。
durations
命令行使用--durations,pytest在测试结果中会包含持续时间报告。--durations需要一个整数值n,并将报告最慢的n测试:
(venv) $ pytest --durations=5
...
============================= slowest 5 durations =============================
3.03s call test_code.py::test_request_read_timeout
1.07s call test_code.py::test_request_connection_timeout
0.57s call test_code.py::test_database_read
(2 durations < 0.005s hidden. Use -vv to show these durations.)
=========================== short test summary info ===========================
...
显示在持续时间报告中的每个测试占用的总测试时间高于平均水平。
注:某些测试也可能有不可见的设置开销。例如django_db将触发 Django 测试数据库的创建。durations报告反映了在触发数据库创建的测试中设置数据库所花费的时间。
版权归原作者 软件质量保障 所有, 如有侵权,请联系我们删除。