(2024.03.09 Sat @KLN)
对比Pytest和Unittest
pytest的优势在于简单容易实现,不需要太多test fixture,构建方便,执行测试时会在项目路径(子目录)下自动发现和运行名如test_*.py或*_test.py的文件,并在terminal中输入pytest即可运行。
- 语法和简洁性:pytest提供了比unittest更加简洁(concise)和可读性高的语法,方便写测试和理解
- 测试发现(test discovery):pytest不需要提供显式和明确的子类和命名规则(naming convention)就可以自动发现测试文件和测试用例;unittest遵循严格的发现机制
- 固定件(fixtures)和插件(fixtures&plugins):pytest提供了功能强大的fixture mechanism,简化了测试设定和teardown。fixture可用于定义重复使用的设定和code cleanup,提升测试的组织性减少代码重写(duplication)。pytest还提供了大量插件用于扩展功能,诸如test coverage,mocking和参数化(parametrisation)
- 测试参数化(parametrisation):pytest内置了对参数化的支援,可便于开发者在相同的测试中使用不同的输入和参数,特别适用于重复代码在不同的场景中的情况。unittest则需要更多的手工设定实现参数化。
- 声明控制(assertion handling):pytest提供了更富有表达力(expressive)和灵活的assertion handling,提供更多的failure message,便于诊断问题
- 兼容与生态:pytest需要预先安装单提供了丰富的插件生态和社区支持;unittest作为python内置标准库兼容旧版python
(2024.03.10 Sun @KLN)
Pytest的特征
Fixture, mark
Fixture - Managing States and Dependencies
pytest固定件(后文统称fixture)为测试提供了数据,翻倍(test doubles),和状态初始化设定。fixtures作为函数可返回一系列值(a wide range of values)。每个依赖于fixture的测试必须显式的接受fixture作为输入变量。
使用fixture的典型场景:test-driven development (TTD)
试想一个格式转换的案例,函数format_transfer_1讲一个输入JSON转换成list,输入的JSON有三个变量,family_name,given_name和job_title,输出的list中的每个元素是一个string,格式如<given_name> <family_name>: <job_title>。以TTD的模式开发,可写成如下模式:
# format_transfer.py
def format_transfer_1(people):
... # function body
测试代码如:
# test_format_transfer.py
def test_format_transfer_1():
c
assert format_transfer_1_list(people) == ["john tompson: professor",
"dave laurenson: professor"]
之后又开发一个新函数,功能是将同样的JSON数据转换成CSV格式,代码如
# format_transfer.py
def format_transfer_1_list(people):
... # function body
def format_transfer_2_csv(people):
... # function body
测试代码如
# test_format_transfer.py
def test_format_transfer_1():
...
def test_format_transfer_2():
people = [{"given_name": "john", "family_name": "thompson", "job_title": "professor"},
{"given_name": "dave", "family_name": "laurenson", "job_title": "professor"}]
assert format_transfer_2_csv(people) ==
"given_name, family_name, job_title
john, thompson, professor
dave, laurenson, professor"
注意到不同的test cases都用到了相同的测试数据people。在这种情况下,pytest fixture就有了用武之地。测试用数据people置于单独一个函数中,并用@pytest.fixture装饰,在test cases中调用即可。
# test_format_transfer.py
@pytest.fixture
def example_people():
return people = [{"given_name": "john", "family_name": "thompson", "job_title": "professor"},
{"given_name": "dave", "family_name": "laurenson", "job_title": "professor"}]
# ...
在test cases中使用时只需要将函数名(function reference)作为参数传入测试函数,注意到传递的是fixture function reference,而非调用fixture function。
# test_format_transfer.py
# ...
def test_format_transfer_1(example_people):
assert format_transfer_1_list(example_people) ==
["john tompson: professor", "dave laurenson: professor"]
def test_format_transfer_2(example_people):
assert format_transfer_2_csv(example_people) ==
"given_name, family_name, job_title
john, thompson, professor
dave, laurenson, professor"
这种调用方式,类似class中@property的调用方式。
fixture的使用减少了代码量,很难不过度使用,下面介绍在何种情况下避免使用fixture。
何时避免使用Fixture
不同的测试用例需要对数据做微小修改,这种情况使用fixture未必能节省代码。如果对数据加入不同的层级未必有效。
成规模使用fixture (use fixture at scale)
随着测试中会用的fixture越来越多,fixture的抽象会提升效率。在pytest中,fixture可以像包(modular)一样被导入,也可以导入其他包,其他包也可以来fixture。这个特性使开发和可以为test cases编写合适的fixture抽象集。
(2024.03.14 Thur)
比如两个在独立文件中的fixtures使用共同的依赖,这种情况就可以转移fixtures到一个更通用的fixture相关的包中,之后在测试文件中引入fixtures。
另有方法可以在整个项目中使用fixture而不引入(import),设置一个设置包conftest.py可实现该功能。
pytest在每个路径中搜索conftest.py包,将通用目的(general-purpose)的fixtures加入到一个conftest.py包中,可在该包母目录(parent directory)和子目录下调用而无需引入,这极大的方便了fixtures的使用。
Fixtures和conftest.py的另一个应用是保护对资源的使用(guarding access to resources)。一个处理API调用的测试集(test suite)中,开发者要确保该测试集不会接入真实网络调用。
(2024.03.16 Sat KLN)
pytest提供了monkeypatch fixture用于代替数值和行为,案例如下
# conftest.py
import pytest, requests
@pytest.fixture(autouse=True)
def disable_network_connection(monkeypatch):
def stunted_get():
raise RuntimeError("Network banned for testing!")
monkeypatch.setattr(reqests, "get", lambda *args, **kwargs: stunted_get())
在conftest.py加入该disable_network_connection()函数和autouse=True选项,可保证测试中网络调用都被禁用。任何执行requests.get()的代码都返回RuntimeError错误。
Marks: categorise tests
默认情况下pytest会运行当前工作路径下的所有测试,同时可以利用mark功能对测试用例进行过滤和选择。对test cases做分类,可实现选择运行或不运行特定类比的test cases。比如有些测试需要连接数据库,可用这个装饰器装饰@pytest.mark.database_access。
考虑到对测试用例的命名可能出现笔误和记错等情况,pytest会在标记无法识别时发出提醒。可在pytest命令中使用--strict-markers选项确保测试中的所有标记都登记在pytest的设置文件即pytest.ini中,这个命令会组织没有登记的测试用例运行。
接上面需要连接数据库的案例,在运行测试时可默认运行所有测试用例。如果在运行指令中加入database_access,即pytest -m database_access则只运行加了@pytest.mark.database_access的测试用例。如果要排除掉这些test cases,则加入not,即pytest -m "not database_access"。当然也可使用一个autouse fixture来限制标记了database_access测试用例的运行。
部分插件会扩展标记的功能。pytest-django提供了django_db标记,没有加这个标记则连接数据库会失败,第一次试图访问数据库的测试会触发Django创建测试数据库。添加了django_db标记会迫使开发者清晰和显式的给出依赖(nudges you toward stating dependencies explicitly),比如需要或不需要连接数据库之类,节省开发和测试时间。
pytest的若干标记:
-
skip:无条件跳过测试 -
skipif:如果表达式为True则跳过 -
xfail:测试的预期结果为fail,如果返回fail,则测试通过 -
parametrize:用不同的输入值创建一个测试的多个变体
可用命令pytest --markers查看所有标记。
Parametrisation: Combining Tests
前面提到pytest fixtures通过寻找和提取共同的依赖以减少代码duplication。当同一个测试有不一样的输入时,fixtures并不是太有效。同一个测试多种输入的情况下,可参数化(parametrise)单独测试定义,pytest会根据用户指定的参数创建测试不同变体。
比如一个判断字符串是否是回文(palindrome)的测试用例
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")
除了后两个test cases,其他的有相似的格式
def test_is_palindrome_<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_paralindrome(palindrome):
assert is_palindrome(palindrome)
后两个用例,可写为
@pytest.mark.parametrize("non_palindrome", [
"abc",
"abad",
])
def test_is_palindrome_not(non_palindrome):
assert not is_palindrome(non_palindrome)
parametrize()函数的第一个输入变量是逗号分隔的参数名字符串,第二个输入是list/tuple/single value,以对应参数的值。下面将前面的测试案例集成在一起。
@pytest.mark.parametrize("args, expected_results", [
("", True),
("a", True),
("Bob", True),
("never odd or even", True),
("do geese see god", True),
("abc", False),
("abad", False),
])
def test_is_palindrome(args, expected_results"):
assert is_palindrome(args) == expected_results
参数化可用于将测试数据和测试行为分开,也便于阅读和维护。