第 5 章 参数
在最后几章中,我们研究了自定义和内置夹具。 在本章中,我们回到测试函数。 我们将研究如何将一个测试函数转变为许多测试用例,以便以更少的工作进行更彻底的测试。 我们将通过参数化来做到这一点。
参数化测试是指向我们的测试函数添加参数并向测试传递多组参数以创建新的测试用例。 我们将按照选择的顺序了解在 pytest 中实现参数化测试的三种方法:
-
参数化函数
-
参数化夹具
-
使用名为 pytest_generate_tests 的钩子函数
我们将通过使用所有三种方法解决相同的参数化问题来对它们进行比较; 然而,正如您将看到的,有时一种解决方案比其他解决方案更受青睐。
不过,在我们真正开始讨论如何使用参数化之前,我们将看一下通过参数化避免的冗余代码。 然后我们将了解三种参数化方法。 当我们完成后,您将能够编写简洁、易于阅读的测试代码来测试大量的测试用例。
没参数的测试
通过函数发送一些值并检查输出以确保其正确是软件测试中的常见模式。 然而,使用一组值调用一次函数并检查一次正确性并不足以完全测试大多数函数。 参数化测试是一种通过同一测试发送多组数据的方法,如果其中任何一组失败,则生成 pytest 报告。
为了帮助理解参数化测试试图解决的问题,让我们为 finish()
API 方法编写一些测试:
Unresolved include directive in modules/ROOT/pages/section01/ch05/ch5.adoc - include::example$/cards_proj/src/cards/api.py[]
应用程序中使用的状态是 “todo”、“in prog” 和 “done”,此方法将 cards 的状态设置为 “done”。
为了测试这一点,我们可以
-
创建一个 Card 对象并将其添加到数据库中,这样我们就有一个可以使用的 Card,
-
调用 finish(),并且
-
确保最终状态为 “done”。
一个是启动状态的 Card 变量。 它可以是 “todo”、“in prog”,甚至可以是 “done”。
让我们测试一下这三个。 这是一个开始:
from cards import Card
def test_finish_from_in_prog(cards_db):
index = cards_db.add_card(Card("second edition", state="in prog"))
cards_db.finish(index)
card = cards_db.get_card(index)
assert card.state == "done"
def test_finish_from_done(cards_db):
index = cards_db.add_card(Card("write a book", state="done"))
cards_db.finish(index)
card = cards_db.get_card(index)
assert card.state == "done"
def test_finish_from_todo(cards_db):
index = cards_db.add_card(Card("create a course", state="todo"))
cards_db.finish(index)
card = cards_db.get_card(index)
assert card.state == "done"
测试功能非常相似。 唯一的区别是起始状态和内容摘要。 因为我们只有三种状态,所以将本质上相同的代码编写三次并不算太糟糕,但它看起来确实是一种浪费。
让我们运行一下:
$ cd /path/to/code/ch5
$ pytest -v test_finish.py
========================= test session starts ==========================
collected 3 items
test_finish.py::test_finish_from_todo PASSED [ 33%]
test_finish.py::test_finish_from_in_prog PASSED [ 66%]
test_finish.py::test_finish_from_done PASSED [100%]
========================== 3 passed in 0.05s ===========================
减少冗余代码的一种方法是将它们组合成同一个函数,如下所示:
from cards import Card
def test_finish(cards_db):
for c in [
Card("write a book", state="done"),
Card("second edition", state="in prog"),
Card("create a course", state="todo"),
]:
index = cards_db.add_card(c)
cards_db.finish(index)
card = cards_db.get_card(index)
assert card.state == "done"
这种方法可行,但有问题。 看看这个测试:
$ pytest test_finish_combined.py
========================= test session starts ==========================
collected 1 item
test_finish_combined.py . [100%]
========================== 1 passed in 0.01s ===========================
它通过了,我们已经消除了多余的代码。 呜呼! 但是,还存在其他问题:
-
我们报告了一个测试用例,而不是三个。
-
如果其中一个测试用例失败,如果不查看回溯或其他一些调试信息,我们真的不知道是哪一个。
-
如果其中一个测试用例失败,则失败后的测试用例将不会运行。 当断言失败时 pytest 停止运行测试。
pytest 参数化非常适合解决此类测试问题。 我们将从函数参数化开始,然后是夹具参数化,最后以 pytest_generate_tests 结束。
参数化函数
要参数化测试函数,请将参数添加到测试定义中,并使用 @pytest.mark.parametrize() 装饰器来定义要传递给测试的参数集,如下所示:
import pytest
from cards import Card
@pytest.mark.parametrize(
"start_summary, start_state",
[
("write a book", "done"),
("second edition", "in prog"),
("create a course", "todo"),
],
)
def test_finish(cards_db, start_summary, start_state):
initial_card = Card(summary=start_summary, state=start_state)
index = cards_db.add_card(initial_card)
cards_db.finish(index)
card = cards_db.get_card(index)
assert card.state == "done"
test_finish() 函数现在将其原始的 cards_db 固定装置作为参数,但也有两个新参数:start_summary 和 start_state。 它们直接匹配 @pytest.mark.parametrize() 的第一个参数。
@pytest.mark.parametrize() 的第一个参数是参数名称列表。 它们是字符串,可以是实际的字符串列表,如 ["start_summary", "start_state"],也可以是逗号分隔的字符串,如 "start_summary, start_state"。 @pytest.mark.parametrize() 的第二个参数是我们的测试用例列表。 列表中的每个元素都是一个由元组或列表表示的测试用例,其中发送到测试函数的每个参数都有一个元素。
pytest 将为每个 (start_summary, start_state) 对运行一次此测试,并将每个测试报告为单独的测试:
$ pytest -v test_func_param.py::test_finish
========================= test session starts ==========================
collected 3 items
test_func_param.py::test_finish[write a book-done] PASSED [ 33%]
test_func_param.py::test_finish[second edition-in prog] PASSED [ 66%]
test_func_param.py::test_finish[create a course-todo] PASSED [100%]
========================== 3 passed in 0.05s ===========================
parametrize() 的这种使用适合我们的目的。 但是,更改每个测试用例的内容摘要对于此测试来说并不重要。 因此,对每个测试用例进行更改实际上会增加一些额外的复杂性,这是不必要的。
让我们将参数化更改为 start_state,看看语法如何变化:
@pytest.mark.parametrize("start_state", ["done", "in prog", "todo"])
def test_finish_simple(cards_db, start_state):
c = Card("write a book", state=start_state)
index = cards_db.add_card(c)
cards_db.finish(index)
card = cards_db.get_card(index)
assert card.state == "done"
这仍然是大部分相同的测试。 参数 “列表” 只有一个参数 “start_state”。 测试用例列表现在仅包含单个参数的值。函数定义不再包含 start_summary 参数。我们刚刚将开始内容摘要硬编码到 Card("write a book", state=start_state)
调用中。
现在,当我们运行它时,它会关注我们关心的更改:
$ pytest -v test_func_param.py::test_finish_simple
========================= test session starts ==========================
collected 3 items
test_func_param.py::test_finish_simple[done] PASSED [ 33%]
test_func_param.py::test_finish_simple[in prog] PASSED [ 66%]
test_func_param.py::test_finish_simple[todo] PASSED [100%]
========================== 3 passed in 0.05s ===========================
看看两个示例的输出差异,我们发现现在只列出了起始状态 “todo”、“in prog” 和 “done”。 在第一个示例中,pytest 显示了两个参数的值,并用破折号 (-) 分隔。 当只有一个参数发生变化时,不需要破折号。
在测试代码和输出中,我们都将注意力集中在不同的起始状态上。 在测试代码中,它很微妙,我经常想添加不必要的参数。 然而,输出变化是巨大的。 从输出中可以很清楚地看出测试用例的差异。 当测试用例失败时,这种清晰的输出非常有帮助。 它可以让您更快地将与测试失败相关的更改归零。
我们可以使用夹具参数化而不是函数参数化来编写相同的测试。 它的工作原理基本相同,但语法不同。
参数化夹具装置
当我们使用函数参数化时,pytest 为我们提供的每组参数值调用一次我们的测试函数。 通过夹具参数化,我们将这些参数转移到夹具中。 然后 pytest 将为我们提供的每组值调用一次固定装置。 然后在下游,将调用依赖于夹具的每个测试函数,每个夹具值各调用一次。
此外,语法也不同:
import pytest
from cards import Card
@pytest.fixture(params=["done", "in prog", "todo"])
def start_state(request):
return request.param
def test_finish(cards_db, start_state):
c = Card("write a book", state=start_state)
index = cards_db.add_card(c)
cards_db.finish(index)
card = cards_db.get_card(index)
assert card.state == "done"
发生的情况是 pytest 最终调用 start_state() 三次,每次调用 params 中的所有值一次。 params 的每个值都保存到 request.param 中以供夹具使用。 在 start_state() 中,我们可以拥有取决于参数值的代码。 但是,在本例中,我们只是返回参数值。
test_finish() 函数与我们在函数参数化中使用的 test_finish_simple() 函数相同,但没有参数化装饰器。 因为它有 start_state 作为参数,所以 pytest 将为传递给 start_state() 装置的每个值调用一次。 所有这些之后,输出看起来和以前一样:
$ pytest -v test_fix_param.py
========================= test session starts ==========================
collected 3 items
test_fix_param.py::test_finish[done] PASSED [ 33%]
test_fix_param.py::test_finish[in prog] PASSED [ 66%]
test_fix_param.py::test_finish[todo] PASSED [100%]
========================== 3 passed in 0.05s ===========================
这很酷。它看起来就像函数参数化示例。
乍一看,夹具参数化的目的与函数参数化几乎相同,但代码要多一些。 有时夹具参数化是有好处的。
夹具参数化的优点是为每组参数运行夹具。 如果您有需要为每个测试用例运行的设置或拆卸代码(可能是不同的数据库连接,或者文件的不同内容,等等),这非常有用。
它还具有许多测试函数能够使用同一组参数运行的优点。 所有使用 start_state 固定装置的测试都将被调用三次,每个开始状态一次。
夹具参数化也是思考同一问题的不同方式。 即使在测试 finish() 的情况下,如果我从 “相同的测试,不同的数据” 的角度来思考它,我通常会倾向于函数参数化。 但如果我将其视为 “相同的测试,不同的开始状态”,我就会倾向于夹具参数化。
使用pytest_generate_tests参数化
第三种参数化方法是使用名为 pytest_generate_tests 的钩子函数。 插件经常使用钩子函数来改变 pytest 的正常操作流程。 但我们可以在测试文件和 conftest.py 文件中使用其中的许多内容。
使用 pytest_generate_tests 实现与之前相同的流程如下所示:
from cards import Card
def pytest_generate_tests(metafunc):
if "start_state" in metafunc.fixturenames:
metafunc.parametrize("start_state", ["done", "in prog", "todo"])
def test_finish(cards_db, start_state):
c = Card("write a book", state=start_state)
index = cards_db.add_card(c)
cards_db.finish(index)
card = cards_db.get_card(index)
assert card.state == "done"
test_finish() 函数没有改变。 我们刚刚更改了每次调用测试时 pytest 填充initial_state 值的方式。
我们提供的 pytest_generate_tests 函数将在 pytest 构建要运行的测试列表时被调用。 metafunc 对象有很多信息,但我们使用它只是为了获取参数名称并生成参数化。
当我们运行这个表单时,它看起来很熟悉:
$ pytest -v test_gen.py
========================= test session starts ==========================
collected 3 items
test_gen.py::test_finish[done] PASSED [ 33%]
test_gen.py::test_finish[in prog] PASSED [ 66%]
test_gen.py::test_finish[todo] PASSED [100%]
========================== 3 passed in 0.06s ===========================
pytest_generate_tests 函数实际上非常强大。 此示例是一个简单的案例,用于匹配先前参数化方法的功能。 但是,如果我们想在测试收集时以有趣的方式修改参数化列表,则 pytest_generate_tests 特别有用。
以下是一些可能性:
-
我们可以将参数化列表基于命令行标志,因为 metafunc 使我们能够访问 metafunc.config.getoption("--someflag")。 也许我们添加一个 --excessive 标志来测试更多值,或者添加一个 --quick 标志来测试几个值。
-
一个参数的参数化列表可以基于另一个参数的存在。 例如,对于要求两个相关参数的测试函数,我们可以使用一组不同的值对它们进行参数化,而不是测试仅要求其中一个参数。
-
我们可以使用
metafunc.parametrize("planet, Moon", [('Earth', 'Moon'), ('Mars', 'Deimos'), ('Mars', 'Phobos') 同时参数化两个相关参数 '), …])
, 例如。
现在我们已经看到了三种参数化测试的方法。尽管我们在 finish() 示例中使用它仅从一个测试函数创建三个测试用例,但参数化有可能生成大量测试用例。 在下一节中,我们将了解如何使用 -k
标志来选择子集。
使用关键字选择测试用例
参数化技术对于快速创建大量测试用例非常有效。 因此,能够运行一部分测试通常是有益的。 我们首先在第 25 页的运行测试子集中查看了 -k
,但我们在这里使用它,因为本章中有相当多的测试用例:
$ pytest -v
========================= test session starts ==========================
collected 16 items
test_finish.py::test_finish_from_in_prog PASSED [ 6%]
test_finish.py::test_finish_from_done PASSED [ 12%]
test_finish.py::test_finish_from_todo PASSED [ 18%]
test_finish_combined.py::test_finish PASSED [ 25%]
test_fix_param.py::test_finish[done] PASSED [ 31%]
test_fix_param.py::test_finish[in prog] PASSED [ 37%]
test_fix_param.py::test_finish[todo] PASSED [ 43%]
test_func_param.py::test_finish[write a book-done] PASSED [ 50%]
test_func_param.py::test_finish[second edition-in prog] PASSED [ 56%]
test_func_param.py::test_finish[create a course-todo] PASSED [ 62%]
test_func_param.py::test_finish_simple[done] PASSED [ 68%]
test_func_param.py::test_finish_simple[in prog] PASSED [ 75%]
test_func_param.py::test_finish_simple[todo] PASSED [ 81%]
test_gen.py::test_finish[done] PASSED [ 87%]
test_gen.py::test_finish[in prog] PASSED [ 93%]
test_gen.py::test_finish[todo] PASSED [100%]
========================== 16 passed in 0.05s ==========================
我们可以使用 -k todo
运行所有 “todo” 案例:
$ pytest -v -k todo
========================= test session starts ==========================
collected 16 items / 11 deselected / 5 selected
test_finish.py::test_finish_from_todo PASSED [ 20%]
test_fix_param.py::test_finish[todo] PASSED [ 40%]
test_func_param.py::test_finish[create a course-todo] PASSED [ 60%]
test_func_param.py::test_finish_simple[todo] PASSED [ 80%]
test_gen.py::test_finish[todo] PASSED [100%]
=================== 5 passed, 11 deselected in 0.02s ===================
如果我们想用 “play” 或 “create” 来消除测试用例,我们可以进一步放大:
$ pytest -v -k "todo and not (play or create)"
========================= test session starts ==========================
collected 16 items / 12 deselected / 4 selected
test_finish.py::test_finish_from_todo PASSED [ 25%]
test_fix_param.py::test_finish[todo] PASSED [ 50%]
test_func_param.py::test_finish_simple[todo] PASSED [ 75%]
test_gen.py::test_finish[todo] PASSED [100%]
=================== 4 passed, 12 deselected in 0.02s ===================
我们可以选择一个测试函数,它将运行它的所有参数化:
$ pytest -v "test_func_param.py::test_finish"
========================= test session starts ==========================
collected 3 items
test_func_param.py::test_finish[write a book-done] PASSED [ 33%]
test_func_param.py::test_finish[second edition-in prog] PASSED [ 66%]
test_func_param.py::test_finish[create a course-todo] PASSED [100%]
========================== 3 passed in 0.02s ===========================
我们也可以只选择一个测试用例:
$ pytest -v "test_func_param.py::test_finish[write a book-done]"
========================= test session starts ==========================
collected 1 item
test_func_param.py::test_finish[write a book-done] PASSED [100%]
========================== 1 passed in 0.01s ===========================
很高兴看到所有正常子集工具都可以使用参数化测试。 这些并不是新技术,但我发现在运行和调试参数化测试时经常使用它们。
回顾
在本章中,我们研究了三种参数化测试的方法:
-
当我们应用 @pytest.mark.parametrize() 装饰器时,我们可以参数化测试函数,创建许多测试用例。
-
我们可以使用 @pytest.fixture(params=()) 参数化装置。 如果夹具需要根据参数值执行不同的工作,这会很有帮助。
-
我们可以使用 pytest_generate_tests 生成复杂的参数化集。
我们还研究了如何使用 pytest -k
运行参数化测试用例的子集。
然而,虽然本章介绍的参数化技术非常强大,但当您在自己的测试中开始使用参数化时,您可能会遇到更复杂的参数集需求,例如需要
-
使用所有三种技术对多个参数进行参数化,
-
组合技术,
-
使用列表和生成器进行参数化,
-
创建自定义标识符(这在使用对象值进行参数化时特别有用),或
-
使用间接参数化。
我们将在 【第 16 章 “高级参数化”(第 221 页)】 中介绍这些高级场景。
练习
当人们开始使用参数化时,我注意到许多人倾向于偏爱他们首先学到的技术——通常是函数参数化——而很少使用其他方法。
通过这些练习将帮助您了解这三种技术有多么简单。 然后,在您自己的测试中,您将能够从三种工具中进行选择,并选择当时对您最有用的工具。
我们已经测试过 finish() 了。 但还有另一个类似的 API 方法需要测试,start():
Unresolved include directive in modules/ROOT/pages/section01/ch05/ch5.adoc - include::example$cards_proj/src/cards/api.py[]
让我们为其构建一些参数化测试:
-
编写三个测试函数,以确保调用 start() 时任何启动状态都会导致 “in prog”:
-
test_start_from_done()
-
test_start_from_in_prog()
-
test_start_from_todo()
-
-
编写一个 test_start() 函数使用函数参数化来测试三个测试用例。
-
使用夹具参数化重写 test_start()。
-
使用 pytest_generate_tests 重写 test_start()。
对于练习 3 和练习 4,如果将 test_start() 函数与 test_finish() 放在同一文件中,则可以重复使用 start_state 夹具和 pytest_generate_tests 实现。
共享的固定装置,甚至参数化的固定装置和 pytest_generate_tests 也可以放置在 conftest.py 中并在许多测试文件之间共享。 然而,在我们的例子中,如果我们尝试在 conftest.py 中放置一个 start_state 固定装置和一个参数化 start_state 的 pytest_generate_tests 钩子函数,它将不起作用。 pytest 会注意到碰撞并给我们一个重复的 “start_state” 错误。 当然,这通常不是问题,因为我们通常不会使用两种方法来参数化同一参数。