0%

pytest学习文档

1、Pytest启动!

1.1 安装 Pytest

使用 pip 进行安装:

1
$ pip install pytest

1.2 第一个测试函数

Pytest 使用 Python 的 assert 进行条件判断,最简单的测试函数如:

1
2
3
4
# test1.py

def test_passing():
assert (1, 2, 3) == (1, 2, 3)

1.3 运行测试函数

使用命令 pytest 运行测试函数:

1
2
3
4
5
6
7
8
9
$ pytest tests/test1.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item

tests\test1.py . [100%]

========================== 1 passed in 0.09 seconds ===========================

pytest 使用 . 标识测试成功(PASSED)。

可以使用 -v 选项,显示测试的详细信息。

使用 pytest -h 查看 pytest 的所有选项。

1
2
3
4
5
6
7
8
9
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0 -- c:\anaconda3\python.exe
cachedir: .pytest_cache
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item

tests/test1.py::test_passing PASSED [100%]

========================== 1 passed in 0.03 seconds ===========================

1.4 测试失败

下面是一个失败的测试函数:

1
2
3
4
# test2.py

def test_failing():
assert (1, 2, 3) == (3, 2, 1)

运行结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ pytest tests/test2.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item

tests\test2.py F [100%]

================================== FAILURES ===================================
________________________________ test_failing _________________________________

def test_failing():
> assert (1, 2, 3) == (3, 2, 1)
E assert (1, 2, 3) == (3, 2, 1)
E At index 0 diff: 1 != 3
E Use -v to get the full diff

tests\test2.py:2: AssertionError
========================== 1 failed in 0.19 seconds ===========================

pytest 使用 F 标识测试失败(FAILED)。

2、测试函数

2.1 断言

在 pytest 中,assert 是编写测试的最基础工具。如:

1
2
3
assert a == b

assert a <= b

2.2 捕获异常

在测试过程中,经常需要测试是否如期抛出预期的异常,以确定异常处理模块生效。在 pytest 中使用 pytest.raises() 进行异常捕获:

1
2
3
4
5
6
7
# test_raises.py

def test_raises():
with pytest.raises(TypeError) as e:
connect('localhost', '6379')
exec_msg = e.value.args[0]
assert exec_msg == 'port type must be int'

运行结果如下:

1
2
3
4
5
6
7
8
9
$ pytest test_raises.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item

tests\test-function\test_raise.py . [100%]

========================== 1 passed in 0.07 seconds ===========================

2.3 mark函数使用

2.3.1 Pytest 查找测试策略

默认情况下,pytest 会递归查找当前目录下所有以 test 开始或结尾的 Python 脚本,并执行文件内的所有以 test 开始或结束的函数和方法。

对于下面脚本:

1
2
3
4
5
6
7
# test_no_mark.py

def test_func1():
assert 1 == 1

def test_func2():
assert 1 != 1

直接执行测试脚本会同时执行所有测试函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ pytest tests/test-function/test_no_mark.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items

tests\test-function\test_no_mark.py .F [100%]

================================== FAILURES ===================================
_________________________________ test_func2 __________________________________

def test_func2():
> assert 1 != 1
E assert 1 != 1

tests\test-function\test_no_mark.py:6: AssertionError
===================== 1 failed, 1 passed in 0.07 seconds ======================

2.3.2 mark测试函数

由于某种原因(如 test_func2 的功能尚未开发完成),我们只想执行指定的测试函数。在 pytest 中有几种方式可以解决:

第一种,显式指定函数名,通过 :: 标记。

1
2
3
4
5
6
7
8
9
$ pytest tests/test-function/test_no_mark.py::test_func1
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item

tests\test-function\test_no_mark.py . [100%]

========================== 1 passed in 0.02 seconds ===========================

第二种,使用模糊匹配,使用 -k 选项标识。

1
2
3
4
5
6
7
8
9
$ pytest -k func1 tests/test-function/test_no_mark.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items / 1 deselected

tests\test-function\test_no_mark.py . [100%]

=================== 1 passed, 1 deselected in 0.03 seconds ====================

