文章目录
背景:最近在开发RT-Thread 的一键部署AI模型框架,可以无缝的将AI模型一键移植到RT-Thread 系统上面。待开源…
用 python 写了一堆代码,每次老大问起的时候
“小陈啊,进度怎么样了啊?”
我:“快了快了,不出bug的话今天下午能搞完”
过了两天,“小陈啊,进度怎么样了啊?”
我:“功能还差最后一部分啊”
“测试写了吗?”
我:…
后来我才知道,老大的意思是,问我测试完成几个部分了,而不是功能完成多少了,哎,吃了书读少了的亏
测试驱动开发!万岁!
简单介绍了一下三个python常用的框架,以及 “pytest 的简版使用教程”
最终选择的是pytest
, 理由是集成了unittest
和 强大的社区生态、插件
快速上手 pytest
不是梦,看完自己能编写一个例程!
1. unittest
unittest
支持自动化测试,测试用例的初始化和关闭,测试用例的聚合等功能。unittest
有一个很重要的特性:它通过类(class)的方式,将测试用例组织在一起。
import unittest
class TestStringMethods(unittest.TestCase):
def test_upper(self):
self.assertEqual('foo'.upper(), 'FOO')
if __name__ == '__main__':
unittest.main()
运行单元测试:
# 1. 上述代码保存为 test.py
python test.py
# 2. (推荐)一次批量运行很多单元测试
python -m unittest test
# 输出结果
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
2. nose
先安装:
pip3 install nose
任何函数和类,只要名称匹配一定的条件(例如,以test开头或以test结尾等),都会被自动识别为测试用例,兼容unittest
一个简单的nose单元测试示例如下:
import nose
def test_example ():
pass
if __name__ == '__main__':
nose.runmodule()
~ » python -m unittest test
----------------------------------------------------------------------
Ran 0 tests in 0.000s
OK
3. pytest
3A 原则:
Arrange 测试数据
Act 调用被测方法
Assert 断言
pytest是Python另一个第三方单元测试库。它的目的是让单元测试变得更容易,并且也能扩展到支持应用层面复杂的功能测试。
pytest的特性有:
1)支持用简单的assert语句实现丰富的断言,无需复杂的self.assert*函数
2)自动识别测试模块和测试函数
3)兼容unittest和nose测试集
4)支持Python3和PyPy3
5)丰富的插件生态,已有300多个各式各样的插件,和活跃的社区
pytest一个简单的示例如下:
def inc(x):
return x +1
def test_answer():
assert inc(3) ==5
文件储存为 test_xpytest.py
,在当前目录下执行 pytest
执行结果如下:
~/Templates » pytest
============================= test session starts ==============================
platform linux -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/lebhoryi/Templates
plugins: doctestplus-0.5.0, remotedata-0.3.2, astropy-header-0.1.2, arraydiff-0.3, hypothesis-5.5.4, openfiles-0.4.0
collected 1 item
test_answer.py F [100%]
=================================== FAILURES ===================================
_________________________________ test_answer __________________________________
def test_answer():
> assert inc(3) ==5
E assert 4 == 5
E + where 4 = inc(3)
test_answer.py:5: AssertionError
============================== 1 failed in 0.02s ===============================
以下是测试功能的可能结果:
-
PASSED (.):测试成功。
-
FAILED (F):测试失败(或XPASS + strict)。
-
SKIPPED (s): 测试被跳过。
你可以使用@pytest.mark.skip()或 pytest.mark.skipif()修饰器告诉pytest跳过测试
-
xfail (x):预期测试失败。@pytest.mark.xfail()
-
XPASS (X):测试不应该通过。
-
ERROR (E):错误
3.1 进阶 - fixture1
准备测试数据和初始化测试对象
假设外部存在user.dev.json
文件,内容如下:
[
{"name":"jack","password":"Iloverose"},
{"name":"rose","password":"Ilovejack"},
{"name":"tom","password":"password123"}
]
新建名为 test_user_password.py
的文件:
import pytest
import json
class TestUserPassword(object):
@pytest.fixture
def users(self):
return json.loads(open('./users.dev.json', 'r').read()) # 读取当前路径下的users.dev.json文件,返回的结果是dict
def test_user_password(self, users):
# 遍历每条user数据
for user in users:
passwd = user['password']
assert len(passwd) >= 6
msg = "user %s has a weak password" %(user['name'])
assert passwd != 'password', msg
assert passwd != 'password123', msg
运行:
pytest test_user_password.py
结果:
~/Templates » pytest test_user_password.py
============================= test session starts ==============================
platform linux -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/lebhoryi/Templates
plugins: doctestplus-0.5.0, remotedata-0.3.2, astropy-header-0.1.2, arraydiff-0.3, hypothesis-5.5.4, openfiles-0.4.0
collected 1 item
test_user_password.py F [100%]
=================================== FAILURES ===================================
_____________________ TestUserPassword.test_user_password ______________________
self = <test_user_password.TestUserPassword object at 0x7fd261cb5cd0>
users = [{'name': 'jack', 'password': 'Iloverose'}, {'name': 'rose', 'password': 'Ilovejack'}, {'name': 'tom', 'password': 'password123'}]
def test_user_password(self, users):
# 遍历每条user数据
for user in users:
passwd = user['password']
assert len(passwd) >= 6
msg = "user %s has a weak password" %(user['name'])
assert passwd != 'password', msg
> assert passwd != 'password123', msg
E AssertionError: user tom has a weak password
E assert 'password123' != 'password123'
test_user_password.py:16: AssertionError
============================== 1 failed in 0.02s ===============================
数据清理:
import smtplib
import pytest
@pytest.fixture(scope="module")
def smtp():
smtp = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
yield smtp # provide the fixture value
print("teardown smtp")
smtp.close()
3.2 进阶 - fixture2 参数化
3.1 进阶 - fixture1 中的 fixture
遇到一个错误案例就弹出,现在改进,执行所有的测试案例
假设外部存在user.dev.json
文件,内容如下:
[
{"name":"jack","password":"Iloverose"},
{"name":"rose","password":"Ilovejack"}
{"name":"tom","password":"password123"},
{"name":"mike","password":"password"},
{"name":"james","password":"AGoodPasswordWordShouldBeLongEnough"}
]
新建文件test_user_password_with_params.py
,内容如下:
import pytest
import json
class TestUserPasswordWithParam(object):
@pytest.fixture(params=json.loads(open('./users.test.json', 'r').read()))
def user(self, request):
return request.param
def test_user_password(self, user):
passwd = user['password']
assert len(passwd) >= 6
msg = "user %s has a weak password" %(user['name'])
assert passwd != 'password', msg
assert passwd != 'password123', msg
注意:
@pytest.fixture(params=list)
存在,会使得程序执行len(params)
次- 通过
request.param
来读取参数
~/Templates » pytest test_user_password_with_params.py
============================= test session starts ==============================
platform linux -- Python 3.7.6, pytest-5.3.5, py-1.8.1, pluggy-0.13.1
rootdir: /home/lebhoryi/Templates
plugins: doctestplus-0.5.0, remotedata-0.3.2, astropy-header-0.1.2, arraydiff-0.3, hypothesis-5.5.4, openfiles-0.4.0
collected 5 items
test_user_password_with_params.py ..FF. [100%]
=================================== FAILURES ===================================
_____________ TestUserPasswordWithParam.test_user_password[user2] ______________
self = <test_user_password_with_params.TestUserPasswordWithParam object at 0x7f141f423190>
user = {'name': 'tom', 'password': 'password123'}
def test_user_password(self, user):
passwd = user['password']
assert len(passwd) >= 6
msg = "user %s has a weak password" %(user['name'])
assert passwd != 'password', msg
> assert passwd != 'password123', msg
E AssertionError: user tom has a weak password
E assert 'password123' != 'password123'
test_user_password_with_params.py:14: AssertionError
_____________ TestUserPasswordWithParam.test_user_password[user3] ______________
self = <test_user_password_with_params.TestUserPasswordWithParam object at 0x7f141f55c850>
user = {'name': 'mike', 'password': 'password'}
def test_user_password(self, user):
passwd = user['password']
assert len(passwd) >= 6
msg = "user %s has a weak password" %(user['name'])
> assert passwd != 'password', msg
E AssertionError: user mike has a weak password
E assert 'password' != 'password'
test_user_password_with_params.py:13: AssertionError
========================= 2 failed, 3 passed in 0.03s ==========================
~/Templates »
另外一种执行所有测试案例得方法:
# test_parametrize.py
@pytest.mark.parametrize('passwd',
['123456',
'abcdefdfs',
'as52345fasdf4'])
def test_passwd_length(passwd):
assert len(passwd) >= 8
3.3 进阶3 - 生成 xml格式得测试报告
生成junit
格式的xml
报告
pytest test_quick_start.py --junit-xml=report.xml
3.4 进阶4 - 标记
标记已经完成开发得功能函数和未完成得函数
# test_no_mark.py
def test_func1():
assert 1 == 1
def test_func2():
assert 1 != 1
-
第一种,显式指定函数名,通过
::
标记pytest tests/test-function/test_no_mark.py::test_func1
-
第二种,使用模糊匹配,使用
-k
选项标识。pytest -k func1 tests/test-function/test_no_mark.py
-
第三种,使用
pytest.mark
在函数上进行标记。# test_with_mark.py @pytest.mark.finished def test_func1(): assert 1 == 1 @pytest.mark.unfinished def test_func2(): assert 1 != 1
测试时使用
-m
选择标记的测试函数:$ pytest -m finished tests/test-function/test_with_mark.py
3.5 进阶5 - 跳过测试
Pytest
使用特定的标记 pytest.mark.skip
完美的解决了这个问题。
# test_skip.py
@pytest.mark.skip(reason='out-of-date api')
def test_connect():
pass
Pytest
还支持使用 pytest.mark.skipif
为测试函数指定被忽略的条件。
@pytest.mark.skipif(conn.__version__ < '0.2.0',
reason='not supported until v0.2.0')
def test_api():
pass
3.6 进阶6 - 外部传参
新建coftest.py
文件
-
conftest.py
文件名字是固定的,不可以做任何修改 -
文件和用例文件在同一个目录下,那么
conftest.py
作用于整个目录 -
conftest.py
文件所在目录必须存在__init__.py文件 -
conftest.py
文件不能被其他文件导入 -
所有同目录测试文件运行前都会执行
conftest.py
文件
# content of conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption(
"--cmdopt", action="store", default="type1", help="my option: type1 or type2"
)
@pytest.fixture
def cmdopt(request):
return request.config.getoption("--cmdopt")
编写测试用例
import pytest
# content of test_sample.py
def test_answer(cmdopt):
if cmdopt == "type1":
print("first")
elif cmdopt == "type2":
print("second")
assert 0 # to see what was printed
if __name__ == '__main__':
# 使用参数
pytest.main(['-s', '-q', __file__])
input()
3.7 进阶7 - 测试前运行函数和测试后运行函数
在某些情况下,需要创建临时工作文件夹,就需要用到这个代码了
def setup_function():
print("setip_function(): 每个函数之前执行")
def teardown_function():
print("\nteardown_function(): 每个函数之后执行\n")
3.8 进阶8 - 设置环境变量
在某些情况下,需要自定义一些参数,比如mdk5 路径,
但是,在测试中,使用外部传参的方法是及其不可取的,正确的做法是:
设置环境变量:插件 pytest-env
-
新建一个
pytest.ini
的文件 -
写入相关环境变量
[pytest] env = MDK_PATH=D:/Program Files (x86)/Keil_v5
-
在对应的测试文件中使用
os.getenv("MDK_PATH") # or os.environ["MDK_PATH"]
掌握了上述几个技巧差不多就可以使用pytest
编写测试案例了,战斗吧,骚年!
(剩下你只需要实战即可)
4. Pytest 使用方法简版实战
update 2020/11/25
- add setup and teardown
- split test case
0. 测试等级参考
- Level1:正常流程可用,即一个函数在输入正确的参数时,会有正确的输出
- Level2:异常流程可抛出逻辑异常,即输入参数有误时,不能抛出系统异常,而是用自己定义的逻辑异常通知上层调用代码其错误之处
- Level3:极端情况和边界数据可用,对输入参数的边界情况也要单独测试,确保输出是正确有效的
- Level4:所有分支、循环的逻辑走通,不能有任何流程是测试不到的
- Level5:输出数据的所有字段验证,对有复杂数据结构的输出,确保每个字段都是正确的
作者:gashero
链接:各位都是怎么进行单元测试的? - gashero的回答
1. 测试样例 - pytest_test.py
先安装pytest:
$ pip install pytest pytest-env pytest-html -i https://pypi.doubanio.com/simple
'''
@ Summary: pytest 简版教程
@ Update:
@ file: pytest_test.py
@ version: 1.0.0
@ Author: Lebhoryi@gmail.com
@ Date: 2020/11/19 15:54
'''
import pytest
# def setup_function():
# print("setip_function(): 每个函数之前执行")
# def teardown_function():
# print("\nteardown_function(): 每个函数之后执行\n")
def division(a, b):
return int(a / b)
@pytest.mark.parametrize('a, b, c',
[(4, 2, 2), (0, 2, 0), (1, 0, 0), (6, 8, 0), (4, 2, 3)],
ids=['整除', '被除数为0', '除数为0', '非整除', '整除失败'])
def test_1(a, b, c):
'''使用 try...except... 接收异常'''
try:
res = division(a, b)
except Exception as e:
print('\n发生异常,异常信息{},进行异常断言\n'.format(e))
assert str(e) == 'division by zero'
else:
assert res == c
@pytest.mark.parametrize('a, b, c',
[(4, 2, 2), (0, 2, 0), (1, 0, 0), (6, 8, 0), (4, 2, 3)],
ids=['整除', '被除数为0', '除数为0', '非整除', '整除失败'])
def test_2(a, b, c):
'''使用 pytest.raises 接收异常'''
if b == 0:
with pytest.raises(ZeroDivisionError) as e:
division(a, b)
# 断言异常 type
assert e.type == ZeroDivisionError
# 断言异常 value,value 是 tuple 类型
assert "division by zero" in e.value.args[0]
else:
assert division(a, b) == c
def test_read_ini(tmpdir):
print(tmpdir) # /private/var/folders/ry/z60xxmw0000gn/T/pytest-of-gabor/pytest-14/test_read0
if __name__ == '__main__':
pytest.main(['-s', '-q', f"{__file__}"])
# 倒计时60s
import time
for i in range(60, 0, -1):
print("\r{}秒之后自动关闭窗口!".format(i), end="", flush=False)
time.sleep(1)
- 测试数据:
('a, b, c',
[(4, 2, 2), (0, 2, 0), (1, 0, 0), (6, 8, 0), (4, 2, 3)],
ids=['整除', '被除数为0', '除数为0', '非整除', '整除失败'])
- 测试函数:
def division(a, b):
return int(a / b)
- 测试代码解读:
-
test_1:
执行测试函数
division(a, b)
,如果有异常,且异常内容为 ‘division by zero’, 则测试通过
否则断言测试函数结果是否等于
c
,若等于,则测试通过,若不等于,测试失败 -
test_2:
当
b == 0
时,测试函数division(a, b)
抛出ZeroDivisionError
,且division by zero
字符串在异常中时,测试通过当
b != 0
时,断言测试函数结果是否等于c
,若等于,则测试通过,若不等于,测试失败
-
1. 双击测试代码
对于不同的测试代码文件,直接双击代码文件。
比如当前文件夹下面存在 pytest_test.py,直接双击,会出现如下界面:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LiaLX5CN-1607132458177)(https://gitee.com/lebhoryi/PicGoPictureBed/raw/master/img/20201119160837.png ‘pytest.py::test_1’)]
-
测试结果:(
F
表示失败,.
表示测试用例通过,在下方会显示失败的结果)可以看到,前四个测试案例,尽管有一个案例中除数为0,但是测试代码中指定了异常,所以仍能通过测试,
可是最后一个案例测试失败,说明代码写的不完美,可能是异常情况考虑不周全,
完美的理想状态是通过所有的测试用例,即有异常也在指定范围内
这里仅为pytest
使用教程,所以不展开讨论
2. 命令行使用
pytest pytest_test.py::test_1 # :: 是指定测试test_1
运行结果和上面一样,
执行pytest 会自动运行该路径下面所有
test
开头或者结尾的文件。
这里要说的是第二点,查看详细报告 和 导出html
或者 xml
测试报告
-
查看详细报告
pytest pytest_test.py::test_1 -v
================================================= test session starts ================================================= platform win32 -- Python 3.7.9, pytest-6.1.2, py-1.9.0, pluggy-0.13.1 -- c:\users\12813\appdata\local\programs\python\python37\python.exe cachedir: .pytest_cache metadata: {'Python': '3.7.9', 'Platform': 'Windows-10-10.0.18362-SP0', 'Packages': {'pytest': '6.1.2', 'py': '1.9.0', 'pluggy': '0.13.1'}, 'Plugins': {'html': '3.0.0', 'metadata': '1.10.0'}} rootdir: D:\Project\utils\test plugins: html-3.0.0, metadata-1.10.0 collected 5 items pytest_test.py::test_1[\u6574\u9664] PASSED [ 20%] pytest_test.py::test_1[\u88ab\u9664\u6570\u4e3a0] PASSED [ 40%] pytest_test.py::test_1[\u9664\u6570\u4e3a0] PASSED [ 60%] pytest_test.py::test_1[\u975e\u6574\u9664] PASSED [ 80%] pytest_test.py::test_1[\u6574\u9664\u5931\u8d25] FAILED [100%] ====================================================== FAILURES ======================================================= __________________________________________ test_1[\u6574\u9664\u5931\u8d25] ___________________________________________ a = 4, b = 2, c = 3 @pytest.mark.parametrize('a, b, c', [(4, 2, 2), (0, 2, 0), (1, 0, 0), (6, 8, 0), (4, 2, 3)], ids=['整除', '被除数为0', '除数为0', '非整除', '整除失败']) def test_1(a, b, c): '''使用 try...except... 接收异常''' try: res = division(a, b) except Exception as e: print('\n发生异常,异常信息{},进行异常断言\n'.format(e)) assert str(e) == 'division by zero' else: > assert res == c E assert 2 == 3 E +2 E -3 pytest_test.py:31: AssertionError =============================================== short test summary info =============================================== FAILED pytest_test.py::test_1[\u6574\u9664\u5931\u8d25] - assert 2 == 3 ============================================= 1 failed, 4 passed in 0.06s =============================================
-
xml
pytest pytest_test.py::test_1 --junitxml=test.xml
-
html
# install pip install pytest-html pytest pytest_test.py::test_1 --html=test.html
5. 参考文献
最后的最后,欢迎各位来参加我们的开发者大会呀