将您Nose测试模块中找到的nose.tools.assert_调用转换为pytest的原始断言
项目描述
概述
此包提供Python脚本和pytest插件,以帮助将基于Nose的测试转换为基于pytest的测试。具体来说,该脚本将nose.tools.assert_*函数调用转换为原始断言语句,尽可能保留原始参数的格式。例如,该脚本
assert_true(a, msg)
assert_greater(a, b, msg)
转换为
assert a, msg
assert a > b, msg
一小部分 nose.tools.assert_* 函数调用未被转换,因为没有相应的原始 assert 语句,或者相应的转换难以维护。这些函数作为 pytest 命名空间中的函数通过 pytest 的插件系统提供。
运行
对于一次性转换,请使用 shell 命令
pipx run --python 3.11 nose2pytest path/to/dir/with/python_files
这将查找以 path/to/dir/with/python_files 为起点的文件夹树中的所有 .py 文件,并覆盖原始文件(假设大多数用户将在受版本控制的代码库上运行此命令,这几乎总是最方便的做法)。输入 nose2pytest -h 查看其他选项,例如 -v。
安装
对于执行多次转换,请使用 shell 命令
pipx install --python 3.11 nose2pytest
对于每次转换,请使用 shell 命令
nose2pytest path/to/dir/with/python_files
动机
我已经使用 Nose 几年了,它是一个非常棒的工具。然而,为了使用 Nose 获得良好的测试失败诊断,您应该使用 assert_*() 函数,这些函数来自 nose.tools。虽然它们提供了非常好的诊断,但使用起来并不像原始断言那样方便,因为您必须事先决定要编写哪种类型的断言:与 None 的身份比较、真值检查、假值检查、与另一个对象的身份比较等。仅仅能够编写原始断言,并且还能像 pytest 一样在失败时获得良好的诊断,这真的非常好。这是我使用 pytest 的主要原因之一。另一个原因是 pytest 中 fixture 的设计。
即使没有 nose2pytest,也可以将现有的测试套件从 Nose 转换为 pytest,因为它需要相对较少的工作:相对的,您可能只需要进行一些修改,所有这些都可以手动完成,以获得相同的测试覆盖率和结果。一些需要注意的问题
具有 __init__ 的测试类将被忽略,这些类需要移动(通常,移动到类的 setup_class() 中)
可能需要编辑 setup.cfg,因为 pytest 的测试发现规则稍微严格一些
测试的顺序可能不同,但总的来说,这不应该有问题
所有测试模块都会提前导入,因此某些测试模块可能需要调整,例如将一些代码从测试模块的顶部移动到其 setup_module()
一旦对现有代码库进行了上述修改,您实际上就不需要做任何事情了。但是,您的测试套件现在有一个额外的第三方测试依赖项(Nose),仅仅是因为那些到处使用的 assert_* 函数。此外,您的测试套件中不再有做事情的一个明显的方法:现有的测试代码使用 nose.tools.assert_* 函数,而使用 pytest 您可以使用原始断言。如果您添加测试,开发者应该使用这两种方法中的哪一种?如果您修改现有测试,新的断言应该使用原始断言吗?应该更新剩余的测试方法、测试类或测试模块吗?一个测试模块可以包含数百次对 nose.tools.assert_* 函数的调用,开发者要手动逐个进行转换吗?这既痛苦又容易出错,通常不可能手动完成。
这就是我开发 nose2pytest 的原因:我想将我的 pypubsub 项目的测试套件从 Nose 迁移到 pytest,但只想依赖 pytest,并且有一个明显的方法在测试套件中编写断言。
需求
我期望 nose2pytest 脚本在支持的 CPython 版本 <= v3.11 上运行,在任何支持 fissix 兼容的 lib2to3 的 Python 版本上运行。我期望它即使在相当旧的 Nose 版本上也能成功(甚至在约 2010 年发布的 1.0 版本之前)以及新的 Nose2 测试驱动程序。
只有当您安装了 pytest 时,pytest 包命名空间才会扩展到脚本未转换的 assert 函数。
状态
该包已经在超过5000次 assert_*() 函数调用中使用,其中包括pypubsub测试套件。我认为它是稳定的,但我只在我的代码和一些其他开发者的代码上使用过。对转换结果的反馈将非常受欢迎(例如版本信息和转换的断言语句数量)。
以下已实现转换
函数 |
语句 |
---|---|
assert_true(a[, msg]) |
assert a[, msg] |
assert_false(a[, msg]) |
assert not a[, msg] |
assert_is_none(a[, msg]) |
assert a is None[, msg] |
assert_is_not_none(a[, msg]) |
assert a is not None[, msg] |
assert_equal(a,b[, msg]) |
assert a == b[, msg] |
assert_not_equal(a,b[, msg]) |
assert a != b[, msg] |
assert_list_equal(a,b[, msg]) |
assert a == b[, msg] |
assert_dict_equal(a,b[, msg]) |
assert a == b[, msg] |
assert_set_equal(a,b[, msg]) |
assert a == b[, msg] |
assert_sequence_equal(a,b[, msg]) |
assert a == b[, msg] |
assert_tuple_equal(a,b[, msg]) |
assert a == b[, msg] |
assert_multi_line_equal(a,b[, msg]) |
assert a == b[, msg] |
assert_greater(a,b[, msg]) |
assert a > b[, msg] |
assert_greater_equal(a,b[, msg]) |
assert a >= b[, msg] |
assert_less(a,b[, msg]) |
assert a < b[, msg] |
assert_less_equal(a,b[, msg]) |
assert a <= b[, msg] |
assert_in(a,b[, msg]) |
assert a in b[, msg] |
assert_not_in(a,b[, msg]) |
assert a not in b[, msg] |
assert_is(a,b[, msg]) |
assert a is b[, msg] |
assert_is_not(a,b[, msg]) |
assert a is not b[, msg] |
assert_is_instance(a,b[, msg]) |
assert isinstance(a, b)[, msg] |
assert_count_equal(a,b[, msg]) |
assert collections.Counter(a) == collections.Counter(b)[, msg] |
assert_not_regex(a,b[, msg]) |
assert not re.search(b, a)[, msg] |
assert_regex(a,b[, msg]) |
assert re.search(b, a)[, msg] |
assert_almost_equal(a,b[, msg]) |
assert a == pytest.approx(b, abs=1e-7)[, msg] |
assert_almost_equal(a,b, delta[, msg]) |
assert a == pytest.approx(b, abs=delta)[, msg] |
assert_almost_equal(a, b, places[, msg]) |
assert a == pytest.approx(b, abs=1e-places)[, msg] |
assert_not_almost_equal(a,b[, msg]) |
assert a != pytest.approx(b, abs=1e-7)[, msg] |
assert_not_almost_equal(a,b, delta[, msg]) |
assert a != pytest.approx(b, abs=delta)[, msg] |
assert_not_almost_equal(a,b, places[, msg]) |
assert a != pytest.approx(b, abs=1e-places)[, msg] |
脚本在a和/或b周围添加括号,如果操作符优先级会改变表达式的解释或者涉及换行。例如
assert_true(some-long-expression-a in
some-long-expression-b, msg)
assert_equal(a == b, b == c), msg
转换为
assert (some-long-expression-a in
some-long-expression-b), msg
assert (a == b) == (b == c), msg
不是每个来自nose.tools的assert_*函数都会被nose2pytest转换
一些Nose函数可以通过全局搜索替换来处理,因此修复器不是必需的
assert_raises:替换为pytest.raises
assert_warns:替换为pytest.warns
一些Nose函数可以进行转换,但可读性会降低
assert_dict_contains_subset(a,b) -> assert set(b.keys()) >= a.keys() and {k: b[k] for k in a if k in b} == a
nose2pytest发行版包含一个名为assert_tools.py的模块,该模块定义了这些实用函数以包含等效的原始断言语句。将模块复制到您的测试文件夹或pytest包中,并根据需要更改测试代码的from nose.tools import ...语句。pytest自省将在断言失败时提供错误信息。
一些Nose函数没有一行断言语句的等效,它们必须保持为实用函数
assert_raises_regex
assert_raises_regexp # 已被Nose弃用
assert_regexp_matches # 已被Nose弃用
assert_warns_regex
这些函数在nose2pytest分布的assert_tools.py中可用,并且直接从unittest.TestCase导入(但按照Nose重命名)。将模块复制到您的测试文件夹或pytest包中,并相应地更改测试代码中的from nose.tools import ...语句。
一些Nose函数我之前并没有注意到;例如,我第一次注意到有一个nose.tools.ok_()函数,它与assert_equal相同。欢迎通过电子邮件或拉取请求贡献。
限制
脚本没有转换nose.tools.assert_导入语句,因为有太多的可能性。是否应将from nose.tools import ...更改为from pytest import ...,并删除已实现的转换?是否需要添加import pytest语句,如果是,应放在哪里?如果它放在nose.tools导入之后的行,那么前面的行真的需要吗?实际上,在pytest命名空间中添加的assert_函数可以通过pytest.assert_访问,在这种情况下,脚本应该将pytest.作为前缀并完全删除from nose.tools import ...。选项太多,您可以通过全局正则表达式搜索/替换轻松处理这个问题。
类似地,形式为nose.tools.assert_的语句也不会被转换:这需要对每个函数调用的语义进行分析,因为以下任何一种情况都是可能的
import nose.tools as nt nt.assert_true(...) nt2 = nt nt2.assert_true(...) nt2.assert_true(...) import bogo.assert_true bogo.assert_true(...) # should this one be converted?
可能性无限,因此支持这一点将需要大量的时间,而我没有这么多时间。与其他本节中的限制一样
可以作为上下文管理器使用的Nose函数显然不能转换为原始断言。然而,目前没有方法防止nose2pytest转换以这种方式使用的Nose函数。您必须手动修复。
@raises:这个装饰器可以通过正则表达式@raises((.*))替换为@pytest.mark.xfail(raises=$1),但我更喜欢将带有此装饰器的测试函数转换为在测试函数体中使用pytest.raises。实际上,很容易忘记装饰器并在抛出语句之后添加代码,但这段代码永远不会被执行,您也不会知道。使用pytest.raises(...比xfail(raise=...)更好。
Nose2pytest没有方法确定断言函数是否在lambda表达式中,因此有效的lambda: assert_func(a, b)被转换为无效的lambda: assert a operator b。这些应该很少见,很容易找到(您的IDE会标记语法错误,或者您会在导入时收到异常),并且很容易通过将lambda表达式更改为局部函数来修复。
我相信随着nose2pytest在更多代码库中的应用,将会出现更多限制。欢迎贡献以解决这些限制和现有限制。
其他工具
如果您的测试套件基于unittest或unittest2,或者您的Nose测试也使用了unittest/2的一些功能(例如测试类中的setUp(self)方法),那么以下内容可能对您有所帮助
我都没有使用过,因此不能提供建议。然而,如果您的基于Nose的测试套件同时使用Nose/2和unittest/2功能(例如unittest.case.TestCase和/或setUp(self)/tearDown(self)方法),您应该能够先运行unittest2pytest转换器,然后运行nose2pytest转换器。
解决方案说明
我认为没有lib2to3/fissix库,这个脚本是不可能实现的,更不用说具有相同的功能了。因为lib2to3/fissix库的目的,它保留了换行符、空格和注释。lib2to3/fissix的文档非常简略,所以我很幸运找到了http://python3porting.com/fixers.html。
除了弄清楚lib2to3/fissix包以便利用其功能之外,代码转换的一些方面仍然很棘手,正如Regobro在其Extending 2to3页面最后一段所警告的那样。
多行参数:Python接受在括号、方括号或花括号中包围的多行表达式,但否则不接受。例如,转换
assert_func(long_a + long_b, msg)
到
assert long_a + long_b, msg
会产生无效的Python代码。然而,转换到以下代码则产生有效的Python代码
assert (long_a + long_b), msg
所以nose2pytest检查每个参数表达式(例如long_a +\n long_b)是否包含会导致无效语法的换行符,如果是这样,就将其括在括号中。然而,对于原始断言的可读性来说,也重要的是只当必要时才使用括号。换句话说
assert_func((long_a + long_b), msg) assert_func(z + (long_a + long_b), msg)
应该转换为
assert (long_a + long_b), msg assert z + (long_a + long_b), msg)
而不是
assert ((long_a + long_b)), msg assert (z + (long_a + long_b)), msg)
所以nose2pytest只尝试限制向真正需要的代码添加外部括号。
运算符优先级:Python为每个运算符分配优先级;具有相同优先级的运算符(如比较运算符==、>=、!=等)按顺序执行。这对两个参数的断言函数提出了问题。例如,将assert_equal(a != b, a <= c)翻译为assert a != b == a <= c是不正确的,它必须转换为assert (a != b) == (a <= c)。然而,总是将每个参数括在括号中并不会产生易于阅读的断言:assert_equal(a, b < c)应该转换为assert a == (b < c),而不是assert (a) == (b < c)。
所以nose2pytest在其参数周围添加括号,如果参数之间使用的运算符的优先级低于参数中找到的任何运算符。因此,assert_equal(a, b + c)转换为assert a == b + c,而assert_equal(a, b in c)转换为assert a == (b in c),但assert_in(a == b, c)转换为assert a == b in c)。
贡献
欢迎提交补丁和扩展。请fork、branch,然后提交PR。Nose2pytest使用lib2to3.pytree,特别是Leaf和Node类。将nose测试表达式转换为等效的pytest表达式有几个特别具有挑战性的方面
查找匹配模式的表达式:如果您要转换的代码在script.py中未匹配到其中一个用例,您将必须确定lib2to3/fissix模式表达式来描述它(这与正则表达式类似,但用于代码的AST表示,而不是文本字符串)。在nose2pytest/script.py的顶部已经存在各种表达式模式。这主要是在没有良好文档的情况下进行试错。
将第1步中提取的子表达式插入到目标“表达式模板”中。例如,要将assert_none(a)转换为assert a is None,必须将通过lib2to3/fissix模式提取的cite>a子表达式插入到目标表达式的正确“占位符”节点中。如果第1步是必要的,那么第2步就像创建一个继承自FixAssertBase的新类。
括号和运算符的优先级:有时,为了防止更高优先级的运算符影响,需要在提取的子表达式中添加括号。例如,在 assert_none(a) 中,a 可以是任意 Python 表达式,例如 var1 and var2。assert_none(var1 and var2) 的意思与 assert var1 and var2 is None 不相同;必须添加括号,即目标表达式必须是 assert (var1 and var2) is None。这是否必要取决于转换方式。wrap_parens_* 函数提供了如何以及何时进行此操作的示例。
空格:代码中的空格和新行应尽可能保留,在不必要时删除。例如,assert_equal(a, b) 转换为 assert a == b;后者在 b 前面有一个空格,但原始表达式也是如此;《lib2to3.pytree》捕获此类“非代码”信息,以便从节点生成 Python 代码与输入相同,如果没有应用转换。这是通过 Node.prefix 属性实现的。
在步骤 1 中正确定义模式后,在 tests/test_script.py 中添加一个针对包含匹配该模式的 Python 代码字符串的测试会导致调用 FixAssertBase.transform(node, results),其中 node 是匹配定义模式的子表达式的 Node。结果 results 是在模式中定义的对象名称到表示子表达式的 Node 子树的映射。例如,对于 assert_none(a)(其中 a 可以是任何子表达式,例如 1+2 或 sqrt(5) 或 var1+var2)的模式会导致 results 包含 a 表示的子表达式。transform() 的目标是然后将提取的结果放入表示目标(转换)表达式的新 Node 树中的正确位置。
节点形成树,每个节点都有一个 children 属性,包含 0 个或多个 Node 和/或 Leaf。例如,如果 node 代表 assert a/2 == b,则树可能如下所示
node (Node) assert (Leaf) node (node) node (node) a (Leaf) / (Leaf) 2 (Leaf) == (Leaf) b (Leaf)
有时您可以猜测给定表达式的树,但大多数情况下最好使用调试器运行测试以尝试转换您感兴趣的表达式(在 tests/test_script.py 中有几个如何操作的示例),在 FixAssertBase.transform() 方法的开始处中断,并探索 node.children 树以找到您需要提取的子表达式。在上面的示例中,assert 叶节点是 node.children 的索引 0 的子节点;而索引 1 是另一个 Node;a 叶节点是 node.children[0].children[0].children[1] 的子节点,即它是 node.children[0].children[0].children[1]。因此从 node 到“a”的“路径”是 (0, 0, 1)。
对于 nose2test 扩展的这一步骤的主要挑战是找到到达目标表达式中的所需“占位符”对象的路径。例如,如果 assert_almost_equal(a, b, delta=value) 必须转换为 assert a == pytest.approx(b, delta=value),那么感兴趣的节点是 a、b 和 delta,它们的路径分别是 0、(2, 2, 1, 0) 和 (2, 2, 1, 2, 2)(当路径只包含 1 项时,不需要使用元组)。
发布
参见 RELEASING.rst。
维护
克隆或分叉 git 仓库,创建一个分支
在您的系统上安装 pytest 和 nose:python -m pip install pytest nose
在根目录下运行 pytest
一旦所有测试通过,在您的系统上安装 tox:
python -m pip install tox
运行 tox:
tox
如果最新的 Python 不在 tox.ini 中,请添加 Python 版本
致谢
感谢(据我所知)Lennart Regebro编写了 http://python3porting.com/fixers.html#find-pattern,以及回答了我在SO上关于 我的问题 和在pytest-dev上的 我的问题 的人。
项目详情
下载文件
下载适用于您平台的文件。如果您不确定要选择哪个,请了解更多关于 安装包 的信息。