以上两种方法,第一种一次只能指定一个测试函数,当要进行批量测试时无能为力;第二种方法可以批量操作,但需要所有测试的函数名包含相同的模式,也不方便。

第三种,使用 pytest.mark 在函数上进行标记。

带标记的测试函数如:

1
2
3
4
5
6
7
8
9
# test_with_mark.py

@pytest.mark.finished
def test_func1():
assert 1 == 1

@pytest.mark.unfinished
def test_func2():
assert 1 != 1

测试时使用 -m 选择标记的测试函数:

1
2
3
4
5
6
7
8
9
$ pytest -m finished tests/test-function/test_with_mark.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items / 1 deselected

tests\test-function\test_with_mark.py . [100%]

=================== 1 passed, 1 deselected in 0.10 seconds ====================

使用 mark,我们可以给每个函数打上不同的标记,测试时指定就可以允许所有被标记的函数。

一个函数可以打多个标记;多个函数也可以打相同的标记。

运行测试时使用 -m 选项可以加上逻辑,如:

1
2
3
$ pytest -m "finished and commit"

$ pytest -m "finished and not merged"

2.4 跳过测试用例

上一节提到 pytest 使用标记过滤测试函数,所以对于那些尚未开发完成的测试,最好的处理方式就是略过而不执行测试。

按正向的思路,我们只要通过标记指定要测试的就可以解决这个问题;但有时候的处境是我们能进行反向的操作才是最好的解决途径,即通过标记指定要跳过的测试。

Pytest 使用特定的标记 pytest.mark.skip 完美的解决了这个问题。

1
2
3
4
5
# test_skip.py

@pytest.mark.skip(reason='out-of-date api')
def test_connect():
pass

执行结果可以看到该测试已被忽略:

1
2
3
4
5
6
7
8
9
$ pytest tests/test-function/test_skip.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item

tests\test-function\test_skip.py s [100%]

========================== 1 skipped in 0.13 seconds ==========================

pytest 使用 s 表示测试被跳过(SKIPPED)。

Pytest 还支持使用 pytest.mark.skipif 为测试函数指定被忽略的条件。

1
2
3
4
@pytest.mark.skipif(conn.__version__ < '0.2.0',
reason='not supported until v0.2.0')
def test_api():
pass

2.5 预见错误

如果我们事先知道测试函数会执行失败,但又不想直接跳过,而是希望显示的提示。

Pytest 使用 pytest.mark.xfail 实现预见错误功能:

1
2
3
4
5
6
7
8
# test_xfail.py

@pytest.mark.xfail(gen.__version__ < '0.2.0',
reason='not supported until v0.2.0')
def test_api():
id_1 = gen.unique_id()
id_2 = gen.unique_id()
assert id_1 != id_2

执行结果:

1
2
3
4
5
6
7
8
9
$ pytest tests/test-function/test_xfail.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item

tests\test-function\test_xfail.py x [100%]

========================== 1 xfailed in 0.12 seconds ==========================

pytest 使用 x 表示预见的失败(XFAIL)。

如果预见的是失败,但实际运行测试却成功通过,pytest 使用 X 进行标记(XPASS)。

2.6 传递参数

当对一个测试函数进行测试时,通常会给函数传递多组参数。比如测试账号登陆,我们需要模拟各种千奇百怪的账号密码。

当然,我们可以把这些参数写在测试函数内部进行遍历。不过虽然参数众多,但仍然是一个测试,当某组参数导致断言失败,测试也就终止了。

通过异常捕获,我们可以保证程所有参数完整执行,但要分析测试结果就需要做不少额外的工作。

在 pytest 中,我们有更好的解决方法,就是参数化测试,即每组参数都独立执行一次测试。使用的工具就是 pytest.mark.parametrize(argnames, argvalues)

这里是一个密码长度的测试函数,其中参数名为 passwd,其可选列表包含三个值:

1
2
3
4
5
6
7
8
# test_parametrize.py

@pytest.mark.parametrize('passwd',
['123456',
'abcdefdfs',
'as52345fasdf4'])
def test_passwd_length(passwd):
assert len(passwd) >= 8

运行可知执行了三次测试:

