Python测试-pytest, 2024-03-09

(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_namegiven_namejob_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

参数化可用于将测试数据和测试行为分开,也便于阅读和维护。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容