OpenStack 的单元测试

版权声明:转载请注明出处 JmilkFan_范桂飓:http://blog.csdn.net/jmilk https://blog.csdn.net/Jmilk/article/details/89317163

目录

前言

在 OpenStack 官方指导手册(https://wiki.openstack.org/wiki/TestGuide)中明确的将 OpenStack 测试分为小型测试(单元测试)、中型测试(功能测试)以及大型测试(集成测试),在本文中我们关注的是单元测试(https://wiki.openstack.org/wiki/SmallTestingGuide)。

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.

单元测试能提高生产率

在这里插入图片描述
单元测试是与源代码(测试单元)捆绑最为紧密的测试方法,如果测试用例不通过,可以迅速定位出问题所在。上图清晰明了的说明了单元测试(UNIT TESTS)之于生产效率(PRODUCTIVITY)的关系。请记住,单元测试虽然要编写更多的代码,但却能为团队节省更多的生产资源。因为你不清楚什么时候的小提交会导致全局性的逻辑错乱,而处理这样的问题往往需要花费昂贵的沟通成本,在开源社区的协作场景中尤甚。

Python 单元测试工具清单

  • unittest:是 Python 的标准单元测试库,提供了最基本的单元测试框架和单元测试运行器。
  • mock:在 Python 3.x 中作为一个模块被内嵌到 unittest 标准库。简单的说,mock 就是制造假数据(对象)的模块,以此来模拟多种代码运行的情景,而无需真的发生了这种情景。
  • fixtures:第三方模块,对 unittest 的 Test Fixture 机制进行了增强,有效提高了测试代码的复用率。
  • testtools:第三方模块,是 unittest 的扩展,对 unittest 进行了断言之类的功能增强,让测试代码的编写更加方便。
  • testscenarios:第三方模块,用于满足了 “场景测试” 的需求,是节省重复代码的有效手段。
  • subunit:第三方模块,是一种传输测试结果的数据流协议,有助于对接多种类型的数据分析工具。
  • testrepository:第三方模块,提供了一个测试结果存储库,同时也提供了一些单元测试用例的管理方法。
  • stestr:第三方模块,是 testrepository 的分支,实现了一个并行的测试运行器,旨在使用多进程来运行 unittest 的 Test Suites。是推荐的 testrepository 替代方案。
  • coverage:第三方模块,用于统计单元测试用例的覆盖率。
  • tox:第三方模块,用于管理和构建单元测试虚拟环境(virtualenv)。

unittest

unittest 是 Python 的标准单元测试库,提供了最基本的单元测试框架和单元测试运行器。

官方文档https://docs.python.org/3.7/library/unittest.html

单元测试框架 TestCast 使用示例

# 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

Test Discover

unittest 库提供了 Test Discover(测试发现)功能,开发者只需要遵守 “约定俗成” 的命名规则,单元测试用例就可以被自动的发现并运行。

通过匹配条件自动发现单元测试模块示例:

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}

Test Fixture

unittest 库实现了 Test Fixture(测试夹具)机制,用于抽象测试环境设置、清理逻辑。Such a working environment for the testing code is called a fixture.

单元测试框架 TestCase 通过定义 setUp()tearDown()setUpClass()tearDownClass()setUpModuletearDownModule 等方法来进行 单元测试前的设置工作单元测试后的清理工作

  • 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 机制解决了这个问题。

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(断言)

Assert(断言)是单元测试关键,用于检测一个条件是否符合预期。如果是真,不做任何事。如果为假,就抛出 AssertionError 和错误信息。

NTOE:更详细的信息建议查看官方文档。
在这里插入图片描述

mock

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 模块大致上可以总结出这样的流程:

  1. 确定测试单元中要模拟的 Python 对象,可以是一个类、一个函数、一个实例对象或者是一个输入。
  2. 在编写单元测试用例的过程中,实例化 Mock 对象并设置其属性、行为与被替代的 Python 对象的属性、行为能够符合预期。比如:被调用时会返回预期的值,被访问实例属性时会返回预期的值等。

Mock 类的原型

class Mock(spec=None, side_effect=None, return_value=DEFAULT, wraps=None, name=None, spec_set=None, **kwargs)
  • name:命名一个 Mock 实例化对象,起到标识作用。
  • side_effect:指定一个可调用对象,一般为函数。当 Mock 实例化对象被调用,如果该可调用对象返回的不是 DEFAULT,则以该可调用对象的返回值作为 Mock 实例化对象调用的返回值。
  • return_value:显示指定返回一个值(或对象),当 Mock 实例化对象被调用,如果 side_effect 指定的可调用对象返回的是 DEFAULT,则以 return_value 作为 Mock 实例化对象调用的返回值。

NTOE:更详细的信息建议查看官方文档。

Mock 对象的实例属性自动创建机制

当访问一个 Mock 实例化对象不存在的实例属性时,它首先会自动创建一个子对象,然后对正在访问的实例属性进行赋值,这个机制对实现多级属性的 Mock 很方便。e.g.

>>> import mock
>>> client = mock.Mock()
# 自动创建子对象
>>> client.v2_client.get.return_value = '200'
>>> client.v2_client.get()
'200'

Mock 的作用域

有时候我们会希望 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()
  • Mock 掉一个 Python 对象的实例方法
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)
  • Mock 掉一个 Python 对象的实例属性
    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

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()

MockPatchObject 和 MockPatch

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

testtools:第三方模块,是 unittest 的扩展,对 unittest 进行了断言之类的功能增强,让测试代码的编写更加方便。

官方文档https://testtools.readthedocs.io/en/latest/

增强项目

  • Better assertion methods
  • Matchers: better than assertion methods
  • More debugging info, when you need it
  • Extend unittest, but stay compatible and re-usable
  • Cross-Python compatibility

示例

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(场景)

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 具有不同的实现。

python-subunit

subunit:第三方模块,是一种传输测试结果的数据流协议,有助于对接多种类型的数据分析工具。

官方文档https://pypi.org/project/python-subunit/

当测试用例很多的时候,如何高效处理测试结果就显得很重要了。subunit 协议能够将测试结果转换成多种格式,可以灵活对接多种数据分析工具。

Subunit supplies the following filters:

  • tap2subunit - convert perl’s TestAnythingProtocol to subunit.
  • subunit2csv - convert a subunit stream to csv.
  • subunit2disk - export a subunit stream to files on disk.
  • subunit2pyunit - convert a subunit stream to pyunit test results.
  • subunit2gtk - show a subunit stream in GTK.
  • subunit2junitxml - convert a subunit stream to JUnit’s XML format.
  • subunit-diff - compare two subunit streams.
  • subunit-filter - filter out tests from a subunit stream.
  • subunit-ls - list info about tests present in a subunit stream.
  • subunit-stats - generate a summary of a subunit stream.
  • subunit-tags - add or remove tags from a stream.

python-subunit 也提供了测试运行器

python -m subunit.run mypackage.tests.test_suite

使用 python-subunit 提供的数据流转换工具

python -m subunit.run mypackage.tests.test_suite | subunit2pyunit

testrepository(仓库)

testrepository:第三方模块,提供了一个测试结果存储库,同时也提供了一些单元测试用例的管理方法。

官方文档https://pypi.org/project/testrepository/

在自动化单元测试流程中引入 testrepository 可以:

  • 显示用例运行时间
  • 显示运行失败的用例
  • 重新运行上次运行失败的用例

testrepository 使用流程

  • Create a config file
$ touch .testr.conf
  • Create a repository
$ testr init
  • Load a test run into the repository
$ testr load < testrun
  • Query the repository
$ testr stats $ testr last $ testr failing
  • Delete a repository
$ 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

stestr:第三方模块,是 testrepository 的分支,实现了一个并行的测试运行器,旨在使用多进程来运行 unittest 的 Test Suites。是推荐的 testrepository 替代方案。stestr 与 testrepository 具有相同上层概念对象,但底层却以不同的方式运作。虽然 stestr 不需要依赖 python-subunit 的测试运行器,但仍会使用 subunit 协议。

官方文档https://stestr.readthedocs.io/en/latest/

stestr 使用流程

  • 创建 .testr.conf 配置文件,此文件告诉了 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(覆盖)

coverage:第三方模块,用于统计单元测试用例的覆盖率。

官方文档https://coverage.readthedocs.io/en/v4.5.x/

coverage 本质用于统计代码覆盖了,即有多少代码被执行了,而在单元测试场景中,coverage 就用于统计单元测试的覆盖率,即测试单元在整个工程中的比率。

coverage 使用流程

  • Use coverage run to run your program and gather data
# 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
  • Use coverage report to report on the results
$ 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%
  • For a nicer presentation, use coverage html to get annotated HTML listings detailing missed lines
$ coverage html

tox

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 运作流程图
在这里插入图片描述

Nova 的单元测试分析(Rocky)

Nova 的 tox.ini 配置

[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 目录下为单元测试用例。

小结 Nova 单元测试工作流程

  1. 使用 unittest, mock, testtools, fixtures, testscenarios 等工具编写 Python 工程的单元测试代码。
  2. 使用 tox 来管理单元测试虚拟运行环境。
  3. tox.ini 定义了 testrepository 或 stestr 执行指令来启动测试流程。
  4. testrepository 调用 subunit 来执行测试用例并输出测试结果到存储库,或者是 stestr 直接执行测试用例并存储测试结果。
  5. tox.ini 定义了 coverage 执行指令生成单元测试覆盖率报告。

执行 py27 单元测试

执行指令:

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 条:

  1. stestr run
  2. env TEST_OSPROFILER=1 stestr run --combine --no-discover nova.tests.unit.test_profiler
  3. stestr slowest

这里我们主要关注第一条指令,它涉及到 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

没有更多推荐了,返回首页