1
2
3
4
5
6
7
8
9
$ pytest tests/test-function/test_parametrize.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 3 items

tests\test-function\test_parametrize.py F.. [100%]

================================== FAILURES ===================================

再看一个多参数的例子,用于校验用户密码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# test_parametrize.py

@pytest.mark.parametrize('user, passwd',
[('jack', 'abcdefgh'),
('tom', 'a123456a')])
def test_passwd_md5(user, passwd):
db = {
'jack': 'e8dc4081b13434b45189a720b77b6818',
'tom': '1702a132e769a623c1adb78353fc9503'
}

import hashlib

assert hashlib.md5(passwd.encode()).hexdigest() == db[user]

使用 -v 执行测试

1
2
3
4
5
6
7
8
9
10
11
$ pytest -v tests/test-function/test_parametrize.py::test_passwd_md5
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0 -- c:\anaconda3\python.exe
cachedir: .pytest_cache
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items

tests/test-function/test_parametrize.py::test_passwd_md5[jack-abcdefgh] PASSED [ 50%]
tests/test-function/test_parametrize.py::test_passwd_md5[tom-a123456a] PASSED [100%]

========================== 2 passed in 0.04 seconds ===========================

如果觉得每组测试的默认参数显示不清晰,我们可以使用 pytest.paramid 参数进行自定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# test_parametrize.py

@pytest.mark.parametrize('user, passwd',
[pytest.param('jack', 'abcdefgh', id='User<Jack>'),
pytest.param('tom', 'a123456a', id='User<Tom>')])
def test_passwd_md5_id(user, passwd):
db = {
'jack': 'e8dc4081b13434b45189a720b77b6818',
'tom': '1702a132e769a623c1adb78353fc9503'
}

import hashlib

assert hashlib.md5(passwd.encode()).hexdigest() == db[user]

现在的执行结果为:

1
2
3
4
5
6
7
8
9
10
11
$ pytest -v tests/test-function/test_parametrize.py::test_passwd_md5_id
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0 -- c:\anaconda3\python.exe
cachedir: .pytest_cache
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items

tests/test-function/test_parametrize.py::test_passwd_md5_id[User<Jack>] PASSED [ 50%]
tests/test-function/test_parametrize.py::test_passwd_md5_id[User<Tom>] PASSED [100%]

========================== 2 passed in 0.07 seconds ===========================

3、夹具

3.1 什么是夹具

夹具(Fixture)是一些函数,pytest 会在执行测试函数之前(或之后)加载运行它们。

我们可以利用夹具做任何事情,其中最常见的可能就是数据库的初始连接和最后关闭操作。

Pytest 使用 pytest.fixture() 定义夹具,下面是最简单的夹具,只返回北京邮编:

1
2
3
4
5
6
7
8
9
# test_postcode.py

@pytest.fixture()
def postcode():
return '010'


def test_postcode(postcode):
assert postcode == '010'

夹具可以直接定义在各测试脚本中,就像上面的例子。更多时候,我们希望一个夹具可以在更大程度上复用,这就需要对夹具进行集中管理。Pytest 使用文件 conftest.py 集中管理夹具。

在复杂的项目中,可以在不同的目录层级定义 conftest.py,其scope为其所在的目录和子目录。

*不要自己显式调用 conftest.py,pytest 会自动调用,可以把 conftest 当做插件来理解。*

3.2 setup和teardown

很多时候需要在测试前进行预处理(如新建数据库连接),并在测试完成进行清理(关闭数据库连接)。

当有大量重复的这类操作,最佳实践是使用夹具来自动化所有预处理和后处理。

Pytest 使用 yield 关键词将夹具分为两部分,yield 之前的代码属于预处理,会在测试前执行;yield 之后的代码属于后处理,将在测试完成后执行。

以下测试模拟数据库查询,使用夹具来模拟数据库的连接关闭:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# test_db.py

@pytest.fixture()
def db():
print('Connection successful')

yield

print('Connection closed')


def search_user(user_id):
d = {
'001': 'xiaoming'
}
return d[user_id]


def test_search(db):
assert search_user('001') == 'xiaoming'

