第5章 pytest的相关插件及插件管理 第5章pytest的相关插件及插件管理 5.1pytest的插件安装 pytest不仅是一个功能强大的测试框架,同时还是一个插件化的测试平台。插件的使用方法与功效 各有千秋,它们的共同特点是只需配置就可以直接使用,而不需要测试代码配合。 安装和卸载第三方插件的命令如下: pip install pytest-NAME pip uninstall pytest-NAME 如果安装了插件,pytest则可以自动查找并集成它,而无须激活它。 5.2常见插件介绍 pytestxdist: 将测试分发到CPU和远程主机,以盒装模式运行,该模式可以保留分段错误,以looponfail模式运行,并根据文件更改自动重新运行失败的测试。 pytestinstafail: 在测试运行时报告失败。 pytestbdd: 使用行为驱动的测试编写测试。在第11章将详细介绍。 pytesttimeout: 基于功能标记或全局定义的超时测试。 pytestpep8: 启用PEP8符合性检查的选项。 pytestflakes: 使用pyflakes检查源代码。 pytestdjango: 使用pytest集成为Django应用编写测试。在本书中未介绍,大家可自行学习。 pytestcov: 覆盖率报告,与分布式测试兼容。 要查看所有插件的完整列表,以及它们针对不同的pytest和Python版本的最新测试状态,可访问http://plugincompat.herokuapp.com/,或搜索https://pypi.org/search/?q=pytest。 5.3常用插件的使用 按字母顺序简单介绍常用插件的使用。 5.3.1pytestassume断言报错后依然执行 pytest的断言失败后,后面的代码就不会执行了,通常在一个用例中我们会写多个断言,有时候我们希望第一个断言失败后,后面能继续断言。pytestassume插件可以解决断言失败后继续执行后面断言的问题。 环境准备: 先安装pytestassume依赖包,此插件存在与pytest和Python的兼容性问题。 pip install pytest-assume 有些断言是并行的,我们同样想知道其他断言执行结果。举例说明,输入的测试数据有3种,我们需要断言同时 满足3种情况: x==y,x+y>1,x>1,这3个条件是并行的。 代码如下: import pytest @pytest.mark.parametrize(('x', 'y'), [(1, 1), (1, 0), (0, 1)]) def test_simple_assume(x, y): print("测试数据x=%s, y=%s" % (x, y)) assert x == y assert x+y > 1 assert x > 1 未导入插件前运行的结果如下: 遇到第一个错误就停下来,当x=1,y=1时,x==y的断言通过了,x+y>1的断言也通过了,由于错误便停在x>1的断言上。当x=1,y=0时,在第一个断言x==y时就停止了。执行结果如下: F测试数据x=1, y=1 src/chapter5/test_assume.py: 12 (test_simple_assume[1-1]) x = 1, y = 1 @pytest.mark.parametrize(('x', 'y'), [(1, 1), (1, 0), (0, 1)]) def test_simple_assume(x, y): print("测试数据x=%s, y=%s" % (x, y)) assert x == y assert x+y > 1 > assert x > 1 E assert 1 > 1 test_assume.py: 19: AssertionError F测试数据x=1, y=0 src/chapter5/test_assume.py: 12 (test_simple_assume[1-0]) 1 != 0 Expected : 0 Actual : 1 x = 1, y = 0 @pytest.mark.parametrize(('x', 'y'), [(1, 1), (1, 0), (0, 1)]) def test_simple_assume(x, y): print("测试数据x=%s, y=%s" % (x, y)) > assert x == y E assert 1 == 0 test_assume.py: 17: AssertionError F测试数据x=0, y=1 src/chapter5/test_assume.py: 12 (test_simple_assume[0-1]) 0 != 1 Expected : 1 Actual : 0 x = 0, y = 1 @pytest.mark.parametrize(('x', 'y'), [(1, 1), (1, 0), (0, 1)]) def test_simple_assume(x, y): print("测试数据x=%s, y=%s" % (x, y)) > assert x == y E assert 0 == 1 test_assume.py: 17: AssertionError 导入插件并修改代码如下: import pytest @pytest.mark.parametrize(('x', 'y'), [(1, 1), (1, 0), (0, 1)]) def test_simple_assume1(x, y): print("测试数据x=%s, y=%s" % (x, y)) pytest.assume(x == y) pytest.assume(x+y > 1) pytest.assume(x > 1) print("测试完成!") 导入插件后执行的结果如下,此时出现错误后不会停止执行,但会统计错误的次数,例如,Failed Assumptions: 3,(1,1)的这组数据中的结果就是Failed Assumptions: 1。 F测试数据x=1, y=1 测试完成! src/chapter5/test_assume.py: 21 (test_simple_assume1[1-1]) test_assume.py: 28: AssumptionFailure pytest.assume(x > 1) ------------------------------------------------------------ Failed Assumptions: 1 F测试数据x=1, y=0 测试完成! src/chapter5/test_assume.py: 21 (test_simple_assume1[1-0]) test_assume.py: 26: AssumptionFailure pytest.assume(x == y) test_assume.py: 27: AssumptionFailure pytest.assume(x+y > 1) test_assume.py: 28: AssumptionFailure pytest.assume(x > 1) ------------------------------------------------------------ Failed Assumptions: 3 F测试数据x=0, y=1 测试完成! src/chapter5/test_assume.py: 21 (test_simple_assume1[0-1]) test_assume.py: 26: AssumptionFailure pytest.assume(x == y) test_assume.py: 27: AssumptionFailure pytest.assume(x+y > 1) test_assume.py: 28: AssumptionFailure pytest.assume(x > 1) ------------------------------------------------------------ Failed Assumptions: 3 还可以使用with上下文管理器编写,代码如下: import pytest #笔者的assume版本是2.3.3 from pytest_assume.plugin import assume @pytest.mark.parametrize(('x', 'y'), [(1, 1), (1, 0), (0, 1)]) def test_simple_assume_with(x, y): print("测试数据x=%s, y=%s" % (x, y)) with assume: assert x == y with assume: assert x+y > 1 with assume: assert x > 1 print("测试完成!") 执行结果如下: F测试数据x=1, y=1 测试完成! src/chapter5/test_assume.py: 33 (test_simple_assume_with[1-1]) x = 1, y = 1 @pytest.mark.parametrize(('x', 'y'), [(1, 1), (1, 0), (0, 1)]) def test_simple_assume_with(x, y): print("测试数据x=%s, y=%s" % (x, y)) with assume: assert x == y with assume: assert x+y > 1 > with assume: assert x > 1 E pytest_assume.plugin.FailedAssumption: E 1 Failed Assumptions: E E test_assume.py: 40: AssumptionFailure E >>with assume: assert x > 1 E AssertionError: assert 1 > 1 test_assume.py: 40: FailedAssumption F测试数据x=1, y=0 测试完成! src/chapter5/test_assume.py: 33 (test_simple_assume_with[1-0]) x = 1, y = 0 @pytest.mark.parametrize(('x', 'y'), [(1, 1), (1, 0), (0, 1)]) def test_simple_assume_with(x, y): print("测试数据x=%s, y=%s" % (x, y)) with assume: assert x == y with assume: assert x+y > 1 > with assume: assert x > 1 E pytest_assume.plugin.FailedAssumption: E 3 Failed Assumptions: E E test_assume.py: 38: AssumptionFailure E >>with assume: assert x == y E AssertionError: assert 1 == 0 E E test_assume.py: 39: AssumptionFailure E >>with assume: assert x+y > 1 E AssertionError: assert (1 + 0) > 1 E E test_assume.py: 40: AssumptionFailure E >>with assume: assert x > 1 E AssertionError: assert 1 > 1 test_assume.py: 40: FailedAssumption F测试数据x=0, y=1 测试完成! src/chapter5/test_assume.py: 33 (test_simple_assume_with[0-1]) x = 0, y = 1 @pytest.mark.parametrize(('x', 'y'), [(1, 1), (1, 0), (0, 1)]) def test_simple_assume_with(x, y): print("测试数据x=%s, y=%s" % (x, y)) with assume: assert x == y with assume: assert x+y > 1 > with assume: assert x > 1 E pytest_assume.plugin.FailedAssumption: E 3 Failed Assumptions: E E test_assume.py: 38: AssumptionFailure E >>with assume: assert x == y E AssertionError: assert 0 == 1 E E test_assume.py: 39: AssumptionFailure E >>with assume: assert x+y > 1 E AssertionError: assert (0 + 1) > 1 E E test_assume.py: 40: AssumptionFailure E >>with assume: assert x > 1 E AssertionError: assert 0 > 1 test_assume.py: 40: FailedAssumption 整体运行结果如图51所示。 图51pytestassume整体执行效果图 5.3.2pytestcov 测试覆盖率 pytestcov是自动检测测试覆盖率的一个插件,在测试中被广泛应用。提到覆盖率,先介绍一下Python自 带的代码覆盖率的命令行检测工具coverage.py。它监视你的程序,并指出代码的哪些部分已执行,然后分析源代码以识别可能已执行但尚未执行的代码。要理解pytestcov首先要了解coverage这个命令行工具。 1. coverage coverage在覆盖率中是语句覆盖的一种,是白盒测试中最低级的用例设计方法和要求,还有分支覆盖、条件判定覆盖、条件分支覆盖、路径覆盖等,语句覆盖不能对逻辑进行判断,逻辑的真实意义需要多结合项目本身,这个覆盖率数据 不具有强大说服力,不要盲目追求。 一般来讲,覆盖率测试通常用于评估测试的有效性。有效性从高到低的顺序 依次是“路径覆盖率” > “判定覆盖” > “语句覆盖”。 coverage可以显示测试正在执行代码的哪些部分,哪些没有被执行。目前最新版本是2020年7月5日发布的coverage.py 5.2,参考文档网址: https://coverage.readthedocs.io/en/coverage5.2/。实现语言覆盖的步骤如下: 第1步,安装coverage.py。 pip install coverage 下面对coverage命令参数进行简单介绍。 coverage命令共有10种参数形式,分别是:  run: 运行一个Python程序并收集运行数据;  report: 生成报告;  html: 把结果输出html格式;  xml: 把结果输出xml格式;  annotate: 运行一个Python程序并收集运行数据;  erase: 清楚之前coverage收集的数据;  combine: 合并coverage收集的数据;  debug: 获取调试信息;  help: 查看coverage帮助信息;  coverage help动作或者coverage 动作 help: 查看指定动作的帮助信息。 第2步,运行命令。 通过coverage run命令运行Python程序,并收集信息,命令如下: coverage run test.py 第3步,报告结果。 coverage report 提供4种风格的输出文件格式,分别对应html和xml命令。最简单的报告是report命令输出的概要信息,执行结果如下,report包括执行的行数stmts,没有执行的行数miss,以及覆盖百分比cover。 coverage report NameStmts MissCover --------------------------------------------- my_program.py20480% my_module.py 15286% my_other_module.py 56689% --------------------------------------------- TOTAL91 1287% 2. pytestcov pytestcov是pytest的一个插件,其本质也是引用 Python的coverage 库,用来统计代码覆盖率。我们新建3个文件,my_program.py 是程序代码,test_my_program.py是测试代码,在同一个目录coveragecov下还建立一个run.py执行文件。 (1) pip安装,命令如下: pip install pytest-cover (2) 建立my_program.py文件,代码如下: def cau (type,n1, n2): if type==1: a=n1 + n2 elif type==2: a = n1 - n2 else: a=n1 * n2 return a 可以看出函数有3个参数,里面的逻辑由3条条件分支组成,即type等于1时为加法,type等于2时为减法, type为其他值时为乘法,最后返回结果。 (3) 新建test_my_program.py测试文件,代码如下: from my_program import cau class Test_cover: def test_add(self): a=cau(1,2,3) assert a==3 上面代码用于测试type等于1时这个语句的覆盖率。 (4) 新建执行脚本run.py文件,代码如下: import pytest if __name__=='__main__': pytest.main(["--cov=要测试的绝对路径" ,"--cov-report=html","--cov-config=绝对路径/.coveragerc"] ) #执行某个目录下case 上述代码说明: cov参数后面接的是测试的目录,程序代码跟测试脚本必须在同一个文件夹下。covreport=html 用于生成报告。 只需输入命令python run.py 就可以运行。也可以在run.py 上直接右击使pytest运行。这样执行情况如图52所示,HTML报告如图53所示。 图52执行情况 图53HTML覆盖率的报告 run.py文件中coveragerc 是配置文件,配置用于跳过omit某些脚本 ,这些脚本不用于覆盖率测试。例如: 跳过所有非开发文件的统计, 即run.py、test_my_program.py文件跟init文件。在coveragerc文件中增加以下内容: [run] omit = */__init__.py */run.py */test_my_program.py 再次执行run.py文件,HTML报告如图54所示。 生成结果后进入htmlcov文件夹,可以直接单击index.html文件,如 图54所示,跳过测试文件和初始化文件。单击进入my_program.py文件, my_program.py的覆盖率详细说明,如图55所示。 图54跳过非开发代码的HTML报告 图55每个文件的覆盖率 从图55中可以看到以下的执行情况,绿色代表 已运行的代码(6、7、9、13行已覆盖),红色代表未被执行(9、10、12行未被覆盖),自己检查下代码逻辑,可以得出该结果是正确的。在测试代码中增加其他分支的测试,再执行则覆盖率会提高,直到把所有分支都测试完成,覆盖率便为100%了。 在test_my_program.py文件中增加测试代码如下: def test_sub(self): a=cau(2,3,2) assert a==1 再次执行,单击进入my_program.py文件查看结果,如图56所示。 图56修改后再次执行的结果 测试覆盖率,除了成功和失败以外,最重要的测试数据。上面的测试还差一个分支没有 完成,所以增加测试把所有分支至少执行一次。这个是采用单元测试分支覆盖方法写出的测试用例。100%测试覆盖率,只是完成Python项目单元测试的一个基本要求。因此,这个插件是十分重要的一个插件。 5.3.3pytestfreezegun 冰冻时间 这个插件随时可以变化当前系统时间,freezer可以冰冻时间,freezer.move_to可以改变时间,解决验证某一时间点的代码触发,或未来时间的代码变化问题。 下面的代码在test_frozen_date中是未冰冻的, 即当前时间是相等的。test_moveing_date 通过move_to修改时间,再验证 此时的时间不等于当前时间。还可以通过标识修改当前时间,在test_current_date方法上加@pytest.mark.freeze_time('修改的时间,时间格式见下面')同样可以修改时间。 此外,还可以与fixture结合实现修改时间, 在test_changing_date测试上添加@pytest.mark.freeze_time,使用fixture依赖注入传参的方式实现修改时间。 代码如下: #File: test_pytest-freezegun.py import time from datetime import datetime, date import pytest def test_frozen_date(freezer): now = datetime.now() time.sleep(1) later = datetime.now() assert now == later def test_moving_date(freezer): now = datetime.now() freezer.move_to('2017-05-20') later = datetime.now() assert now != later @pytest.mark.freeze_time('2017-05-21') def test_current_date(): assert date.today() == date(2017, 5, 21) @pytest.fixture def current_date(): return date.today() @pytest.mark.freeze_time def test_changing_date(current_date, freezer): freezer.move_to('2017-05-20') assert current_date == date(2017, 5, 20) freezer.move_to('2017-05-21') assert current_date == date(2017, 5, 21) 5.3.4pytestflakes静态代码检查 这是一个基于pyflakes的插件,对Python代码做一个快速的静态代码检查。使用方式和pytestpep8类似,效果也十分显著。环境准备,输入命令如下: pip install pytest-flakes 在test_flakes.py文件导入os模块,但后面的代码未用到这个导入,pyflakes这个插件就可以自动检查出来这是无用的导入(unused)。 代码如下: import os print('Hello world!') 执行pytest flakes test_flakes.py 命令,运行结果如下: lindafang@cpe-172-115-247-185 chapter-5 % pytest --flakes test_flakes.py ========================= test session starts ========================= platform darwin -- Python 3.6.8, pytest-5.2.1, py-1.8.0, pluggy-0.13.1 rootdir: /Users/lindafang/PyCharmProjects/pytest_book plugins: rerunfailures-5.0, forked-1.0.2, pep8-1.0.6, flakes-4.0.0, assume-1.2.2, cov-2.10.0, xdist-1.28.0, ordering-0.6, metadata-1.8.0, bdd-3.2.1 collected 1 item test_flakes.py F[100%] ===================== FAILURES ======================== ________________________ pyflakes-check ___________________ /PyCharmProjects/pytest_book/src/chapter-5/test_flakes.py: 5: UnusedImport 'os' imported but unused ======================== 1 failed in 0.05s ================== 5.3.5pytesthtml生成HTML报告 可以使用的两个HTML报告框架,pytesthtml和allure,本节主要介绍pytesthtml,在测试的内部通用。 pytesthtml是个插件,此插件用于生成测试结果的HTML报告,兼容Python 2.7和Python 3.8。GitHub源码网址: https://github.com/pytestdev/pytesthtml。 环境准备,执行命令如下: pip install pytest-html 执行时加入目标目录即可。 pytest --html=./report/html/report.html 执行完后会在当前目录生成一个report.html的报告文件。生成的报告如图57所示。 css是独立的,通过邮件分享报告的时候样式就会丢失,不好阅读,也无法筛选。 图57pytesthtml的报告 5.3.6pytesthttpserver 模拟HTTP服务 在Python程序中,用requests发起网络请求 是常见的操作,但如何测试是一个麻烦的问题。如果是单元测试,则可以用pytestmock,但如果是集成测试,用Stub的思路,则可以考虑pytesthttpserver。 如何使用pytesthttpserver来对requests等涉及网络请求操作的代码进行集成测试呢? 可以利用pytest的fixture机制为测试函数提供一个httpserver。以下提供一个简单的代码样例,便于理解完整流程。 代码如下: #File: test_httpserver.py import requests from pytest_httpserver import HTTPServer from pytest_httpserver.httpserver import RequestHandler def test_root(httpserver:HTTPServer): handler = httpserver.expect_request('/') assert isinstance(handler, RequestHandler) handler.respond_with_data('', status=200) response = requests.get(httpserver.url_for('/')) assert response.status_code == 200 httpserver需要设置两方面内容,输入(Request)和输出(Response)。先通过expect_request指定输入,再通过respond_with_data指定输出。最后,通过url_for获取随机生成Server的完整URL。这里,仅对“/”的Request响应,返回status=200的Response。 如果在一些不方便使用fixtures的场景,则可以通过with来使用相同功能。 代码如下: def test_root(): with HTTPServer() as httpserver: handler = httpserver.expect_request('/') assert isinstance(handler, RequestHandler) handler.respond_with_data('', status=200) response = requests.get(httpserver.url_for('/')) assert response.status_code == 200 上面的代码定义了“/”路径的响应,下面代码增加其他路径('/status'、'/method'和'/data')的响应,代码如下: #/status 这个路径返回的状态码为302 def test_status(httpserver:HTTPServer): uri = '/status' handler = httpserver.expect_request(uri) handler.respond_with_data('', status=302) response = requests.get(httpserver.url_for(uri)) assert response.status_code == 302 #/method 这个路径请求方法为get ,返回的状态码为200 def test_method(httpserver:HTTPServer): uri = '/method' handler = httpserver.expect_request(uri=uri, method='GET') handler.respond_with_data('', status=200) response = requests.get(httpserver.url_for(uri)) assert response.status_code == 200 response = requests.post(httpserver.url_for(uri)) assert response.status_code == 500 #/data 这个路径,方法method为post ,返回的状态码为200 def test_respond_with_data(httpserver:HTTPServer): uri = '/data' handler = httpserver.expect_request( uri=uri, method='POST', ) handler.respond_with_data('good') response = requests.post(httpserver.url_for(uri)) assert response.status_code == 200 assert response.content == 'good' #/data 这个路径,方法method为post ,数据为json类型,返回的状态码为200 def test_respond_with_json(httpserver:HTTPServer): uri = '/data' expect = {'a': 1, 'b': 2} handler = httpserver.expect_request( uri=uri, method='POST', ) handler.respond_with_json(expect) handler.respond_with_data response = requests.post(httpserver.url_for(uri)) assert response.status_code == 200 assert expect == response.json() 5.3.7pytestinstafail用于用例失败时立刻显示错误信息 用例失败时立刻显示错误的堆栈回溯信息,安装插件及执行如下: pip install pytest-instafail pytest--instafail 执行结果如图58所示。 图58pytestinstafail的执行结果 5.3.8pytestmock 模拟未实现的部分 1. mock的定义 mock通常用于测试,mock的意思是虚假的、模拟的。在Python的单元测试中,由于一切都是对象(object) ,而mock的技术就是在测试时不修改源码的前提下,替换某些对象,从而模拟测试环境。 2. mock的源起 单元测试的条件有限,在测试过程中,有时会遇到难以准备的环境。例如,与服务器的网络交互、对数据库的读写等。 传统思路是利用fixture进行测试环境准备。这种做法的优点是,与真实环境非常相似,测试效果好,但缺点是,测试代码开发时间长,测试执行时间也很长。 另一种思路是,准备一个虚假的沙箱,对代码的执行效果进行模拟。这样虽然不能测试真正的最终效果,但是更容易保证100%测试覆盖率,并且避免重复测试,从而降低测试执行时间。 例如,在一个函数中调用了3个函数。只需测试这3个函数是否被依次调用,而无须测试真实的调用修改。 代码如下: def put_elepent_into_fridge(elepent, fridge): fridge.open() fridge.put(elepent) fridge.close() 假设fridge这个类已经完全被测试覆盖了。这里如果用传统的测试方法,只能让这3种方法再被测试一遍。而如果把fridge换成一个mock,那么就可以避免重复测试,并且达到测试目的。 在Python标准库中,有unittest这个库。在Python 3.3以后,其中包含一个unittest.mock,就是Python最常用的mock库。此外,PyPI上还有一个mock库,是进入标准库前的mock,可以在旧的版本使用。 虽然可以直接在pytest的测试中,直接使用mock,但是并不方便。所以,在此直接推荐pytestmock。 3. pytestmock插件 pytestmock是一个pytest的插件,安装即可使用。它提供了一个名为mocker的fixture,仅在当前测试function或method生效,而不用自行包装。模拟一个object,是最常见的需求。由于function也是一个object,所以以function举例。 代码如下: import os def rm(filename): os.remove(filename) def test_rm(mocker): filename = 'test.file' mocker.patch('os.remove') rm(filename) 这里在给os.remove打了一个补丁,让它变成了一个MagicMock,然后利用assert_called_once_with查看它是否被调用一次,并且参数为filename。 注意: 只能对已经存在的东西使用mock。 有时,仅仅需要模拟一个object里的method,而无须模拟整个object。例如,在对当前object的某个method进行测试时可以用patch.object。 代码如下: class ForTest: field = 'origin' def method(): pass def test_for_test(mocker): test = ForTest() mock_method = mocker.patch.object(test, 'method') test.method() assert mock_method.called assert 'origin' == test.field mocker.patch.object(test, 'field', 'mocked') assert 'mocked' == test.field #上例中,分别对field和method进行了模拟。当然,对一个给定module的function,也能使用 def test_patch_object_listdir(mocker): mock_listdir = mocker.patch.object(os, 'listdir') os.listdir() assert mock_listdir.called #用spy包装 #如果只是想用MagicMock包装一个东西,而又不想改变其功能,则可以用spy def test_spy_listdir(mocker): mock_listdir = mocker.spy(os, 'listdir') os.listdir() assert mock_listdir.called 与上例中的patch.object不同的是,上例的os.listdir()不会真的执行,而本例中则会真的执行。 4. MagicMock 即使使用pytestmock简化使用过程,对mock本身还是要有基本的了解,尤其是MagicMock。 MagicMock属于unittest.mock中的一个类,是mock这个类的一个默认实现。在构造时,还常用return_value、side_effect和wraps这3个参数。当然,还有其他不常用参数,详见mock。 代码如下: import os import pytest def name_length(filename): if not os.path.isfile(filename): raise ValueError('{} is not a file!'.format(filename)) print(filename) return len(filename) def test_name_length0(mocker): isfile = mocker.patch('os.path.isfile', return_value=True) assert 4 == name_length('test') isfile.assert_called_once() isfile.return_value = False with pytest.raises(ValueError): name_length('test') assert 2 == isfile.call_count def test_name_length1(mocker): mocker.patch('os.path.isfile', side_effect=TypeError) with pytest.raises(TypeError): name_length('test') def test_name_length2(mocker): mocker.patch('os.path.isfile', return_value=True) mock_print = mocker.patch('builtins.print', wraps=print) mock_len = mocker.patch(__name__ + '.len', wraps=len) assert 4 == name_length('test') assert mock_print.called assert mock_len.called 以上展示了return_value、side_effect和wraps的用法。不仅可以在构造MagicMock时作为参数传入, 还可以在传入参数之后调整。return_value修改了os.path.isfile的返回值,控制程序执行流,而无须在文件系统中生成文件。side_effect可以令某些函数抛出指定的异常。wraps可以既把某些函数包装成MagicMock,又不改变它的执行效果(这一点类似spy) 。当然,也完全可以替换成另一个函数。 在Python 3中,内置函数可以通过builtins.*进行模拟,然而某些内置函数牵涉甚广,例如len,不适合在Builtin作用域进行 模拟,可以在被测试的函数所在的Global作用域进行模拟。如本例中,就对当前module的Global作用域里的len进行了模拟。 此外,上例中还展示了MagicMock中的一些属性,如assert_called_once、call_count、called等,详见mock。 5. 总结 无论是pytestmock这层薄薄的封装,还是unittest.mock本身,都还有很多未介绍的细节,但以上介绍的内容,应该已经可以满足绝大部分使用场景。 在弄懂了mock之后,Python的单元测试功能终于算是大成了。验证函数返回值是否相等,断言你的函数返回了某个值。如果此断言失败,将看到函数调用的返回值。 5.3.9pytestordering调整执行顺序 用例执行顺序的基本原则根据名称的字母逐一进行ASCII码比较,其值越大越先执行。 当含有多个测试模块(.py文件)时,根据基本原则执行。在一个测试模块(.py文件)中,先执行测试函数, 然后执行测试类。多个测试类则遵循基本原则,类中的测试方法遵循代码编写顺序。 如果想调整这个顺序,则可以通过插件进行,可以在测试方法上加@pytest.mark.run(order=1), 其值1表示最先执行。 代码如下: #File: test_ordering.py import pytest def test_03(): print("\ntest_03") def test_04(): print("test_04") class TestA(object): def test_05(self): print("test_05") #@pytest.mark.last def test_06(self): print("test_06") class TestC(object): #@pytest.mark.run(order=1) def test_01(self): print("\ntest_01") def test_02(self): print("test_02") 未修改执行顺序前执行结果如图59所示。test_01的执行顺序是倒数第二个执行。 图59pytestordering调整执行顺序前 通过pytestordering改变执行顺序,执行结果如图510所示。test_01的执行顺序是第1个。 图510pytestordering调整执行顺序后 5.3.10pytestpep8自动检测代码规范 PEP 8是Python中一个通用的代码规范。Python是一门优雅的语言,然而,如果连这个规范都不遵守,则Python代码 根本谈不上优雅,而pytestpep8就是在进行pytest测试时自动检测代码是否符合PEP 8规范的插件,安装命令如下: pip install pytest-pep8 安装后,增加pep8参数,即可执行测试。 pytest --pep8 只要有一行代码不符合规范,就会让整个测试失败,如图511所示。 图511pytestpep8 执行结果 5.3.11pytestpicked运行未提交git的用例 我们每天写完自动化用例后都会将代码提交到git仓库,随着用例的增多,为了保证仓库代码的干净,当有 新增用例的时候,我们希望只运行新增的且尚未提交到git仓库的用例。 pytestpicked插件可以实现只运行尚未提交到git仓库的代码。 环境准备,安装插件命令如下: pip install pytest-picked 在git中文件从新建到暂存库期间有4种状态,可以通过不同参数执行不同状态的文件,如图512所示。 图512git中文件从新建到提交到暂存库期间的4种状态 运行尚未提交git的测试用例的步骤如下: 第1步,在已提交过git仓库的用例中新增两个文件test_new.py和test_new_2.py,如图513所示。 图513pytestpicked的测试方法 第2步,使用git status查看当前分支状态。 执行结果如下,有两个新文件。 >git status On branch master Your branch is up-to-date with 'origin/master'. Changes to be committed: (use "git reset HEAD ..." to unstage) new file: pytest_demo/test_new.py new file: pytest_demo/test_new_2.py Changes not staged for commit: (use "git add ..." to update what will be committed) (use "git checkout -- ..." to discard changes in working directory) modified: pytest_demo/test_new.py modified: pytest_demo/test_new_2.py 第3步,使用pytest picked运行用例。 执行结果如下,所有测试都将从已修改但尚未提交的文件和文件夹中运行。 >pytest --picked Changed test files... 2. ['pytest_demo/test_new.py', 'pytest_demo/test_new_2.py'] Changed test folders... 0. [] =========================== test session starts ======================= platform win32 -- Python 3.6.6, pytest-6.0.2, py-1.9.0, pluggy-0.13.1 Test order randomisation NOT enabled. Enable with --random-order or --random-order-bucket= rootdir: ... collected 4 items pytest_demo\test_new.py .. [ 50%] pytest_demo\test_new_2.py .. [100%] =========================== 4 passed in 0.20s ========================= 不同参数具有不同的执行效果,下面举例分析。 1. 参数picked=first 首先运行修改后的测试文件中的测试,然后运行所有未修改的测试。 代码如下: >pytest --picked=first ========================= test session starts ========================= platform win32 -- Python 3.6.6, pytest-6.0.2, py-1.9.0, pluggy-0.13.1 rootdir: ... collected 11 items pytest_demo\test_new.py .. [ 18%] pytest_demo\test_new_2.py .. [ 36%] pytest_demo\test_b.py ...... [ 90%] pytest_demo\test_c.py . [100%] ============================= 11 passed in 0.10s ============ 2. 参数mode=unstaged执行未提交的所有文件 mode有2个参数可选 unstaged和branch, 默认为mode=unstaged。当git文件的状态为 untrack时,执行没添加到git中的新文件。unstaged表示未暂存状态,也就是没 有被git add过的文件。staged表示已暂存状态,执行git add 后文件状态。 为更好地理解什么是 untrack 状态,举例说明。当我们 用PyCharm打开git项目,并且新增一个文件时,会弹出询问框: 是否将文件添加到git,如图514所示。 图514pytestpicked 添加git文件 如果选择是,文件会变为绿色,也就是unstaged状态(没git add过)。选择否, 表示此文件是一个新文件,未被加到当前分支的git目录中,文件颜色是棕色。 当使用git status 查看当前分支的状态时,会看到pytest_demo/test_3.py是Untracked files。Test_new.py和test_new_2.py文件状态是unstage。 执行结果如下: >git status On branch master Your branch is up-to-date with 'origin/master'. Changes to be committed: (use "git reset HEAD ..." to unstage) new file: pytest_demo/test_new.py new file: pytest_demo/test_new_2.py Changes not staged for commit: (use "git add ..." to update what will be committed) (use "git checkout -- ..." to discard changes in working directory) modified: pytest_demo/test_new.py modified: pytest_demo/test_new_2.py Untracked files: (use "git add ..." to include in what will be committed) .idea/ pytest_demo/__pycache__/ pytest_demo/test_3.py 运行pytest picked,执行所有的Untracked文件和not staged文件,此时参数默认为mode=unstaged。 >pytest --picked Changed test files... 3. ['pytest_demo/test_new.py', 'pytest_demo/test_new_2.py', 'pytest_demo/test_3.py'] Changed test folders... 2. ['.idea/', 'pytest_demo/__pycache__/'] ======================== test session starts ======================== platform win32 -- Python 3.6.6, pytest-6.0.2, py-1.9.0, pluggy-0.13.1 collected 5 items pytest_demo\test_new.py .. [ 40%] pytest_demo\test_new_2.py .. [ 80%] pytest_demo\test_3.py . [100%] ========================= 5 passed in 0.06s =================== 3. 参数mode=branch运行分支上已经被暂存但尚未提交的代码 如果只需运行当前分支上已经被暂存,但尚未提交的文件(不包含Untracked files),则可以使用git diff查看分支代码的差异。 执行命令及结果如下: >git diff --name-only master pytest_demo/test_new.py pytest_demo/test_new_2.py 运行pytest picked mode=branch,即运行分支上已经被暂存但尚未提交的代码。 执行命令及结果如下: >pytest --picked --mode=branch Changed test files... 2. ['pytest_demo/test_new.py', 'pytest_demo/test_new_2.py'] Changed test folders... 0. [] ====================== test session starts ========================= platform win32 -- Python 3.6.6, pytest-6.0.2, py-1.9.0, pluggy-0.13.1 collected 4 items pytest_demo\test_new.py .. [ 50%] pytest_demo\test_new_2.py .. [100%] ======================= 4 passed in 0.04s ======================== 5.3.12pytestrerunfailures 失败重试 测试过程中经常在执行测试用例时会有失败的情况出现,这种失败可能是断言失败,可能是代码问题, 可能是环境问题,还可能是未知问题。我们为了排除部分原因会在失败时重试这些用例。失败重试依赖pytestrerunfailures插件实现。 pip install pytest-rerunfailures 用例失败再重新执行一次,需要在命令行加参数reruns。 参数reruns有两种用法: reruns=RERUNS RERUNS是失败重执行的次数,默认为0; rerunsdelay=RERUNS_DELAY RERUNS_DELAY是失败后间隔多少秒重新执行。 pytest --reruns 1 -html=report.html --self-contained-html 5.3.13pytestrepeat 重复运行测试 重复运行测试: pytestrepeat。环境准备的代码如下: pip install pytest-repeat; pytest test_x.py count=n (重复运行的次数)。 pytestrepeat允许用户重复执行单个用例或多个测试用例,并指定重复次数。提供marker功能,允许单独指定某些测试用例的执行次数。 GitHub网址https://github.com/pytestdev/pytestrepeat。 源码解析: #pytest-repeat.py: #pytest_addoption(parser): 添加一个command line的option def pytest_addoption(parser): parser.addoption( '--count', action='store', default=1, type=int, help='Number of times to repeat each test') pytest_configure(config): 一般用来注册marker,这样当用户使用pytest markers 时便可了解有哪些可用的marker了,如果不加这个hook,则功能上没什么影响, 建议使用这种规范的写法。 代码如下: def pytest_configure(config): config.addinivalue_line( 'markers', 'repeat(n): run the given test function `n` times.') #pytest_generate_tests(metafunc): 参数化生成test case的hook @pytest.fixture(autouse=True) def __pytest_repeat_step_number(request): if request.config.option.count > 1: try: return request.param except AttributeError: if issubclass(request.cls, TestCase): warnings.warn( "Repeating unittest class tests not supported") else: raise UnexpectedError( "This call couldn't work with pytest-repeat. " "Please consider raising an issue with your usage.") @pytest.hookimpl(trylast=True) def pytest_generate_tests(metafunc): count = metafunc.config.option.count m = metafunc.definition.get_closest_marker('repeat') if m is not None: count = int(m.args[0]) if count > 1: def make_progress_id(i, n=count): return '{0}-{1}'.format(i + 1, n) scope = metafunc.config.option.repeat_scope metafunc.parametrize( '__pytest_repeat_step_number', range(count), indirect=True, ids=make_progress_id, scope=scope ) #config.option.xx和marker.args[n]或者marker.kwargs(xx)优先级处理代码 count = metafunc.config.option.count m = metafunc.definition.get_closest_marker('repeat') if m is not None: count = int(m.args[0]) if count > 1: #do something here #利用parametrize fixture的hook function生成repeat的test case items metafunc.parametrize( '__pytest_repeat_step_number', range(count), indirect=True, ids=make_progress_id, scope=scope ) 5.3.14pytestrandomorder 随机顺序执行 pytestrandomorder插件允许用户按随机顺序执行测试,它提供包括module、class、package及global等不同粒度的随机性,并且允许用户使用mark标记特定粒度的测试集,从而保证部分test cases的执行顺序不被更改,具有高度灵活性。 源码解析: 主要介绍plugin这个module,直接与pytest插件开发相关。 random_order/plugin.py: pytest_addoption: Hook function,这里创建了一个argparser的group,通过addoption方法添加option,使得显示help信息时相关option显示在同一个group下面,更加友好。 代码如下: def pytest_addoption(parser): group = parser.getgroup('pytest-random-order options') group.addoption( '--random-order', action='store_true', dest='random_order_enabled', help='Randomise test order (by default, it is disabled) with default configuration.', ) group.addoption( '--random-order-bucket', action='store', dest='random_order_bucket', default=Config.default_value('module'), choices=bucket_types, help='Randomise test order within specified test buckets.', ) group.addoption( '--random-order-seed', action='store', dest='random_order_seed', default=Config.default_value(str(random.randint(1, 1000000))), help='Randomise test order using a specific seed.', ) #pytest_report_header: Hook function,在这里给出插件运行的相关信息, #方便出现问题时定位和复现问题 def pytest_report_header(config): plugin = Config(config) if not plugin.is_enabled: return "Test order randomisation NOT enabled. Enable with --random-order or --random-order-bucket=" return ( 'Using --random-order-bucket={plugin.bucket_type}\n' 'Using --random-order-seed={plugin.seed}\n' ).format(plugin=plugin) #pytest_collection_modifyitems: Hook function, 在测试项收集完以后执行, #用于过滤或者重排测试项 def pytest_collection_modifyitems(session, config, items): failure = None session.random_order_bucket_type_key_handlers = [] process_failed_first_last_failed(session, config, items) item_ids = _get_set_of_item_ids(items) plugin = Config(config) try: seed = plugin.seed bucket_type = plugin.bucket_type if bucket_type != 'none': _shuffle_items( items, bucket_key=bucket_type_keys[bucket_type], disable=_disable, seed=seed, session=session, ) except Exception as e: #See the finally block -- we only fail if we have lost user's tests _, _, exc_tb = sys.exc_info() failure = 'pytest-random-order plugin has failed with {0!r}: \n{1}'.format( e, ''.join(traceback.format_tb(exc_tb, 10)) ) if not hasattr(pytest, "PytestWarning"): config.warn(0, failure, None) else: warnings.warn(pytest.PytestWarning(failure)) finally: #Fail only if we have lost user's tests if item_ids != _get_set_of_item_ids(items): if not failure: failure = 'pytest-random-order plugin has failed miserably' raise RunTimeError(failure) pytestrandomorder是一个pytest插件,用于随机化测试顺序。这对于按顺序检测通过的测试可能是有用的,因为该测试恰好在不相关的测试之后运行,从而使系统处于良好状态。 该插件允许用户控制他们想要引入的随机性级别,并禁止对测试子集进行重新排序。通过传递先前测试运行中报告的种子值,可以按特定顺序重新运行测试,如图515所示。 图515pytestrandomorder的测试用例结构 使用pip安装插件,命令如下: pip install pytest-random-order 使用命令pytest h查看,命令行有3个参数供选择。 代码如下: pytest-random-order options: --random-orderRandomise test order (by default, it is disabled) with default configuration. --random-order-bucket={global,package,module,class,parent,grandparent,none} Randomise test order within specified test buckets. --random-order-seed=RANDOM_ORDER_SEED Randomise test order using a specific seed. 从版本v1.0.0开始,默认情况下,此插件不再将测试随机化。要启用随机化,必须以下列方式之一运行pytest: pytest --random-order pytest --random-order-bucket= pytest --random-order-seed= 如果要始终随机化测试顺序,需配置pytest。有很多种方法可以做到这一点,笔者最喜欢的一种方法是addopts=randomorder,即在pytest选项(通常是[pytest]或[tool: pytest]部分)下添加特定用于项目的配置文件。 #pytest.ini文件内容 [pytest] addopts = --random-order #--random-order 随机测试 先写几个简单的用例,如图516所示,目录结构如下: #module1/test_order1.py class TestRandom(): def test_01(self): print("用例1") def test_02(self): print("用例2") def test_03(self): print("用例3") # module2/test_order2.py class TestDemo(): def test_04(self): print("用例4") def test_05(self): print("用例5") def test_06(self): print("用例6") >pytest --random-order -v ================= test session starts ======================= Using --random-order-bucket=module Using --random-order-seed=357703 collected 6 items module2/test_order2.py: : TestDemo: : test_04 PASSED [ 16%] module2/test_order2.py: : TestDemo: : test_05 PASSED [ 33%] module2/test_order2.py: : TestDemo: : test_06 PASSED [ 50%] module1/test_order1.py: : TestRandom: : test_03 PASSED [ 66%] module1/test_order1.py: : TestRandom: : test_02 PASSED [ 83%] module1/test_order1.py: : TestRandom: : test_01 PASSED [100%] =========================== 6 passed in 0.05s ======= 图516pytestrandomorder的测试用例 5.3.15pytestsugar 显示彩色进度条 很多程序员喜欢将执行结果用不同颜色进行显示,即显示色彩和进度条(也能显示错误的堆栈信息)。如果有更好 的报告模板,则此插件就没什么用了,而且有些时候跟某些插件或版本有冲突。 插件安装后立即生效: pip install pytestsugar。 执行结果如图517所示。 图517pytestsugar的效果 5.3.16pytestselenium 浏览器兼容性测试 在兼容性测试中测试网站在不同浏览器中各种功能是否正常。通常使用自动化的方式实现。pytestselenium可以将浏览器的名字通过参数传入,这样就可以通过命令行方式进行兼容性测试了。 插件安装: pip install pytest-selenium 举例说明: 自动化实现启动一个浏览器、打开网址、运行Web应用、填充表单等。 代码如下: #Author: lindafang #Date: 2020-07-14 16:29 #File: test_pytest_selenium_browser.py import sys import pytest_selenium import pytest def test_baidu_title(selenium): selenium.get('http://www.baidu.com/') assert selenium.title == '百度一下,你就知道' def test_baidu_current_URL(selenium): selenium.get('http://www.baidu.com/') assert selenium.current_URL == 'https://www.baidu.com/' def test_baidu_so_getValue(selenium): selenium.get('http://www.baidu.com/') so = selenium.find_element_by_id('kw') so.send_keys('linda') assert so.get_attribute('value') == 'linda' 执行命令如下: pytest --driver Chrome --driver-path /path/to/Chromedriver 注意: 有时执行会有问题,说明与其他插件有冲突,逐步找到冲突的插件。 5.3.17pytesttimeout 设置超时时间 为测试设置时间限制: pytesttimeout。 安装插件: pip install pytesttimeout。 pytest test_x.py timeout=n (时间限制,单位: 秒) 5.3.18pytestxdist测试并发执行 pytestxdist这款插件允许用户将测试并发执行(进程级并发)。主要开发者是pytest目前的核心开发人员Bruno Oliveira ,截至笔者写作此文时,该项目已有711个star,应用于7850个项目。需要注意的是,由于插件 动态决定测试用例执行的顺序,为了保证各个测试能在各自独立线程中正确地执行,用例的作者应该保证测试用例的独立性(这也符合测试用例设计的最佳实践)。 具体的执行流程如下: 第1步,收集测试项。 第2步,测试收集检查。 第3步,测试分发。 第4步,测试执行。 第5步,测试结束。 把本章的所有测试用例使用并发的形式执行一下,命令为pytest n 3,这里的数字3是并发3个线程执行,结果如下: pytest -n 3 ========================= test session starts ============================ platform darwin -- Python 3.6.8, pytest-6.0.1, py-1.9.0, pluggy-0.13.1 sensitiveURL: .* rootdir: /Users/lindafang/PyCharmProjects/pytest_book plugins: xdist-2.1.0, gw0 [36] / gw1 [36] / gw2 [36] .FFFFFFFFF......FF...F.....FEEE..... [100%] ======================== ERRORS ============================== #......略过一些执行结果 test_pytest-freezegun.py:37: AssertionError ======================= short test summary info ========================== FAILED test_assume.py::test_simple_assume[0-1] - assert 0 == 1 #......略过一些执行结果 ================== 13 failed, 20 passed, 3 errors in 3.59s ================== 注意: 测试用例执行时间短,并发的效果可能会有相反的效果,因为多建立一个线程也需要时间。 5.4插件管理 5.4.1在测试模块或conftest文件中加载插件 可以在测试模块或conftest文件中加载插件,代码如下: pytest_plugins = ("myapp.testsupport.myplugin",) 加载测试模块或conftest插件时,也会加载指定的插件。 注意: 不推荐使用pytest_plugins非根conftest.py文件中的变量来加载插件。参阅插件部分中的完整说明。 该名称pytest_plugins是保留名称,不应用作自定义插件模块的名称。 5.4.2找出哪些插件处于活动状态 如果要找出环境中哪些插件处于活动状态,则可以输入如下命令: pytest --trace-config 此命令可获得扩展的测试标头,其中显示了已激活的插件及其名称。 5.4.3通过名称停用/注销插件 可以阻止插件加载或注销它们,命令如下: pytest -p no: NAME 这意味着任何随后的激活/加载命名插件的尝试都将不起作用。 如果要无条件禁用项目插件,则可以将此选项添加到pytest.ini文件中,代码如下: [pytest] addopts = -p no: NAME 或者,仅在某些环境中(例如,在CI服务器中)禁用它,可以将PYTEST_ADDOPTS环境变量设置为 p no: NAME。 5.5本章小结 本章主要讲解pytest的插件及管理: (1) 常用插件的介绍和使用。 (2) 常用插件管理。