在 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