执行时使用 -s 阻止消息被吞:

1
2
3
4
5
6
7
8
9
10
11
$ pytest -s tests/fixture/test_db.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item

tests\fixture\test_db.py Connection successful
.Connection closed


========================== 1 passed in 0.02 seconds ===========================

可以看到在测试成功的 . 标识前后有数据库的连接和关闭操作。

如果想更细的跟夹具执行,可以使用 --setup-show 选项:

1
2
3
4
5
6
7
8
9
10
11
12
$ pytest --setup-show tests/fixture/test_db.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item

tests\fixture\test_db.py
SETUP F db
tests/fixture/test_db.py::test_search (fixtures used: db).
TEARDOWN F db

========================== 1 passed in 0.03 seconds ===========================

3.3 scope使用

夹具的作用是为了抽离出重复的工作和方便复用,为了更精细化控制夹具(比如只想对数据库访问测试脚本使用自动连接关闭夹具),pytest 使用scope来进行指夹具的使用范围。

在定义夹具时,通过 scope 参数声明scope,可选项有:

  • function: 函数级,每个测试函数都会执行一次夹具;
  • class: 类级别,每个测试类执行一次,所有方法都可以使用;
  • module: 模块级,每个模块执行一次,模块内函数和方法都可使用;
  • session: 会话级,一次测试只执行一次,所有被找到的函数和方法都可用。

默认的scope为 function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@pytest.fixture(scope='function')
def func_scope():
pass


@pytest.fixture(scope='module')
def mod_scope():
pass


@pytest.fixture(scope='session')
def sess_scope():
pass


@pytest.fixture(scope='class')
def class_scope():
pass

最简单使用夹具方式是作为测试函数参数:

1
2
3
4
# test_scope.py

def test_multi_scope(sess_scope, mod_scope, func_scope):
pass

执行结果如下,可以清楚看到各夹具的scope和执行顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ pytest --setup-show tests/fixture/test_scope.py::test_multi_scope
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item

tests\fixture\test_scope.py
SETUP S sess_scope
SETUP M mod_scope
SETUP F func_scope
tests/fixture/test_scope.py::test_multi_scope (fixtures used: func_scope, mod_scope, sess_scope).
TEARDOWN F func_scope
TEARDOWN M mod_scope
TEARDOWN S sess_scope

========================== 1 passed in 0.10 seconds ===========================

对于类使用scope,需要使用 pytest.mark.usefixtures (对函数和方法也适用):

1
2
3
4
5
6
7
8
9
# test_scope.py

@pytest.mark.usefixtures('class_scope')
class TestClassScope:
def test_1(self):
pass

def test_2(self):
pass

执行结果如下,可见所有测试函数都在夹具的scope内:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ pytest --setup-show tests/fixture/test_scope.py::TestClassScope
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items

tests\fixture\test_scope.py
SETUP C class_scope
tests/fixture/test_scope.py::TestClassScope::()::test_1 (fixtures used: class_scope).
tests/fixture/test_scope.py::TestClassScope::()::test_2 (fixtures used: class_scope).
TEARDOWN C class_scope

========================== 2 passed in 0.03 seconds ===========================

3.4 自动执行夹具

目前为止,所有夹具的使用都是手动指定,或者作为参数,或者使用 usefixtures

如果我们想让夹具自动执行,可以在定义时指定 autouse 参数。

下面是两个自动计时夹具,一个用于统计每个函数运行时间(function),一个用于计算测试总耗时(session ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# test_autouse.py

DATE_FORMAT = '%Y-%m-%d %H:%M:%S'


@pytest.fixture(scope='session', autouse=True)
def timer_session_scope():
start = time.time()
print('\nstart: {}'.format(time.strftime(DATE_FORMAT, time.localtime(start))))

yield

finished = time.time()
print('finished: {}'.format(time.strftime(DATE_FORMAT, time.localtime(finished))))
print('Total time cost: {:.3f}s'.format(finished - start))


@pytest.fixture(autouse=True)
def timer_function_scope():
start = time.time()
yield
print(' Time cost: {:.3f}s'.format(time.time() - start))

注意下面的两个测试函数并都没有直接使用夹具:

1
2
3
4
5
6
def test_1():
time.sleep(1)


def test_2():
time.sleep(2)

执行测试可看到夹具自动执行并完成计时任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ pytest -s tests/fixture/test_autouse.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items

tests\fixture\test_autouse.py
start: 2018-06-12 10:16:27
. Time cost: 1.003s.
. Time cost: 2.003s.
finished: 2018-06-12 10:16:30
Total time cost: 3.016s.


========================== 2 passed in 3.11 seconds ===========================

3.5 夹具重命名

夹具的名称默认为定义时的函数名,如果不想使用默认,可以通过 name 选项指定名称:

1
2
3
4
5
6
7
8
9
# test_rename.py

@pytest.fixture(name='age')
def calculate_average_age():
return 28


def test_age(age):
assert age == 28

3.6 传递参数

在“第二部分 测试函数”中,介绍了函数的参数化测试:

1
2
3
4
5
6
7
8
# test-function/test_parametrize.py

@pytest.mark.parametrize('passwd',
['123456',
'abcdefdfs',
'as52345fasdf4'])
def test_passwd_length(passwd):
assert len(passwd) >= 8

因为夹具也是函数,我们同样可以对夹具进行参数化。在什么情况下需要对夹具参数化?

假设现在有一批 API 需要测试对不同数据库的支持情况(对所有数据库进行相同操作),最简单的方法就是针对每个数据库编写一个测试用例,但这包含大量重复代码,如数据库的连接、关闭,查询等。

进一步,可以使用夹具抽离出数据库的通用操作,每个 API 都能复用这些数据库夹具,同时可维护性也得到提升。

更进一步,可以继续将这些夹具合并为一个,而通过参数控制连接到不同的数据库。这就需要使用夹具参数化来实现。夹具参数化需要使用 pytest 内置的夹具 request,并通过 request.param 获取参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@pytest.fixture(params=[
('redis', '6379'),
('elasticsearch', '9200')
])
def param(request):
return request.param


@pytest.fixture(autouse=True)
def db(param):
print('\nSucceed to connect %s:%s' % param)

yield

print('\nSucceed to close %s:%s' % param)


def test_api():
assert 1 == 1

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ pytest -s tests/fixture/test_parametrize.py
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 2 items

tests\fixture\test_parametrize.py
Succeed to connect redis:6379
.
Succeed to close redis:6379

Succeed to connect elasticsearch:9200
.
Succeed to close elasticsearch:9200

========================== 2 passed in 0.10 seconds ===========================

与函数参数化使用 @pytest.mark.parametrize 不同,夹具在定义时使用 params 参数进行参数化。

夹具参数化依赖于内置夹具 request 及其属性 param

3.7 内置夹具

3.7.1 tmpdir & tmpdir_factory

用于临时文件和目录管理,默认会在测试结束时删除。

tmpdir 只有 function scope,只能在函数内使用。

使用 tmpdir.mkdir() 创建目临时录,tmpdir.join() 创建临时文件(或者使用创建的目录)。

1
2
3
4
5
6
7
def test_tmpdir(tmpdir):
a_dir = tmpdir.mkdir('mytmpdir')
a_file = a_dir.join('tmpfile.txt')

a_file.write('hello, pytest!')

assert a_file.read() == 'hello, pytest!'

tmpdir_factory 可以在所有scope使用,包括 function, class, module, session

1
2
3
4
5
6
7
8
@pytest.fixture(scope='module')
def my_tmpdir_factory(tmpdir_factory):
a_dir = tmpdir_factory.mktemp('mytmpdir')
a_file = a_dir.join('tmpfile.txt')

a_file.write('hello, pytest!')

return a_file

3.7.2 pytestconfig

使用 pytestconfig,可以很方便的读取命令行参数和配置文件。

下面示例演示命令行参数解析:首先在 conftest.py 中使用函数 pytest_addoptionhook function ):

1
2
3
4
5
# conftest.py

def pytest_addoption(parser):
parser.addoption('--host', action='store', help='host of db')
parser.addoption('--port', action='store', default='8888', help='port of db')

