python的测试框架 - pytest 简版教程

背景:最近在开发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 原则:

  1. Arrange 测试数据

  2. Act 调用被测方法

  3. 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

注意:

  1. @pytest.fixture(params=list) 存在,会使得程序执行len(params)
  2. 通过 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
  1. 第一种,显式指定函数名,通过 :: 标记

    pytest tests/test-function/test_no_mark.py::test_func1
    
  2. 第二种,使用模糊匹配,使用 -k 选项标识。

    pytest -k func1 tests/test-function/test_no_mark.py
    
  3. 第三种,使用 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文件

  1. conftest.py文件名字是固定的,不可以做任何修改

  2. 文件和用例文件在同一个目录下,那么conftest.py作用于整个目录

  3. conftest.py文件所在目录必须存在__init__.py文件

  4. conftest.py文件不能被其他文件导入

  5. 所有同目录测试文件运行前都会执行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

  1. 新建一个 pytest.ini 的文件

  2. 写入相关环境变量

    [pytest]
    env =
        MDK_PATH=D:/Program Files (x86)/Keil_v5
    
  3. 在对应的测试文件中使用

    os.getenv("MDK_PATH")
    # or
    os.environ["MDK_PATH"]
    

掌握了上述几个技巧差不多就可以使用pytest编写测试案例了,战斗吧,骚年!

(剩下你只需要实战即可)

4. Pytest 使用方法简版实战

update 2020/11/25

  1. add setup and teardown
  2. split test case

0. 测试等级参考

  1. Level1:正常流程可用,即一个函数在输入正确的参数时,会有正确的输出
  2. Level2:异常流程可抛出逻辑异常,即输入参数有误时,不能抛出系统异常,而是用自己定义的逻辑异常通知上层调用代码其错误之处
  3. Level3:极端情况和边界数据可用,对输入参数的边界情况也要单独测试,确保输出是正确有效的
  4. Level4:所有分支、循环的逻辑走通,不能有任何流程是测试不到的
  5. 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’)]

仅测试 pytest_test.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. 参考文献

最后的最后,欢迎各位来参加我们的开发者大会呀
在这里插入图片描述

  • 2
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值