我们将研究如何把一个测试函数变成许多测试用例,以更少的工作量进行更全面的测试。我们将用参数化来做这件事。
参数化测试是指向我们的测试函数添加参数,并向测试传入多组参数,以创建新的测试案例。我们将看看在pytest中实现参数化测试的三种方法,并按照它们的顺序进行选择。
- 参数化函数
- 参数化Fixture
- pytest_generate_tests钩子函数
减少代码的尝试
让我们为finish()API方法写一些测试。
cards_proj/src/cards/api.py
def finish(self, card_id: int):
"""Set a card state to 'done'."""
self.update_card(card_id, Card(state="done"))
应用程序中使用的状态是 "todo"、"in progressive "和 "done",这个方法将一个卡片的状态设置为 "done"。
Card的起始状态。它可能是 "todo"、"progressive",甚至是已经 "完成"。
ch5/test_finish.py
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
$ pytest -v test_finish.py
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0 -- D:\ProgramData\Anaconda3\python.exe
cachedir: .pytest_cache
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collecting ... collected 3 items
test_finish.py::test_finish_from_in_prog PASSED [ 33%]
test_finish.py::test_finish_from_done PASSED [ 66%]
test_finish.py::test_finish_from_todo PASSED [100%]
============================== 3 passed in 0.18s ==============================
一种减少代码的方法
ch5/test_finish_combined.py
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 =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collected 1 item
test_finish_combined.py . [100%]
============================== 1 passed in 0.14s ==============================
我们已经消除了多余的代码。但是还有其他问题。我们只有一个测试用例报告,而不是三个。
如果其中一个测试用例失败了,如果不看回溯或其他一些调试信息,我们真的不知道是哪一个。如果其中一个测试用例失败了,那么失败后的测试用例将不会被运行。 当一个断言失败时,pytest停止运行测试。
参数化函数
要对测试函数进行参数化处理,需要在测试定义中添加参数,并使用@pytest.mark.parametrize()装饰器来定义要传递给测试的参数集,像这样。
ch5/test_func_param.py
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"
@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"
test_finish()函数现在有它原来的card_db Fixture作为参数,但也有两个新参数: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 =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0 -- D:\ProgramData\Anaconda3\python.exe
cachedir: .pytest_cache
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collecting ... 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.14s ==============================
$ pytest -v test_func_param.py::test_finish_simple
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0 -- D:\ProgramData\Anaconda3\python.exe
cachedir: .pytest_cache
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collecting ... 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.15s ==============================
对Fixture进行参数化
使用函数参数化时,pytest对我们提供的每组参数值都调用一次测试函数。使用Fixture参数化,我们将这些参数转移到Fixture中,然后pytest将为我们提供的每组值调用一次Fixture。然后每个依赖于夹具的测试函数都会被调用,每个Fixture的值都会被调用。
另外,语法是不同的。
ch5/test_fix_param.py
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"
执行
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"
乍一看,夹具参数化的作用与函数参数化的作用差不多,只是代码多了一点。有的时候,Fixture参数化是有好处的。
Fixture参数化的好处是可以为每组参数运行Fixture。如果你有需要为每个测试案例运行的设置或拆除代码,这很有用--可能是不同的数据库连接,或不同的文件内容,或其他。
它还有一个好处,就是许多测试函数能够以相同的参数集运行。所有使用start_state夹具的测试都会被调用三次,每个启动状态一次。
Fixture参数化也是思考同一问题的不同方式。即使在测试finish()的情况下,如果我从 "相同的测试,不同的数据 "的角度来考虑,我经常倾向于函数参数化。但如果我考虑的是 "同样的测试,不同的开始状态",我就会倾向于Fixture参数化。
pytest_generate_tests参数化
参数化的第三种方法是使用pytest_generate_tests的钩子函数。钩子函数经常被插件用来改变pytest的正常操作流程
用pytest_generate_tests实现与之前相同的流程:
ch5/test_gen.py
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 =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0 -- D:\ProgramData\Anaconda3\python.exe
cachedir: .pytest_cache
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collecting ... 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.16s ==============================
如果我们想在测试收集时以有趣的方式修改参数化列表,pytest_generate_tests就特别有用。
我们可以将参数化列表建立在一个命令行标志上,因为metafunc让我们可以访问metafunc.config.getoption("--someflag")。也许我们可以添加一个 --excessive 标志来测试更多的值,或者一个 --quick 标志来测试几个值。
参数的参数化列表可以基于另一个参数的存在。例如,对于要求两个相关参数的测试函数,我们可以用一组不同的值对它们进行参数化,而不是在测试时只要求其中一个参数。
例如,我们可以用metafunc.parametrize("planet, moon", [('Earth', 'Moon'), ('Mars', 'Deimos'), ('Mars', 'Phobos'), ...]) 同时对两个相关参数进行参数化。
使用关键词来选择测试用例
参数化技术在快速创建大量测试用例方面相当有效。因此,能够运行测试的一个子集往往是有益的。我们在运行测试子集中第一次看到了-k,但让我们在这里使用它,因为我们在这一章中有相当多的测试案例。
$ pytest -v -k todo
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0 -- D:\ProgramData\Anaconda3\python.exe
cachedir: .pytest_cache
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collecting ... 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.20s =======================
$ pytest -v -k "todo and not (play or create)"
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0 -- D:\ProgramData\Anaconda3\python.exe
cachedir: .pytest_cache
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collecting ... 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.16s =======================
$ pytest -v "test_func_param.py::test_finish"
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0 -- D:\ProgramData\Anaconda3\python.exe
cachedir: .pytest_cache
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collecting ... 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.14s ==============================
$ pytest -v "test_func_param.py::test_finish"
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0 -- D:\ProgramData\Anaconda3\python.exe
cachedir: .pytest_cache
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collecting ... 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.14s ==============================
$ pytest -v "test_func_param.py::test_finish[write a book-done]"
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0 -- D:\ProgramData\Anaconda3\python.exe
cachedir: .pytest_cache
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collecting ... collected 1 item
test_func_param.py::test_finish[write a book-done] PASSED [100%]
============================== 1 passed in 0.11s ==============================