然后就可以在测试函数中通过 pytestconfig 获取命令行参数:

1
2
3
4
5
# test_config.py

def test_option1(pytestconfig):
print('host: %s' % pytestconfig.getoption('host'))
print('port: %s' % pytestconfig.getoption('port'))

pytestconfig 其实是 request.config 的快捷方式,所以也可以自定义夹具实现命令行参数读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# conftest.py

def pytest_addoption(parser):
parser.addoption('--host', action='store',
help='host of db')
parser.addoption('--port', action='store', default='8888',
help='port of db')


@pytest.fixture
def config(request):
return request.config


# test_config.py

def test_option2(config):
print('host: %s' % config.getoption('host'))
print('port: %s' % config.getoption('port'))

执行结果:

1
2
3
4
5
6
7
8
9
10
11
$ pytest -s --host=localhost tests/fixture/test_config.py::test_option2
============================= test session starts =============================
platform win32 -- Python 3.6.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0
rootdir: F:\self-repo\learning-pytest, inifile:
collected 1 item

tests\fixture\test_config.py host: localhost
port: 8888
.

========================== 1 passed in 0.06 seconds ===========================

3.7.3 capsys

capsys 用于捕获 stdoutstderr 的内容,并临时关闭系统输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# test_capsys.py

def ping(output):
print('Pong...', file=output)


def test_stdout(capsys):
ping(sys.stdout)
out, err = capsys.readouterr()
assert out == 'Pong...\n'
assert err == ''


def test_stderr(capsys):
ping(sys.stderr)
out, err = capsys.readouterr()
assert out == ''
assert err == 'Pong...\n'

3.7.4 monkeypatch

monkeypath 用于运行时动态修改类或模块。

在 Python 中,“monkeypatch ”仅指在运行时对类或模块进行的动态修改,来修补现有的第三方代码,以解决与期望不符的 bug 或功能。

一个简单的 monkeypatch 如:

1
2
3
4
5
6
7
from SomeOtherProduct.SomeModule import SomeClass

def new_speak(self):
return "ook ook eee eee eee!"

# 替换spesk方法
SomeClass.speak = new_speak

Pytest 内置 monkeypatch 提供的函数有:

  • setattr(target, name, value, raising=True),设置属性;
  • delattr(target, name, raising=True),删除属性;
  • setitem(dic, name, value),字典添加元素;
  • delitem(dic, name, raising=True),字典删除元素;
  • setenv(name, value, prepend=None),设置环境变量;
  • delenv(name, raising=True),删除环境变量;
  • syspath_prepend(path),添加系统路径;
  • chdir(path),切换目录。

其中 raising 用于通知 pytest 在元素不存在时是否抛出异常;prepend 如果设置,环境变量将变为 value+prepend+

下面使用保存配置文件示例说明 monkeypatch 的作用和使用。

假设我们需要切换某个服务到国内科大源以加速,有以下脚本用于修改配置文件 .conf.json

1
2
3
4
5
6
7
8
9
10
11
12
13
# test_monkeypatch.py

def dump_config(config):
path = os.path.expanduser('~/.conf.json')
with open(path, 'w', encoding='utf-8') as wr:
json.dump(config, wr, indent=4)


def test_config():
dump_config(config)
path = os.path.expanduser('~/.conf.json')
expected = json.load(open(path, 'r', encoding='utf-8'))
assert expected == config

似乎测试正常执行完全没有问题,但如果我们的家目录下恰好有这个配置文件并且维护了许多配置,运行测试将会覆盖原有配置,这太可怕了!

所以我们需要修改测试,最好能在临时目录里完成。但程序已经写死了文件路径,怎么办?

这种在运行时控制程序的功能就需要 monkeypatch 来实现,下面在测试过程中修改了环境变量:

1
2
3
4
5
6
7
8
9
# test_monkeypatch.py

def test_config_monkeypatch(tmpdir, monkeypatch):
monkeypatch.setenv('HOME', tmpdir.mkdir('home'))

dump_config(config)
path = os.path.expanduser('~/.conf.json')
expected = json.load(open(path, 'r', encoding='utf-8'))
assert expected == config

