第1章介绍了 pytest 是如何工作的,学习了如何指定测试目录、使用命令行选项。本章讲解如何为 python 程序包编写测试函数。
以 Tasks 项目来演示如何为 Python 程序包编写测试。
Tasks 项目获取:https://pragprog.com/titles/bopytest/python-testing-with-pytest/
我自己的gitee也有代码 有1版本和最新版本的
https://gitee.com/burebaobao/test-for-pytest.git
下面来看 Tasks 项目的目录结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BW31VxpR-1680542044318)(/Users/liwei/Library/Application Support/typora-user-images/image-20230403231839591.png)]
src 是包含源码的文件夹,所有的测试都放在 tests 文件夹。其他四个根目录文件在附录 D 中详细介绍。项目目录中包含两类 init.py 文件,一类在 src 目录下,一类在 tests 目录下。src/tasks/init.py 告诉Python解释器该目录是Python包。此外,执行到 import tasks 时,这个 init.py 将作为该包主入口。
测试目录中,功能测试和单元测试分别放在不同的目录下:func、unit。test/func/init.py 和 test/unit/init.py 都是空文件,他们的作用是给pytest提供搜索路径,找到测试根目录以及 pytest.ini 文件,这个文件是可选的,保存了pytest在该项目下的特定配置。
切换到 tasks_proj 根目录,运行以下命令:
pip install .(仅安装测试)
或者 pip install -e .(安装好希望修改源码重新安装就需要用-e(editable)选项)
安装完成之后,运行如下测试文件:
# F:\pytest\my_env\tasks_proj\tests\unit\test_task.py
from tasks import Task
def test_asdict():
"""_asdict() should return a dictionary."""
t_task = Task('do something', 'okken', True, 21)
t_dict = t_task._asdict()
expected = {'summary': 'do something',
'owner': 'okken',
'done': True,
'id': 21}
assert t_dict == expected
def test_replace():
"""replace() should change passed in fields."""
t_before = Task('finish book', 'brain', False, None)
t_after = t_before._replace(id=10, done=True)
t_expected = Task('finish book', 'brain', True, 10)
assert t_after == t_expected
def test_defaults():
"Using no parameters should invoke defaults."
t1 = Task()
t2 = Task(None, None, False, None)
assert t1 == t2
def test_member_access():
"""Check .filed functionality of namedtuple."""
t = Task('buy milk', 'brian')
assert t.summary == 'buy milk'
assert t.owner == 'brian'
assert (t.done, t.id) == (False, None)
collected 4 items
test_task.py .... [100%]
============================================================================ 4 passed in 0.08s =============================================================================
用 pytest 测试时,若需要传递测试失败信息,可以直接使用 Python 自带的 assert 关键字,Python 允许在 assert 后面添加任何表达式。如果表达式的值通过 bool 转换后等于 False,则意味着测试失败。
pytest 的一个很重要的功能:重写 assert 关键字。pytest 会截断对原生assert的调用,替换为pytest定义的assert,从而提供更多的失败信息和细节。
"""Use the Task type to show test failures."""
from tasks import Task
def test_task_equality():
"""Different tasks should not be equal."""
t1 = Task('sit there', 'brian')
t2 = Task('do something', 'okken')
assert t1 == t2
def test_dict_equality():
"""Different tasks compared as dicts should not be equal."""
t1_dict = Task('make sandwich', 'okken')._asdict()
t2_dict = Task('make sandwich', 'okkem')._asdict()
assert t1_dict == t2_dict
以上都是assert失败的案例,可以看到回溯的信息很丰富。
collected 2 items
test_task_fail.py FF [100%]
================================================================================= FAILURES =================================================================================
____________________________________________________________________________ test_task_equality ____________________________________________________________________________def test_task_equality(): """Different tasks should not be equal.""" t1 = Task('sit there', 'brian') t2 = Task('do something', 'okken')
assert t1 == t2 E AssertionError: assert Task(summary=...alse, id=None) == Task(summary=...alse, id=None) E E Omitting 2 identical items, use -vv to show E Differing attributes: E ['summary', 'owner'] E E Drill down into differing attribute summary: E summary: 'sit there' != 'do something'... E E ...Full output truncated (9 lines hidden), use '-vv' to show
test_task_fail.py:9: AssertionError
____________________________________________________________________________ test_dict_equality ____________________________________________________________________________def test_dict_equality(): """Different tasks compared as dicts should not be equal.""" t1_dict = Task('make sandwich', 'okken')._asdict() t2_dict = Task('make sandwich', 'okkem')._asdict()
assert t1_dict == t2_dict E AssertionError: assert {'done': Fals...ake sandwich'} == {'done': Fals...ake sandwich'} E Omitting 3 identical items, use -vv to show E Differing items: E {'owner': 'okken'} != {'owner': 'okkem'} E Use -v to get the full diff
test_task_fail.py:16: AssertionError
========================================================================= short test summary info ==========================================================================
FAILED test_task_fail.py::test_task_equality - AssertionError: assert Task(summary=…alse, id=None) == Task(summary=…alse, id=None)
FAILED test_task_fail.py::test_dict_equality - AssertionError: assert {‘done’: Fals…ake sandwich’} == {‘done’: Fals…ake sandwich’}
============================================================================ 2 failed in 0.15s =============================================================================
当然还可以指定具体的测试方法,用两个冒号::
pytest test_task_fail.py::test_task_equality
Tasks 项目中的 API 中,有几个地方可能抛出异常。
cli.py 中的 CLI 代码 和 api.py 中的 API 代码 统一指定了发送给 API 函数的数据类型,假设检查到数据类型错误,异常很可能就是由这些 API 函数抛出的。
为确保文件中的函数在发生类型错误时可以抛出异常,下面来做一些检验:在测试中使用错误类型的数据,引起 TypeError 异常。
# F:\pytest\my_env\tasks_proj\tests\func\test_api_exceptions.py
import pytest
import tasks
def test_add_raises():
"""add() should raise an exception with wrong type param."""
with pytest.raises(TypeError):
tasks.add(task='not a Task object.') # 传参数据的“类型异常”
测试中有 with pytest.raises(TypeError) 声明,意味着无论 with 中的内容是什么,都至少会发生 TypeError 异常。如果测试通过,说明确实发生了我们预期的 TypeError 异常;如果抛出的是其他类型的异常,则与我们所预期的不一致,说明测试失败。
对于 start_tasks_db(db_path, db_type) 来说,db_type 不单要求是字符串类型,还必须为 “tiny” 或 “mongo”。
在同一个文件中编写另一个测试用例:
# F:\pytest\my_env\tasks_proj\tests\func\test_api_exceptions.py
def test_start_tasks_db_raises():
"""Make sure unsupported db raises an exception."""
with pytest.raises(ValueError) as excinfo: # as 后面的变量是 ExceptionInfo 类型,它会被赋予异常消息的值。
tasks.start_tasks_db('some/great/path', 'mysql')
exception_msg = excinfo.value.args[0]
assert exception_msg == "db_type must be a 'tiny' or 'mongo'"
在这个例子中,我们希望确保第一个(也是唯一的)参数引起的异常消息能与某个字符串匹配。
pytest 提供了标记机制,允许你使用 marker 对测试函数做标记。一个测试函数可以有多个标记,一个 marker 也可以用来标记多个测试函数。
比如我们要选一部分测试作为冒烟测试,可以添加 @pytest.mark.smoke
# F:\pytest\my_env\tasks_proj\tests\func\test_api_exceptions.py
@pytest.mark.smoke
def test_list_raises():
"""List() should raise an exception with wrong type param."""
with pytest.raises(TypeError):
tasks.list_tasks(owner=123)
@pytest.mark.get
@pytest.mark.smoke
def test_get_raises():
"""get() should raise an exception with wrong type param."""
with pytest.raises(TypeError):
tasks.get(task_id='123')
运行测试用 命令 -m 选项选择标签标记的组
pytest -v -m smoke test_api_exceptions.py
(由于 @pytest.mark.get 和 @pytest.mark.smoke 都是自定义的,所以会出现警告。)
-m 后面也可以使用表达式,可以在标记之间添加 and、or、not 关键字,例如:
pytest -v -m "smoke and get" test_api_exceptions.py
pytest -v -m "smoke and not get" test_api_exceptions.py
上面两个测试不能算是合理的冒烟测试,因为两个测试并未涉及 数据库的改动(显然这是有必要的)。
import pytest
import tasks
from tasks import Task
def test_add_returns_valid_id():
"""tasks.add() should return an integer."""
# GIVEN an initialized tasks db
# WHEN a new task is added
# THEN returned task_id is of type int
new_task = Task('do something')
task_id = tasks.add(new_task)
assert isinstance(task_id, int)
@pytest.mark.smoke
def test_added_task_has_id_set():
"""Make sure the task_id filed is set by tasks.add()."""
# DIVEN an initialized tasks db
# AND a new task is added
new_task = Task('sit in chair', owner='me', done=True)
task_id = tasks.add(new_task)
# WHEN task is retrieved
task_from_db = tasks.get(task_id)
# THEN task_id matches id field
assert task_from_db.id == task_id
# 下面定义一个 fixture,用于测试前后控制数据库的连接
@pytest.fixture(autouse=True) # autouse表示当前文件中的所有测试都将使用该fixture
def initialized_tasks_db(tmpdir): # tmpdir是pytest内置的fixture
"""Connect to db before testing, disconnect after."""
# Setup: start db
tasks.start_tasks_db(str(tmpdir), 'tiny')
# yield之前的代码将在测试运行前执行
yield #this is where the testing happens
# yield之后的代码会在测试运行后执行,若有必要,yield也可以返回数据给测试
# Teardown: stop db
tasks.stop_tasks_db()
2.4节中使用的标记是自定义的。pytest 自身内置了一些标记:skip、skipif、xfail。本节介绍skip和skipif,下一节介绍xfail。
为此编写一个测试(注意上一节的initialized_tasks_db fixture仍然有效)
# F:\pytest\my_env\tasks_proj\tests\func\test_unique_id_1.py
import pytest
import tasks
def test_unique_id():
"""Calling unique_id() twice should return different numbers."""
id_1 = tasks.unique_id()
id_2 = tasks.unique_id()
assert id_1 != id_2
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
"""Connect to db before testing, disconnect after."""
# Setup: start db
tasks.start_tasks_db(str(tmpdir), 'tiny')
yield # this is where the testing happens
# Teardown: stop db
tasks.stop_tasks_db()
@pytest.mark.skip(reason=‘xxx’)
# F:\pytest\my_env\tasks_proj\tests\func\test_unique_id_2.py
import pytest
import tasks
from tasks import Task
@pytest.mark.skip(reason='misunderstood the API') # skip标记跳过测试
def test_unique_id_1():
"""Calling unique_id() twice should return different numbers."""
id_1 = tasks.unique_id()
id_2 = tasks.unique_id()
assert id_1 != id_2
def test_unique_id_2():
"""unique_id() should return an unused id."""
ids = []
ids.append(tasks.add(Task('one')))
ids.append(tasks.add(Task('two')))
ids.append(tasks.add(Task('three')))
# grab a uniq id
uid = tasks.unique_id()
# make sure it isn't in the list of existing ids
assert uid not in ids
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
"""Connect to db before testing, disconnect after."""
# Setup: start db
tasks.start_tasks_db(str(tmpdir), 'tiny')
yield # this is where the testing happens
# Teardown: stop db
tasks.stop_tasks_db()
如果要给要跳过的测试添加理由和条件,比如希望它只在包版本低于0.2.0时才生效,应当使用 skipif 来代替 skip:
@pytest.mark.skipif(tasks.version < ‘0.2.0’, reason = ‘not supported untill version 0.2.0’)
import pytest
import tasks
from tasks import Task
# 只改了这一条mark
@pytest.mark.skipif(tasks.__version__ < '0.2.0',
reason = 'not supported untill version 0.2.0')
def test_unique_id_1():
"""Calling unique_id() twice should return different numbers."""
id_1 = tasks.unique_id()
id_2 = tasks.unique_id()
assert id_1 != id_2
def test_unique_id_2():
"""unique_id() should return an unused id."""
ids = []
ids.append(tasks.add(Task('one')))
ids.append(tasks.add(Task('two')))
ids.append(tasks.add(Task('three')))
# grab a uniq id
uid = tasks.unique_id()
# make sure it isn't in the list of existing ids
assert uid not in ids
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
"""Connect to db before testing, disconnect after."""
# Setup: start db
tasks.start_tasks_db(str(tmpdir), 'tiny')
yield # this is where the testing happens
# Teardown: stop db
tasks.stop_tasks_db()
@pytest.mark.xfail(tasks.version < ‘0.2.0’, reason = ‘not supported untill version 0.2.0’)
import pytest
import tasks
from tasks import Task
# 使用xfail标记预期会失败的测试用例
@pytest.mark.xfail(tasks.__version__ < '0.2.0',
reason = 'not supported untill version 0.2.0')
def test_unique_id_1():
"""Calling unique_id() twice should return different numbers."""
id_1 = tasks.unique_id()
id_2 = tasks.unique_id()
assert id_1 != id_2
@pytest.mark.xfail()
def test_unique_id_is_a_duck():
"""Demonstrate xfail"""
uid = tasks.unique_id()
assert uid == 'a duck'
@pytest.mark.xfail()
def test_unique_id_not_a_duck():
"""Demonstrate xpass"""
uid = tasks.unique_id()
assert uid != 'a duck'
def test_unique_id_2():
"""unique_id() should return an unused id."""
ids = []
ids.append(tasks.add(Task('one')))
ids.append(tasks.add(Task('two')))
ids.append(tasks.add(Task('three')))
# grab a uniq id
uid = tasks.unique_id()
# make sure it isn't in the list of existing ids
assert uid not in ids
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
"""Connect to db before testing, disconnect after."""
# Setup: start db
tasks.start_tasks_db(str(tmpdir), 'tiny')
yield # this is where the testing happens
# Teardown: stop db
tasks.stop_tasks_db()
小写的x表示预期失败实际上也失败了,大写的X表示预期失败但实际上通过了。
前面介绍了给测试做标记以及根据标记运行测试。运行测试子集有很多种方式,不但可以选择运行某个目录、文件、类中的测试,还可以选择运行某一个测试用例。
本节介绍测试类,并通过表达式来完成测试函数名册字符串匹配。
pytest tests/func --tb=no -p
pytest -v tests/func/func.py --tb=no
pytest -v tests/func/test_add.py::test_add_returns_valid_id
pytest -v tests/func/test_add.py::TestUpdate
如果不希望运行测试类中的所有测试,只想运行其中一个,一样可以在文件名后添加 :: 符号和方法名:
pytest -v tests/func/test_add.py::TestUpdate::function1
重点是@pytest.mark.parametrize 的使用
允许传递多组数据,一旦发现测试失败,pytesy 会及时报告。
首先看一组测试:
import pytest
import tasks
from tasks import Task
def test_add_1():
"""tasks.get() using id returned from add() works."""
task = Task('breath', 'BRAIN', True)
task_id = tasks.add(task)
t_from_db = tasks.get(task_id)
# everthing but the id should be the same.
assert equivalent(t_from_db, task)
def equivalent(t1, t2):
"""Check two tasks for equivalence."""
# Compare everthing but the id field
return ((t1.summary == t2.summary) and
(t1.owner == t2.owner) and
(t1.done == t2.done))
@pytest.fixture(autouse=True)
def initialized_tasks_db(tmpdir):
"""Connect to db before testing, disconnect after."""
tasks.start_tasks_db(str(tmpdir), 'tiny')
yield
tasks.stop_tasks_db()
批量测试:使用 @pytest.mark.parametrize(argnames, argvalues) 装饰器
@pytest.mark.parametrize('task',
[Task('sleep', done=True),
Task('wake', 'brain'),
Task('breathe', 'BRAIN', True),
Task('exercise','BaIaN', False)])
def test_add_2(task):
"""Demonstrate parametrize with one parameter."""
task_id = tasks.add(task)
t_from_db = tasks.get(task_id)
assert equivalent(t_from_db, task)
@pytest.mark.parametrize('summary, owner, done',
[('sleep', None, False),
('wake', 'brain', False),
('breathe', 'BRAIN', True),
('eat eggs','BaIaN', False)])
def test_add_3(summary, owner, done):
"""Demonstrate parametrize with multiple parameters."""
task = Task(summary, owner, done)
task_id = tasks.add(task)
t_from_db = tasks.get(task_id)
assert equivalent(t_from_db, task)
和test_add_2相比,输出结果中的测试标识使用组合后的参数值以增强可读性 。(多参数情况下,测试输出的可读性非常好。)
可以使用完整的测试标识(pytest 术语为 node)来重新指定需要运行的测试:
如果标识中包含空格,要添加引号: