在 OpenStack 官方指导手册(https://wiki.openstack.org/wiki/TestGuide)中明确的将 OpenStack 测试分为小型测试(单元测试)、中型测试(功能测试)以及大型测试(集成测试),在本文中咱们关注的是单元测试(https://wiki.openstack.org/wiki/SmallTestingGuide)。html
Small Tests are the tests that most developer read, write, and run with the greatest frequency. Small Tests are bundled with the source code, can be executed in any environment, and run extremely fast. Small tests cover the codebase in as fine a granularity as possible in order to make it very easy to locate problems when tests fail.python
单元测试是与源代码(测试单元)捆绑最为紧密的测试方法,若是测试用例不经过,能够迅速定位出问题所在。上图清晰明了的说明了单元测试(UNIT TESTS)之于生产效率(PRODUCTIVITY)的关系。请记住,单元测试虽然要编写更多的代码,但却能为团队节省更多的生产资源。由于你不清楚何时的小提交会致使全局性的逻辑错乱,而处理这样的问题每每须要花费昂贵的沟通成本,在开源社区的协做场景中尤甚。ios
unittest 是 Python 的标准单元测试库,提供了最基本的单元测试框架和单元测试运行器。git
官方文档:https://docs.python.org/3.7/library/unittest.htmlgithub
单元测试框架 TestCast 使用示例:web
# filename: test_module.py import unittest class TestStringMethods(unittest.TestCase): def test_upper(self): self.assertEqual('foo'.upper(), 'FOO') def test_isupper(self): self.assertTrue('FOO'.isupper()) self.assertFalse('Foo'.isupper()) def test_split(self): s = 'hello world' self.assertEqual(s.split(), ['hello', 'world']) # check that s.split fails when the separator is not a string with self.assertRaises(TypeError): s.split(2) if __name__ == '__main__': unittest.main()
单元测试运行器 TestRunner 使用示例:数据库
[root@localhost test]# python -m unittest -v test_module test_isupper (test_module.TestStringMethods) ... ok test_split (test_module.TestStringMethods) ... ok test_upper (test_module.TestStringMethods) ... ok ---------------------------------------------------------------------- Ran 3 tests in 0.001s OK
[root@localhost test]# python -m unittest -v test_module.TestStringMethods test_isupper (test_module.TestStringMethods) ... ok test_split (test_module.TestStringMethods) ... ok test_upper (test_module.TestStringMethods) ... ok ---------------------------------------------------------------------- Ran 3 tests in 0.000s OK
[root@localhost test]# python -m unittest -v test_module.TestStringMethods.test_upper test_upper (test_module.TestStringMethods) ... ok ---------------------------------------------------------------------- Ran 1 test in 0.000s OK
unittest 库提供了 Test Discover(测试发现)功能,开发者只须要遵照 “约定俗成” 的命名规则,单元测试用例就能够被自动的发现并运行。express
经过匹配条件自动发现单元测试模块示例:json
python -m unittest discover -s project_directory -p "*_test.py"
discover 子命令选项:后端
-v, --verbose 详细输出
-s, --start-directory {directory} 启用发现的目录(默认为当前目录)
-p, --pattern {pattern} 匹配单元测试模块的模式(默认为 test*.py)
-t, --top-level-directory {directory}
unittest 库实现了 Test Fixture(测试夹具)机制,用于抽象测试环境设置、清理逻辑。Such a working environment for the testing code is called a fixture.
单元测试框架 TestCase 经过定义 setUp()
和 tearDown()
,setUpClass()
和 tearDownClass()
,setUpModule
和 tearDownModule
等方法来进行 单元测试前的设置工做 和 单元测试后的清理工做。
setUp
:在运行 单元测试用例 以前被自动调用。setUp
异常则不运行单元测试用例。tearDown
:在运行完 单元测试用例 以后被自动调用。tearDown
异常,单元测试用例照常运行。setUpClass
:在运行 单元测试类 以前被自动调用。tearDownClass
:在运行完 单元测试类 以后被自动执行。setUpModule
:在运行 单元测试模块 以前被自动调用。tearDownModule
:在运行完 单元测试模块 以后被自动执行。Test Fixture 应用示例:
import unittest class SimpleWidgetTestCase(unittest.TestCase): def setUp(self): # 运行单元测试用例以前设置 self.widget 实例属性 self.widget = Widget('The widget') def tearDown(self): # 运行完单元测试用例以后清理 self.widget 实例属性 self.widget.dispose() self.widget = None class DefaultWidgetSizeTestCase(SimpleWidgetTestCase): def runTest(self): """真·测试用例""" # 相等断言,检测传入实参是否相等 self.assertEqual(self.widget.size(), (50,50), 'incorrect default size') class WidgetResizeTestCase(SimpleWidgetTestCase): def runTest(self): self.widget.resize(100,150) self.assertEqual(self.widget.size(), (100,150), 'wrong size after resize')
上述实现方式有一个缺陷,若是咱们但愿 Test Fixture 的执行粒度是单元测试用例,而不是单元测试类,咱们就须要为每一个单元测试用例都实现一个继承 Fixture 的测试类,如上述的 DefaultWidgetSizeTestCase 和 WidgetResizeTestCase 都继承了 SimpleWidgetTestCase 以此分别让各自的单元测试用例 runTest 获得 Fixture。显然,为了单元测试用例获得 Fixture 而实现单元测试类是不科学的,Test Suite 机制解决了这个问题。
TestCase 的 Test Suite(测试套件)机制,可以让多个单元测试用例共享同属一个测试类中实现的 Fixture。
应用 Test Suite 简化上述实现的示例:
import unittest class WidgetTestCase(unittest.TestCase): def setUp(self): self.widget = Widget('The widget') def tearDown(self): self.widget.dispose() self.widget = None def test_default_size(self): self.assertEqual(self.widget.size(), (50,50), 'incorrect default size') def test_resize(self): self.widget.resize(100,150) self.assertEqual(self.widget.size(), (100,150), 'wrong size after resize') # 生成测试用例 defaultSizeTestCase defaultSizeTestCase = WidgetTestCase('test_default_size') # 生成测试用例 resizeTestCase resizeTestCase = WidgetTestCase('test_resize') widgetTestSuite = unittest.TestSuite() # 将测试用例加入 Suite,同一个 Suite 中的每一个测试用例都会执行一次 Fixture widgetTestSuite.addTest(defaultSizeTestCase) widgetTestSuite.addTest(resizeTestCase)
Pythonic 的实现:
def suite(): """Return a test suite. """ tests = ['test_default_size', 'test_resize'] return unittest.TestSuite(map(WidgetTestCase, tests))
Assert(断言)是单元测试关键,用于检测一个条件是否符合预期。若是是真,不作任何事。若是为假,就抛出 AssertionError 和错误信息。
NTOE:更详细的信息建议查看官方文档。
mock:在 Python 3.x 中做为一个模块被内嵌到 unittest 标准库。简单的说,mock 就是制造假数据(对象)的模块,以此来模拟多种代码运行的情景,而无需真的发生了这种情景。
使用 Mock 对象来模拟测试情景的示例:
# filename: client.py import requests # 该函数不属于测试范畴 # 是须要被模拟的 Python 对象 def send_request(url): r = requests.get(url) return r.status_code # 待测试的单元 # 功能是访问 URL # 存在两种结果: # 访问成功:200 # 访问失败:404 def visit_baidu(): return send_request('http://www.baidu.com')
# filename: test_client.py import unittest import mock import client class TestClient(unittest.TestCase): def test_success_request(self): # 测试访问生成的状况: # 实例化一个 Mock 对象,用于替换 client.send_request 函数 # 这个 Mock 对象会返回 HTTP Code 200 success_send = mock.Mock(return_value='200') client.send_request = success_send self.assertEqual(client.visit_baidu(), '200') def test_fail_request(self): # 测试访问失败的状况: # 实例化一个 Mock 对象,用于替换 client.send_request 函数 # 这个 Mock 对象会返回 HTTP Code 404 fail_send = mock.Mock(return_value='404') client.send_request = fail_send self.assertEqual(client.visit_baidu(), '404')
在单元测试用例中经过构建模拟对象(Class Mock 的实例化)来模拟待测试代码中指定的 Python 对象的属性和行为,经过这种方式在单元测试用例中模拟出代码运行可能会发生的各类状况。
上述示例中将 client.visit_baidu()
做为测试单元,使用 Mock 对象 success_send/fail_send 来模拟了 client.send_request()
的 成功/失败 返回。在测试单元中被模拟的 Python 对象,每每是这种 “会发生变化的对象” 或 “经过外部接口获取的对象”。使用 mock 模块大体上能够总结出这样的流程:
class Mock(spec=None, side_effect=None, return_value=DEFAULT, wraps=None, name=None, spec_set=None, **kwargs)
NTOE:更详细的信息建议查看官方文档。
当访问一个 Mock 实例化对象不存在的实例属性时,它首先会自动建立一个子对象,而后对正在访问的实例属性进行赋值,这个机制对实现多级属性的 Mock 很方便。e.g.
>>> import mock >>> client = mock.Mock() # 自动建立子对象 >>> client.v2_client.get.return_value = '200' >>> client.v2_client.get() '200'
有时候咱们会但愿 Mock 对象只在特定的地方模拟,而非全局,这就是 Mock Patch(Mock 对象的做用域)。mock.patch()
和 mock.patch.object()
函数会返回一个 Class _patch 的实例对象,这个实例对象能够做为 函数/类 装饰器(Decorator)或上下文管理器(Context Manager),经过这种 Pythonic 的方式来控制 Mock 对象的做用域。
Mock Patch 实现示例:
>>> from unittest.mock import patch # 在 test function 内: # module.ClassName1 被 MockClass1 替代 # module.ClassName2 被 MockClass2 替代 >>> @patch('module.ClassName2') ... @patch('module.ClassName1') ... def test(MockClass1, MockClass2): ... module.ClassName1() ... module.ClassName2() ... assert MockClass1 is module.ClassName1 ... assert MockClass2 is module.ClassName2 ... assert MockClass1.called ... assert MockClass2.called ... >>> test()
class TestClient(unittest.TestCase): def test_success_request(self): status_code = '200' success_send = mock.Mock(return_value=status_code) # 在 with 语句范围内 client.send_request 方法被 mock 掉 with mock.patch('client.send_request', success_send): from client import visit_baidu self.assertEqual(visit_baidu(), status_code) def test_fail_request(self): status_code = '404' fail_send = mock.Mock(return_value=status_code) # 在 with 语句范围内 client.send_request 方法被 mock 掉 with mock.patch('client.send_request', fail_send): from client import visit_baidu self.assertEqual(visit_baidu(), status_code)
def test_fail_request(self): status_code = '404' fail_send = mock.Mock(return_value=status_code) with mock.patch.object(client, 'send_request', fail_send): from client import visit_baidu self.assertEqual(visit_baidu(), status_code)
fixtures:第三方模块,对 unittest 的 Test Fixture 机制进行了加强,有效提升了测试代码的复用率。
官方文档:https://pypi.org/project/fixtures/
fixtures 模块依赖于 testtools 模块,它提供了一种简易建立 Fixture 对象的方式,也提供了一些内置的 Fixture。
自定义 Fixture 的示例:
# Define _setUp to initialize your state and schedule a cleanup for when cleanUp is called and you’re done >>> import unittest >>> import fixtures >>> class NoddyFixture(fixtures.Fixture): ... def _setUp(self): # 运行单元测试用例以前初始化 frobnozzle 实例属性 ... self.frobnozzle = 42 # 运行完单元测试用例以后删除 frobnozzle 实例属性 ... self.addCleanup(delattr, self, 'frobnozzle')
每一个 Fixture 对象都应该实现 setUp()
和 cleanUp()
方法,它们对应 unittest 的 setUp()
+ tearDown()
。
经过 FunctionFixture 组装一个 Fixture 对象并使用的示例:
>>> import os.path >>> import shutil >>> import tempfile # setUp() >>> def setup_function(): ... return tempfile.mkdtemp() # tearDown() >>> def teardown_function(fixture): ... shutil.rmtree(fixture) >>> fixture = fixtures.FunctionFixture(setup_function, teardown_function) >>> fixture.setUp() >>> print (os.path.isdir(fixture.fn_result)) True >>> fixture.cleanUp()
Pythonic 的写法:
>>> with fixtures.FunctionFixture(setup_function, teardown_function) as fixture: # 单元测试用例 ... print (os.path.isdir(fixture.fn_result)) True
fixtures 模块提供的 Fixture 对象在使用上更加灵活,并不是必定要在单元测试类中实现 setUp()
和 tearDown()
。
fixtures 提供了 Class MockPatchObject 和 Class MockPatch,它们返回一个具备 Mock 做用域的 Fixture 对象,这个做用域的范围就是 Fixture setUp 和 cleanUp 之间,并在在做用域范围内 Mock 对象是生效的。
应用 MockPatchObject 的示例:
>>> class Fred: ... value = 1 # 将 Class Fred 转换为一个 fixture >>> fixture = fixtures.MockPatchObject(Fred, 'value', 2) # 在 fixture 的上下文中使用 Mock Fred >>> with fixture: ... Fred().value 2 >>> Fred().value 1
应用 MockPatch 的示例:
>>> fixture = fixtures.MockPatch('subprocess.Popen.returncode', 3)
testtools:第三方模块,是 unittest 的扩展,对 unittest 进行了断言之类的功能加强,让测试代码的编写更加方便。
官方文档:https://testtools.readthedocs.io/en/latest/
加强项目:
示例:
from testtools import TestCase from testtools.content import Content from testtools.content_type import UTF8_TEXT from testtools.matchers import Equals from myproject import SillySquareServer class TestSillySquareServer(TestCase): # 在运行单元测试用例以前执行 def setUp(self): super(TestSillySquareServer, self).setUp() # 载入 SillySquareServer 的 Fixture setUp/cleanUp 到本地 setUp self.server = self.useFixture(SillySquareServer()) # 设定在运行完单元测试用例以后执行的清理动做 self.addCleanup(self.attach_log_file) def attach_log_file(self): self.addDetail( 'log-file', Content(UTF8_TEXT, lambda: open(self.server.logfile, 'r').readlines())) # 单元测试用例 def test_server_is_cool(self): self.assertThat(self.server.temperature, Equals("cool")) # 单元测试用例 def test_square(self): self.assertThat(self.server.silly_square_of(7), Equals(49))
可见,使用 testtools.TestCase 框架可以让测试代码实现变得更加规范而简单。
testscenarios:第三方模块,用于知足了 “场景测试” 的需求,是节省重复代码的有效手段。
官方文档:https://pypi.org/project/testscenarios/
所谓 “场景测试” 就好比:测试一段支持不一样数据库驱动(MongoDB/MySQL/SQLite)的数据库访问代码,那么每一种数据库驱动就是一个场景,一般的咱们会为每种场景都编写一个测试用例,但有了 testscenarios 模块,就只须要编写一个统一的测试用例便可。这是由于 testscenarios 能够经过在单元测试类中设定 scenarios 类属性来描述不一样的场景。TestCease 就能够经过 testscenarios 框架根据 scenarios 自动生成不一样的单元测试用例,从而达到测试不一样场景的目的。
It is the intent of testscenarios to make dynamically running a single test in multiple scenarios clear, easy to debug and work with even when the list of scenarios is dynamically generated.
scenarios 类属性数据结构示例:
>>> class MyTest(unittest.TestCase): ... ... scenarios = [ ... ('scenario1', dict(param=1)), ... ('scenario2', dict(param=2)),]
应用 testscenarios 编写场景测试的示例:
# Some test loaders support hooks like load_tests and test_suite. # Ensuring your tests have had scenario application done through # these hooks can be a good idea - it means that external test # runners (which support these hooks like nose, trial, tribunal) # will still run your scenarios. # unittest 支持 load_tests hooks,加载定制的单元测试用例 # 这里用来加载 testscenarios 框架的 scenarios 测试用例 load_tests = testscenarios.load_tests_apply_scenarios class YamlParseExceptions(testtools.TestCase): scenarios = [ ('scanner', dict(raised_exception=yaml.scanner.ScannerError())), ('parser', dict(raised_exception=yaml.parser.ParserError())), ('reader', dict(raised_exception=yaml.reader.ReaderError('', '', '', '', ''))), ] def test_parse_to_value_exception(self): text = 'not important' with mock.patch.object(yaml, 'load') as yaml_loader: yaml_loader.side_effect = self.raised_exception self.assertRaises(ValueError, template_format.parse, text)
上述示例 testtools.TestCease 会经过 testscenarios 框架自动生成 scanner、parser、reader 三个 scenario 对应的三个单元测试用例,在这些测试用例中的 raised_exception 具备不一样的实现。
subunit:第三方模块,是一种传输测试结果的数据流协议,有助于对接多种类型的数据分析工具。
官方文档:https://pypi.org/project/python-subunit/
当测试用例不少的时候,如何高效处理测试结果就显得很重要了。subunit 协议可以将测试结果转换成多种格式,能够灵活对接多种数据分析工具。
Subunit supplies the following filters:
python-subunit 也提供了测试运行器:
python -m subunit.run mypackage.tests.test_suite
使用 python-subunit 提供的数据流转换工具:
python -m subunit.run mypackage.tests.test_suite | subunit2pyunit
testrepository:第三方模块,提供了一个测试结果存储库,同时也提供了一些单元测试用例的管理方法。
官方文档:https://pypi.org/project/testrepository/
在自动化单元测试流程中引入 testrepository 能够:
testrepository 使用流程:
$ touch .testr.conf
$ testr init
$ testr load < testrun
$ testr stats $ testr last $ testr failing
$ rm -rf .testrepository
查看 testrepository 提供的指令集:
[root@localhost ~]# testr commands command description ---------- -------------------------------------------------------------- commands List available commands. failing Show the current failures known by the repository. help Get help on a command. init Create a new repository. last Show the last run loaded into a repository. list-tests Lists the tests for a project. load Load a subunit stream into a repository. quickstart Introductory documentation for testrepository. run Run the tests for a project and load them into testrepository. slowest Show the slowest tests from the last test run. stats Report stats about a repository.
testrepository 常与 python-subunit 结合使用,testrepository 调用 python-subunit 的测试运行器执行测试,并将测试结果经过 subunit 协议导入到 testrepository 存储库中。
stestr:第三方模块,是 testrepository 的分支,实现了一个并行的测试运行器,旨在使用多进程来运行 unittest 的 Test Suites。是推荐的 testrepository 替代方案。stestr 与 testrepository 具备相同上层概念对象,但底层却以不一样的方式运做。虽然 stestr 不须要依赖 python-subunit 的测试运行器,但仍会使用 subunit 协议。
官方文档:https://stestr.readthedocs.io/en/latest/
stestr 使用流程:
[DEFAULT] test_path=./project_source_dir/tests
stestr run
查看 stestr 提供的指令集:
[root@localhost ~]# stestr --help ... Commands: complete print bash completion command (cliff) failing Show the current failures known by the repository help print detailed help for another command (cliff) init Create a new repository. last Show the last run loaded into a repository. list List the tests for a project. You can use a filter just like with the run command to see exactly what tests match load Load a subunit stream into a repository. run Run the tests for a project and store them into the repository. slowest Show the slowest tests from the last test run.
coverage:第三方模块,用于统计单元测试用例的覆盖率。
官方文档:https://coverage.readthedocs.io/en/v4.5.x/
coverage 本质用于统计代码覆盖了,即有多少代码被执行了,而在单元测试场景中,coverage 就用于统计单元测试的覆盖率,即测试单元在整个工程中的比率。
coverage 使用流程:
# if you usually do: # # $ python my_program.py arg1 arg2 # # then instead do: $ coverage run my_program.py arg1 arg2 blah blah ..your program's output.. blah blah
$ coverage report -m Name Stmts Miss Cover Missing ------------------------------------------------------- my_program.py 20 4 80% 33-35, 39 my_other_module.py 56 6 89% 17-23 ------------------------------------------------------- TOTAL 76 10 87%
$ coverage html
tox:第三方模块,用于管理和构建单元测试虚拟环境(virtualenv)。
官方文档:https://tox.readthedocs.io/en/latest/
一个 Python 工程,可能同时须要运行 Python 2.x 和 Python 3.x 环境下的单元测试。显然,这些任务须要在不一样的虚拟环境中执行。tox 经过配置文件 tox.ini 的定义来为每一个任务构建不一样的虚拟环境。
# content of: tox.ini , put in same dir as setup.py [tox] envlist = py27,py36 [testenv] # install pytest in the virtualenv where commands will be executed deps = pytest commands = # NOTE: you can run any command line tool here - not just tests pytest
tox 运做流程图:
[tox] minversion = 2.1 # 定义虚拟环境清单 envlist = py{27,35},functional,pep8 skipsdist = True # 默认配置 Section # 其余 Section 没有配置的选项都从这里取值 [testenv] basepython = python3 # 指定采用开发者模型构建虚拟环境中的工程 # 因此不会拷贝代码到 virtualenv 目录中,只是作个连接 usedevelop = True whitelist_externals = bash find rm env # 表示构建环境时安装 Python 工程要执行的命令,通常是使用 pip 安装 # -c 指定了依赖包的版本上限 install_command = pip install -c{env:UPPER_CONSTRAINTS_FILE:https://git.openstack.org/cgit/openstack/requirements/plain/upper-constraints.txt} {opts} {packages} # 列出在虚拟机环境中生效的环境变量 setenv = VIRTUAL_ENV={envdir} LANGUAGE=en_US LC_ALL=en_US.utf-8 OS_STDOUT_CAPTURE=1 OS_STDERR_CAPTURE=1 OS_TEST_TIMEOUT=160 # TODO(stephenfin): Remove psycopg2 when minimum constraints is bumped to 2.8 PYTHONWARNINGS = ignore::UserWarning:psycopg2 # 指定构建虚拟环境时须要安装的第三方依赖包 deps = -r{toxinidir}/test-requirements.txt # 指定要构建完虚拟环境以后要执行的指令 commands = find . -type f -name "*.pyc" -delete passenv = OS_DEBUG GENERATE_HASHES # there is also secret magic in subunit-trace which lets you run in a fail only # mode. To do this define the TRACE_FAILONLY environmental variable. # py27 虚拟环境设置 Section [testenv:py27] # TODO(efried): Remove this once https://github.com/tox-dev/tox/issues/425 is fixed. # 指定 Python 版本 basepython = python2.7 # 指定要执行的指令 commands = {[testenv]commands} # 调用 stestr 开始执行单元测试 # {posargs} 参数就是从 tox 指令选项参数传递进来的 stestr run {posargs} # --combine 将运行结果写入存储库 # --no-discover 不执行自动的测试发现,仅执行指定的单元测试模块 # OSProfiler 用于为每一个请求生成一个跟踪 env TEST_OSPROFILER=1 stestr run --combine --no-discover 'nova.tests.unit.test_profiler' # 显示上次测试中运行最慢的单元测试用例 stestr slowest [testenv:py35] # TODO(efried): Remove this once https://github.com/tox-dev/tox/issues/425 is fixed. basepython = python3.5 # 指定要执行的指令 commands = {[testenv]commands} # 调用 stestr 开始执行单元测试 stestr run {posargs} env TEST_OSPROFILER=1 stestr run --combine --no-discover 'nova.tests.unit.test_profiler' [testenv:py36] # TODO(efried): Remove this once https://github.com/tox-dev/tox/issues/425 is fixed. basepython = python3.6 commands = {[testenv:py35]commands} [testenv:py37] # TODO(efried): Remove this once https://github.com/tox-dev/tox/issues/425 is fixed. basepython = python3.7 commands = {[testenv:py35]commands} # PEP8 虚拟环境设置 Section [testenv:pep8] description = Run style checks. envdir = {toxworkdir}/shared commands = # 对工程进行 flack8 静态检查 bash tools/flake8wrap.sh {posargs} # Check that all JSON files don't have \r\n in line. bash -c "! find doc/ -type f -name *.json | xargs grep -U -n $'\r'" # Check that all included JSON files are valid JSON bash -c '! find doc/ -type f -name *.json | xargs -t -n1 python -m json.tool 2>&1 > /dev/null | grep -B1 -v ^python' [testenv:fast8] description = Run style checks on the changes made since HEAD~. For a full run including docs, use 'pep8' envdir = {toxworkdir}/shared commands = bash tools/flake8wrap.sh -HEAD [testenv:functional] # TODO(melwitt): This can be removed when functional tests are gating with # python 3.x # NOTE(cdent): For a while, we shared functional virtualenvs with the unit # tests, to save some time. However, this conflicts with tox siblings in zuul, # and we need siblings to make testing against master of other projects work. basepython = python2.7 setenv = {[testenv]setenv} # As nova functional tests import the PlacementFixture from the placement # repository these tests are, by default, set up to run with latest master from # the placement repo. In the gate, Zuul will clone the latest master from # placement OR the version of placement the Depends-On in the commit message # suggests. If you want to run the test locally with an un-merged placement # change, modify this line locally to point to your dependency or pip install # placement into the appropriate tox virtualenv. We express the requirement # here instead of test-requirements because we do not want placement present # during unit tests. deps = -r{toxinidir}/test-requirements.txt git+https://git.openstack.org/openstack/placement#egg=openstack-placement commands = {[testenv]commands} # NOTE(cdent): The group_regex describes how stestr will group tests into the # same process when running concurently. The following ensures that gabbi tests # coming from the same YAML file are all in the same process. This is important # because each YAML file represents an ordered sequence of HTTP requests. Note # that tests which do not match this regex will not be grouped in any # special way. See the following for more details. # http://stestr.readthedocs.io/en/latest/MANUAL.html#grouping-tests # https://gabbi.readthedocs.io/en/latest/#purpose # 调用 stestr 开始执行单元测试 stestr --test-path=./nova/tests/functional --group_regex=nova\.tests\.functional\.api\.openstack\.placement\.test_placement_api(?:\.|_)([^_]+) run {posargs} stestr slowest # TODO(gcb) Merge this into [testenv:functional] when functional tests are gating # with python 3.5 [testenv:functional-py35] basepython = python3.5 setenv = {[testenv]setenv} deps = {[testenv:functional]deps} commands = {[testenv:functional]commands} [testenv:functional-py36] basepython = python3.6 setenv = {[testenv]setenv} deps = {[testenv:functional]deps} commands = {[testenv:functional]commands} [testenv:functional-py37] basepython = python3.7 setenv = {[testenv]setenv} deps = {[testenv:functional]deps} commands = {[testenv:functional]commands} [testenv:api-samples] envdir = {toxworkdir}/shared setenv = {[testenv]setenv} GENERATE_SAMPLES=True PYTHONHASHSEED=0 commands = {[testenv]commands} # 调用 stestr 开始执行单元测试 stestr --test-path=./nova/tests/functional/api_sample_tests run {posargs} stestr slowest [testenv:genconfig] envdir = {toxworkdir}/shared commands = # 生成 nova.conf 配置文件 oslo-config-generator --config-file=etc/nova/nova-config-generator.conf [testenv:genpolicy] envdir = {toxworkdir}/shared commands = # 生成 policy 配置文件 oslopolicy-sample-generator --config-file=etc/nova/nova-policy-generator.conf [testenv:genplacementpolicy] envdir = {toxworkdir}/shared commands = # 生成 placement policy 配置文件 oslopolicy-sample-generator --config-file=etc/nova/placement-policy-generator.conf [testenv:cover] # TODO(stephenfin): Remove the PYTHON hack below in favour of a [coverage] # section once we rely on coverage 4.3+ # # https://bitbucket.org/ned/coveragepy/issues/519/ envdir = {toxworkdir}/shared setenv = {[testenv]setenv} PYTHON=coverage run --source nova --parallel-mode commands = {[testenv]commands} # 调用 coverage 生成单元测试覆盖率报告 coverage erase stestr run {posargs} coverage combine coverage html -d cover coverage xml -o cover/coverage.xml coverage report [testenv:debug] envdir = {toxworkdir}/shared commands = {[testenv]commands} oslo_debug_helper {posargs} [testenv:venv] deps = -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt -r{toxinidir}/doc/requirements.txt commands = {posargs} [testenv:docs] description = Build main documentation. deps = -r{toxinidir}/doc/requirements.txt commands = rm -rf doc/build # Check that all JSON files don't have \r\n in line. bash -c "! find doc/ -type f -name *.json | xargs grep -U -n $'\r'" # Check that all included JSON files are valid JSON bash -c '! find doc/ -type f -name *.json | xargs -t -n1 python -m json.tool 2>&1 > /dev/null | grep -B1 -v ^python' # 使用 Sphinx 来构建本地文档网站 sphinx-build -W -b html -d doc/build/doctrees doc/source doc/build/html # Test the redirects. This must run after the main docs build whereto doc/build/html/.htaccess doc/test/redirect-tests.txt [testenv:api-guide] description = Generate the API guide. Called from CI scripts to test and publish to developer.openstack.org. envdir = {toxworkdir}/docs deps = {[testenv:docs]deps} commands = rm -rf api-guide/build sphinx-build -W -b html -d api-guide/build/doctrees api-guide/source api-guide/build/html [testenv:api-ref] description = Generate the API ref. Called from CI scripts to test and publish to developer.openstack.org. envdir = {toxworkdir}/docs deps = {[testenv:docs]deps} commands = rm -rf api-ref/build sphinx-build -W -b html -d api-ref/build/doctrees api-ref/source api-ref/build/html [testenv:releasenotes] description = Generate release notes. envdir = {toxworkdir}/docs deps = {[testenv:docs]deps} commands = rm -rf releasenotes/build sphinx-build -W -b html -d releasenotes/build/doctrees releasenotes/source releasenotes/build/html [testenv:all-docs] description = Build all documentation including API guides and refs. envdir = {toxworkdir}/docs deps = -r{toxinidir}/doc/requirements.txt commands = {[testenv:docs]commands} {[testenv:api-guide]commands} {[testenv:api-ref]commands} {[testenv:releasenotes]commands} [testenv:bandit] # NOTE(browne): This is required for the integration test job of the bandit # project. Please do not remove. envdir = {toxworkdir}/shared commands = bandit -r nova -x tests -n 5 -ll # Python 代码静态检查 [flake8] # E125 is deliberately excluded. See # https://github.com/jcrocholl/pep8/issues/126. It's just wrong. # # Most of the whitespace related rules (E12* and E131) are excluded # because while they are often useful guidelines, strict adherence to # them ends up causing some really odd code formatting and forced # extra line breaks. Updating code to enforce these will be a hard sell. # # H405 is another one that is good as a guideline, but sometimes # multiline doc strings just don't have a natural summary # line. Rejecting code for this reason is wrong. # # E251 Skipped due to https://github.com/jcrocholl/pep8/issues/301 enable-extensions = H106,H203,H904 ignore = E121,E122,E123,E124,E125,E126,E127,E128,E129,E131,E251,H405 exclude = .venv,.git,.tox,dist,*lib/python*,*egg,build,tools/xenserver*,releasenotes # To get a list of functions that are more complex than 25, set max-complexity # to 25 and run 'tox -epep8'. # 34 is currently the most complex thing we have # TODO(jogo): get this number down to 25 or so max-complexity=35 [hacking] local-check-factory = nova.hacking.checks.factory import_exceptions = nova.i18n [testenv:bindep] # Do not install any requirements. We want this to be fast and work even if # system dependencies are missing, since it's used to tell you what system # dependencies are missing! This also means that bindep must be installed # separately, outside of the requirements files, and develop mode disabled # explicitly to avoid unnecessarily installing the checked-out repo too (this # further relies on "tox.skipsdist = True" above). usedevelop = False deps = bindep commands = bindep test [testenv:lower-constraints] deps = -c{toxinidir}/lower-constraints.txt -r{toxinidir}/test-requirements.txt -r{toxinidir}/requirements.txt commands = {[testenv]commands} stestr run {posargs}
从配置内容能够看出当咱们运行单元测试的时候,是经过执行指令 stestr run {posargs}
来触发的,而描述单元测试执行细节的 stestr 配置文件的内容以下:
[root@localhost nova]# cat .stestr.conf [DEFAULT] test_path=./nova/tests/unit top_dir=./
若是使用 testrepository,那么配置文件多是这样的:
[root@localhost nova]# cat .testr.conf [DEFAULT] test_command=OS_STDOUT_CAPTURE=${OS_STDOUT_CAPTURE:-1} \ OS_STDERR_CAPTURE=${OS_STDERR_CAPTURE:-1} \ OS_TEST_TIMEOUT=${OS_TEST_TIMEOUT:-160} \ ${PYTHON:-python} -m subunit.run discover -t ./ ${OS_TEST_PATH:-./nova/tests} $LISTOPT $IDOPTION test_id_option=--load-list $IDFILE test_list_option=--list # NOTE(cdent): The group_regex describes how testrepository will # group tests into the same process when running concurently. The # following insures that gabbi tests coming from the same YAML file # are all in the same process. This is important because each YAML # file represents an ordered sequence of HTTP requests. Note that # tests which do not match this regex will not be grouped in any # special way. See the following for more details. # http://testrepository.readthedocs.io/en/latest/MANUAL.html#grouping-tests # https://gabbi.readthedocs.io/en/latest/#purpose group_regex=(gabbi\.(?:driver|suitemaker)\.test_placement_api_([^_]+))
可见,testrepository 调用了 python-subunit 的测试运行器,而 stestr 则不须要。但不管如何,它们最终都执行了 nova/tests 目录下为单元测试用例。
执行指令:
tox -epy27
# 建立 py27 虚拟环境 py27 create: /opt/stack/nova/.tox/py27 # 安装测试依赖包,主要是上述单元测试工具 py27 installdeps: -r/opt/stack/nova/test-requirements.txt # 安装 Python 工程 py27 develop-inst: /opt/stack/nova py27 installed: DEPRECATION: Python 2.7 will reach the end of its life on January 1st, 2020. Please upgrade your Python as Python 2.7 won't be maintained after that date. A future version of pip will drop support for Python 2.7.,alembic==1.0.8,amqp==2.4.2,appdirs==1.4.3,asn1crypto==0.24.0,atomicwrites==1.3.0,attrs==19.1.0,automaton==1.16.0,..., # 运行测试以前设定 PYTHONHASHSEED py27 run-test-pre: PYTHONHASHSEED='3303221241' # 运行测试,执行指令删除 *.pyc 文件 py27 runtests: commands[0] | find . -type f -name '*.pyc' -delete # 执行 stestr run,执行 ./nova/tests/unit 下的单元测试用例 py27 runtests: commands[1] | stestr run # {2} - stestr worker id # nova.tests.unit.api.openstack.compute.test_agents.AgentsTestV21.test_agents_create_without_architecture - 单元测试用例 # [0.241984s] - 执行时间 # ok - 执行成功 {2} nova.tests.unit.api.openstack.compute.test_agents.AgentsTestV21.test_agents_create_without_architecture [0.241984s] ... ok ... ====== Totals ====== Ran: 17506 tests in 282.0000 sec. - Passed: 17440 - Skipped: 65 - Expected Fail: 1 - Unexpected Success: 0 - Failed: 0 Sum of execute time for each test: 4053.6704 sec. # stestr 多进程工做的负载均衡结果 ============== Worker Balance ============== - Worker 0 (1095 tests) => 0:04:19.881048 - Worker 1 (1093 tests) => 0:04:17.439649 - Worker 2 (1094 tests) => 0:04:06.930933 - Worker 3 (1094 tests) => 0:04:14.145450 - Worker 4 (1094 tests) => 0:04:39.898363 - Worker 5 (1094 tests) => 0:04:19.573067 - Worker 6 (1094 tests) => 0:04:19.100025 - Worker 7 (1095 tests) => 0:04:20.019838 - Worker 8 (1092 tests) => 0:04:11.370670 - Worker 9 (1095 tests) => 0:04:09.487689 - Worker 10 (1095 tests) => 0:04:14.567449 - Worker 11 (1096 tests) => 0:04:20.263249 - Worker 12 (1092 tests) => 0:04:02.847126 - Worker 13 (1097 tests) => 0:04:04.662348 - Worker 14 (1094 tests) => 0:04:16.163928 - Worker 15 (1092 tests) => 0:03:56.793120 # 执行 OSProfiler 单元测试指令 py27 runtests: commands[2] | env TEST_OSPROFILER=1 stestr run --combine --no-discover nova.tests.unit.test_profiler {0} nova.tests.unit.test_profiler.TestProfiler.test_all_public_methods_are_traced [0.549399s] ... ok ====== Totals ====== Ran: 1 tests in 0.0000 sec. - Passed: 1 - Skipped: 0 - Expected Fail: 0 - Unexpected Success: 0 - Failed: 0 Sum of execute time for each test: 0.5494 sec. ============== Worker Balance ============== - Worker 0 (1 tests) => 0:00:00.549399 # 执行 stestr slowest 指令输出执行最慢的测试用例 py27 runtests: commands[3] | stestr slowest Test id Runtime (s) --------------------------------------------------------------------------------------------------------------------------------------------------------- ----------- nova.tests.unit.db.test_migrations.TestNovaMigrationsSQLite.test_walk_versions 32.981 nova.tests.unit.test_fixtures.TestDatabaseAtVersionFixture.test_fixture_schema_version 11.045 nova.tests.unit.api.openstack.compute.test_availability_zone.ServersControllerCreateTestV21.test_create_instance_with_invalid_availability_zone_too_short 10.151 nova.tests.unit.api.openstack.compute.test_disk_config.DiskConfigTestCaseV21.test_create_server_with_auto_disk_config 9.632 nova.tests.unit.api.openstack.compute.test_flavor_manage.FlavorManagerPolicyEnforcementV21.test_delete_policy_rbac_change_to_default_action_rule 9.402 nova.tests.unit.api.openstack.compute.test_flavors.DisabledFlavorsWithRealDBTestV21.test_index_should_list_disabled_flavors_to_admin 9.401 nova.tests.unit.api.openstack.compute.test_access_ips.AccessIPsAPIValidationTestV21.test_create_server_with_invalid_access_ipv6 9.238 nova.tests.unit.api.openstack.compute.test_availability_zone.ServersControllerCreateTestV21.test_create_instance_with_invalid_availability_zone_not_str 9.168 nova.tests.unit.api.openstack.compute.test_access_ips.AccessIPsAPIValidationTestV21.test_rebuild_server_with_invalid_access_ipv6 9.152 nova.tests.unit.virt.libvirt.storage.test_rbd.RbdTestCase.test_cleanup_volumes_fail_snapshots 9.114 ___________________________________________________________________________________________________________________________________ summary ____________________________________________________________________________________________________________________________________ py27: commands succeeded congratulations :)
经过日志分析可见,tox 工具启动 py27 单元测试的核心指令有 3 条:
这里咱们主要关注第一条指令,它涉及到 Nova 的单元测试用例是怎么实现的问题。
下面咱们以 GET /servers/{server_uuid} 的测试单元为例。
调试指令:
cd /opt/stack/nova/; stestr run --combine --no-discover 'nova.tests.unit.api.openstack.compute.test_serversV21.ServersControllerTest.test_get_server_by_uuid'
NOTE:实际上你或许应该在对应的虚拟环境中执行调试。e.g.
$ source .tox/py27/bin/activate $ stestr run --combine --no-discover "neutron.tests.unit.scheduler.test_dhcp_agent_scheduler.TestNetworksFailover.test_filter_bindings"
代码分析:
# /opt/stack/nova/nova/tests/unit/api/openstack/compute/test_serversV21.py class ServersControllerTest(ControllerTest): def req(self, url, use_admin_context=False): return fakes.HTTPRequest.blank(url, use_admin_context=use_admin_context, version=self.wsgi_api_version) ... def test_get_server_by_uuid(self): # 生成 request 对象 req = self.req('/fake/servers/%s' % FAKE_UUID) # self.controller 是 nova.api.openstack.compute.servers.ServersController 实例对象 # nova.api.openstack.compute.servers.ServersController.show() 是测试单元 res_dict = self.controller.show(req, FAKE_UUID) self.assertEqual(res_dict['server']['id'], FAKE_UUID)
按照正常的黑盒测试思路,能不能获取 Server,发个请求就知道了。固然了,这样作的前提是后端服务正常的状况下,但运行单元测试的环境显然没有后端服务进程,因此咱们就须要 Fake(欺骗)出一些 “后端服务进程” 出来,fakes.HTTPRequest
的含义正是如此。在测试用例 test_get_server_by_uuid()
中,只要输入指定的 FAKE_UUID,而后返回指定的 res_dict,经过了断言的判断,那么 GET /servers/{server_uuid} 这个测试用例咱们就认为是正确的。
(Pdb) FAKE_UUID 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' (Pdb) pp self.controller.show(req, FAKE_UUID) {'server': {'OS-DCF:diskConfig': 'MANUAL', 'OS-EXT-AZ:availability_zone': u'nova', 'OS-EXT-SRV-ATTR:host': None, 'OS-EXT-SRV-ATTR:hypervisor_hostname': None, 'OS-EXT-SRV-ATTR:instance_name': 'instance-00000002', 'OS-EXT-STS:power_state': 1, 'OS-EXT-STS:task_state': None, 'OS-EXT-STS:vm_state': u'active', 'OS-SRV-USG:launched_at': None, 'OS-SRV-USG:terminated_at': None, 'accessIPv4': '', 'accessIPv6': '', 'addresses': OrderedDict([(u'test1', [{'OS-EXT-IPS-MAC:mac_addr': u'aa:aa:aa:aa:aa:aa', 'version': 4, 'addr': u'192.168.1.100', 'OS-EXT-IPS:type': u'fixed'}, {'OS-EXT-IPS-MAC:mac_addr': u'aa:aa:aa:aa:aa:aa', 'version': 6, 'addr': u'2001:db8:0:1::1', 'OS-EXT-IPS:type': u'fixed'}])]), 'config_drive': None, 'created': '2010-10-10T12:00:00Z', 'flavor': {'id': '2', 'links': [{'href': 'http://localhost/fake/flavors/2', 'rel': 'bookmark'}]}, 'hostId': '', 'id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'image': {'id': '10', 'links': [{'href': 'http://localhost/fake/images/10', 'rel': 'bookmark'}]}, 'key_name': u'', 'links': [{'href': 'http://localhost/v2/fake/servers/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'rel': 'self'}, {'href': 'http://localhost/fake/servers/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'rel': 'bookmark'}], 'metadata': {'seq': u'2'}, 'name': u'server2', 'os-extended-volumes:volumes_attached': [{'id': u'some_volume_1'}, {'id': u'some_volume_2'}], 'progress': 0, 'security_groups': [{'name': u'fake-0-0'}, {'name': u'fake-0-1'}], 'status': 'ACTIVE', 'tenant_id': u'fake_project', 'updated': '2010-11-11T11:00:00Z', 'user_id': u'fake_user'}}
若是你尝试断点调试下去的话,你会发现测试单元的程序流会定格在这个地方:
# /opt/stack/nova/nova/api/openstack/common.py def get_instance(compute_api, context, instance_id, expected_attrs=None, cell_down_support=False): """Fetch an instance from the compute API, handling error checking.""" try: return compute_api.get(context, instance_id, expected_attrs=expected_attrs, cell_down_support=cell_down_support) except exception.InstanceNotFound as e: raise exc.HTTPNotFound(explanation=e.format_message())
此时的 compute_api.get
再也不是 nova.compute.api:API.get() 方法了,而是一个 _AutospecMagicMock 对象,它的 side_effect 是 nova.unit.api.openstack.fakes:fake_compute_get._return_server_obj 函数:
# /opt/stack/nova/nova/tests/unit/api/openstack/fakes.py def fake_compute_get(**kwargs): def _return_server_obj(context, *a, **kw): return stub_instance_obj(context, **kwargs) return _return_server_obj ... def stub_instance_obj(ctxt, *args, **kwargs): db_inst = stub_instance(*args, **kwargs) expected = ['metadata', 'system_metadata', 'flavor', 'info_cache', 'security_groups', 'tags'] inst = objects.Instance._from_db_object(ctxt, objects.Instance(), db_inst, expected_attrs=expected) inst.fault = None if db_inst["services"] is not None: # This ensures services there if one wanted so inst.services = db_inst["services"] return inst
显然的,这是一次 Mock,在 ServersControllerTest 的父类 ControllerTest 的 setUp 中完成:
# /opt/stack/nova/nova/tests/unit/api/openstack/compute/test_serversV21.py class ControllerTest(test.TestCase): def setUp(self): super(ControllerTest, self).setUp() ... return_server = fakes.fake_compute_get(id=2, availability_zone='nova', launched_at=None, terminated_at=None, security_groups=security_groups, task_state=None, vm_state=vm_states.ACTIVE, power_state=1) ... self.mock_get = self.useFixture(fixtures.MockPatchObject( compute_api.API, 'get', side_effect=return_server)).mock
在 setUp 中经过 fixtures.MockPatchObject 将 compute_api.API.get 方法 Mock 成了 fakes._return_server_obj 函数,并最终返回伪造的数据。这就是一个完整的单元测试用例套路了。
http://www.choudan.net/2013/08/12/OpenStack-Small-Tests.html
https://blog.csdn.net/quqi99/article/details/8533071