现在测试会来临时目录中执行,但环境变量可能对系统有依赖,所以更好的解决方法能自己控制路径中 ~ 的替换,这次通过改变 os.path.expanduser 的行为来实现:

1
2
3
4
5
6
7
8
9
10
# test_monkeypatch.py

def test_config_monkeypatch2(tmpdir, monkeypatch):
fake_home = tmpdir.mkdir('home')
monkeypatch.setattr(os.path, 'expanduser',
lambda x: x.replace('~', str(fake_home)))
dump_config(config)
path = os.path.expanduser('~/.conf.json')
expected = json.load(open(path, 'r', encoding='utf-8'))
assert expected == config

3.7.5 recwarn

recwarn 用于捕获程序中 warnings 产生的警告。

1
2
3
4
5
6
7
8
9
10
11
# test_recwarn.py

def warn():
warnings.warn('Deprecated function', DeprecationWarning)


def test_warn(recwarn):
warn()
assert len(recwarn) == 1
w = recwarn.pop()
assert w.category == DeprecationWarning

此外,pytest 可以使用 pytest.warns() 捕获警告:

1
2
3
4
5
6
7
def test_warn2():
with pytest.warns(None) as warnings:
warn()

assert len(warnings) == 1
w = warnings.pop()
assert w.category == DeprecationWarning

4、钩子函数

钩子函数允许在测试执行过程中的特定阶段注入逻辑,根据测试时间修改或者扩展测试的行为。通常钩子函数写在conftest.py文件里面

4.1 界定自定义装饰符

自定义pytest_configure接收一个参数config和一个pytest.config对象,来代表配置信息。他不会返回任何内容,但是可以使用config对象来访问和修改Pytest配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# tests/example1/conftest.py
def pytest_configure(config):
config.addinivalue_line("markers", "ui: mark test as a UI test")
config.addinivalue_line("markers", "api: mark test as an API test")
# tests/example1/test_pytest_configure_markers.py
import pytest

@pytest.mark.ui # Define the marker for the test
def test_ui_component():
# code for testing UI component
print("Testing UI component")

@pytest.mark.api # Define the marker for the test
def test_api_call():
# code for testing API call
print("Testing API call")

在上述代码中test_ui_componenttest_api_call分别被装饰为uiapi

如果想仅运行ui测试,可以通过-m标识符并指定标识名称。

1
pytest -m ui -v

4.2 共享全局变量

通过pytest_configure可以定义在测试中共享的全局变量。

1
2
3
# tests/example2/conftest.py
def pytest_configure(config):
config.my_global_data = "Shared Value"

上述代码定义了一个值为Shared Value的全局变量my_global_data,这个全局变量可以使用request夹具通过config对象访问。

1
2
3
4
5
6
7
8
# tests/example2/test_pytest_configure_global_var.py
import pytest

def test_example(request):
# Retrieve the global variable from the pytest configuration
global_value = request.config.my_global_variable

print(f"The global variable is: {global_value}")

4.3 访问命令行选项

我们可以通过pytest_addoption来访问命令行输入的参数。

1
2
3
4
5
6
# tests/example3/conftest.py
def pytest_addoption(parser):
parser.addoption("--custom-option", action="store", default="default")

def pytest_configure(config):
config.custom_option = config.getoption("--custom-option")

parser.addoption定义了一个新的命令行选项。在pytest_configure中,我们传递了命令行参数--custom-option myValue。总结来说,parser.addoption用来从命令行获取参数,pytest_configure用来将命令行中获取参数变成全局变量。

1
2
3
4
5
6
7
8
9
10
# name:选项名称
# action:选项行为,上例中的store表示为存储值
# default:默认值,如果用户灭有出阿迪选项,就使用这个值
# help(可选):描述选项的用途,显示在pytest --help中
parser_addoption(name,action,default,help)
# tests/example3/test_pytest_configure_cmd.py
import pytest

def test_custom_option(pytestconfig):
assert pytestconfig.custom_option == "myValue" # or any other value passed via command line

上述代码使用pytestconfig可以直接访问全局变量

命令行输入:

1
pytest --custom-option=myValue