Ansible 模块单元测试

简介

本文档解释了为什么、如何以及何时应该为 Ansible 模块使用单元测试。本文档不适用于 Ansible 的其他部分,因为对于这些部分,建议通常更接近 Python 标准。开发者指南中单元测试提供了 Ansible 单元测试的基本文档。本文件对于新的 Ansible 模块编写者来说应该是可读的。如果您发现它不完整或令人困惑,请在 Ansible 论坛 上打开一个错误报告或寻求帮助。

什么是单元测试?

Ansible 在 test/units 目录中包含一组单元测试。这些测试主要涵盖内部机制,但也可能涵盖 Ansible 模块。单元测试的结构与代码库的结构相匹配,因此位于 test/units/modules/ 目录中的测试按模块分组组织。

集成测试可用于大多数模块,但在某些情况下,无法使用集成测试验证情况。这意味着 Ansible 单元测试用例可能不仅仅是测试最小的单元,在某些情况下还将包含一定程度的功能测试。

为什么要使用单元测试?

Ansible 单元测试有优点和缺点。了解这些很重要。优点包括:

  • 大多数单元测试比大多数 Ansible 集成测试快得多。开发人员可以在其本地系统上定期运行完整的单元测试套件。

  • 单元测试可以由无法访问模块设计目标系统的开发人员运行,从而允许验证核心功能的更改不会破坏模块的预期。

  • 单元测试可以轻松替换系统函数,从而允许测试在实践中难以实现的软件。例如,sleep() 函数可以被替换,并且我们可以在不实际等待十分钟的情况下检查是否调用了十分钟的休眠。

  • 单元测试在不同的 Python 版本上运行。这允许我们确保代码在不同的 Python 版本上具有相同行为。

单元测试也有一些潜在的缺点。单元测试通常不会直接测试软件的实际有用功能,而是只测试内部实现。

  • 测试软件内部不可见功能的单元测试可能会使重构变得困难,如果这些内部功能必须更改(另见下面的“方法”中的命名)。

  • 即使内部功能运行正确,也可能存在被测内部代码与交付给用户的实际结果之间的问题。

通常,Ansible 集成测试(用 Ansible YAML 编写的)为大多数模块功能提供了更好的测试。如果这些测试已经测试了某个功能并且性能良好,那么再提供覆盖相同区域的单元测试可能意义不大。

何时使用单元测试

在许多情况下,单元测试比集成测试更好。例如,测试使用集成测试不可能、缓慢或非常难以测试的事物,例如:

  • 强制出现罕见/奇怪/随机的情况,例如特定的网络故障和异常。

  • 对缓慢的配置 API 进行广泛测试。

  • 集成测试无法作为 Azure Pipelines 中运行的主要 Ansible 持续集成的一部分运行的情况。

提供快速反馈

示例

rds_instance 测试用例的一个步骤最多可能需要 20 分钟(在 Amazon 中创建 RDS 实例所需的时间)。整个测试运行可能持续一个多小时。所有 16 个单元测试的执行时间不到 2 秒。

能够在单元测试中运行代码所节省的时间使得在修复模块错误时创建单元测试变得值得,即使这些测试以后并不经常发现问题。作为一个基本目标,每个模块都应该至少有一个单元测试,这将在简单的案例中提供快速反馈,而无需等待集成测试完成。

确保外部接口的正确使用

单元测试可以检查运行外部服务的方式,以确保它们符合规范或尽可能高效 *即使最终输出不会改变*。

示例

一次安装多个软件包比分别安装每个软件包效率高得多。最终结果相同:所有软件包都已安装,因此效率很难通过集成测试来验证。通过提供模拟软件包管理器并验证它只被调用一次,我们可以构建一个有价值的模块效率测试。

另一个相关的用途是 API 具有不同行为的版本的情况。一个从事新版本工作的程序员可能会更改模块以使用新 API 版本,并且无意中破坏旧版本。一个检查对旧版本的调用是否正确发生的测试用例可以帮助避免这个问题。在这种情况下,在测试用例名称中包含版本号非常重要(参见下面的 单元测试命名)。

提供具体的测试设计

通过构建对代码特定部分的要求,然后根据该要求进行编码,单元测试_可以_有时改进代码并帮助未来的开发者理解该代码。

另一方面,测试代码内部实现细节的单元测试几乎总是弊大于利。测试要安装的软件包存储在列表中会减慢速度并使未来的开发者感到困惑,他们可能需要将该列表更改为字典以提高效率。这个问题可以通过清晰的测试命名来减少一些,这样未来的开发者就会立即知道要删除测试用例,但通常最好完全省略测试用例,而测试代码的实际有价值的功能,例如安装作为模块参数提供的所有软件包。

如何对 Ansible 模块进行单元测试

有许多对模块进行单元测试的技术。请注意,大多数没有单元测试的模块的结构使得测试非常困难,并且可能导致非常复杂的测试,这些测试需要比代码更多的工作。有效地使用单元测试可能会导致您重构代码。这通常是一件好事,并导致整体上更好的代码。良好的重构可以使您的代码更清晰易懂。

单元测试的命名

单元测试应该有逻辑名称。如果正在测试的模块的开发人员破坏了测试用例,则应该很容易从名称中弄清楚单元测试涵盖的内容。如果单元测试旨在验证与特定软件或 API 版本的兼容性,则在单元测试的名称中包含该版本。

例如,test_v2_state_present_should_call_create_server_with_name()就是一个好的名称,而test_create_server()则不是。

模拟对象的使用

模拟对象(来自https://docs.pythonlang.cn/3/library/unittest.mock.html)在构建单元测试以处理特殊/困难的情况时非常有用,但它们也可能导致复杂和混乱的编码情况。模拟对象的一个很好的用途是模拟API。至于“six”,Ansible捆绑了“mock” Python包(使用import units.compat.mock)。

确保使用模拟对象时失败案例可见

module.fail_json()这样的函数通常会终止执行。当使用模拟模块对象运行时,这种情况不会发生,因为模拟函数调用总是返回另一个模拟对象。您可以设置模拟对象以引发异常(如上所示),或者您可以断言在每个测试中这些函数都没有被调用。例如

module = MagicMock()
function_to_test(module, argument)
module.fail_json.assert_not_called()

这不仅适用于调用主模块,也适用于模块中几乎任何获取模块对象的其它函数。

实际模块的模拟

实际模块的设置非常复杂(参见下面的传递参数),并且对于大多数使用模块的函数来说通常不需要。相反,您可以使用模拟对象作为模块,并创建被测试函数所需的任何模块属性。如果您这样做,请注意模块退出函数需要特殊处理,如上所述,可以通过抛出异常或确保它们没有被调用来处理。例如

class AnsibleExitJson(Exception):
    """Exception class to be raised by module.exit_json and caught by the test case"""
    pass

# you may also do the same to fail json
module = MagicMock()
module.exit_json.side_effect = AnsibleExitJson(Exception)
with self.assertRaises(AnsibleExitJson) as result:
    results = my_module.test_this_function(module, argument)
module.fail_json.assert_not_called()
assert results["changed"] == True

使用单元测试用例定义API

API交互通常最好使用Ansible集成测试部分中定义的函数测试进行测试,这些测试针对实际的API运行。在某些情况下,单元测试可能效果更好。

根据API规范定义模块

对于与Web服务的交互模块来说,这种情况尤其重要,因为这些Web服务提供Ansible使用的API,但不受用户控制。

通过编写自定义的调用模拟,这些模拟返回来自API的数据,我们可以确保只有API规范中明确定义的功能存在于消息中。这意味着我们可以检查我们使用了正确的参数,而没有其它任何内容。

示例:在rds_instance单元测试中定义了一个简单的实例状态:

def simple_instance_list(status, pending):
    return {u'DBInstances': [{u'DBInstanceArn': 'arn:aws:rds:us-east-1:1234567890:db:fakedb',
                              u'DBInstanceStatus': status,
                              u'PendingModifiedValues': pending,
                              u'DBInstanceIdentifier': 'fakedb'}]}

然后使用它来创建一个状态列表

rds_client_double = MagicMock()
rds_client_double.describe_db_instances.side_effect = [
    simple_instance_list('rebooting', {"a": "b", "c": "d"}),
    simple_instance_list('available', {"c": "d", "e": "f"}),
    simple_instance_list('rebooting', {"a": "b"}),
    simple_instance_list('rebooting', {"e": "f", "g": "h"}),
    simple_instance_list('rebooting', {}),
    simple_instance_list('available', {"g": "h", "i": "j"}),
    simple_instance_list('rebooting', {"i": "j", "k": "l"}),
    simple_instance_list('available', {}),
    simple_instance_list('available', {}),
]

然后将这些状态用作模拟对象的返回值,以确保await函数等待所有可能意味着RDS实例尚未完成配置的状态。

rds_i.await_resource(rds_client_double, "some-instance", "available", mod_mock,
                     await_pending=1)
assert(len(sleeper_double.mock_calls) > 5), "await_pending didn't wait enough"

通过这样做,我们检查await函数将继续等待,即使遇到可能不寻常的状态,这些状态也无法通过集成测试可靠地触发,但在现实中却会不可预测地发生。

定义一个针对多个API版本的模块

对于与许多不同软件版本交互的模块来说,这种情况尤其重要;例如,包安装模块可能需要与许多不同的操作系统版本一起工作。

通过使用先前从API的各种版本中存储的数据,我们可以确保代码针对将从该系统版本发送的实际数据进行测试,即使该版本非常模糊并且在测试期间不太可能可用。

Ansible单元测试的特殊情况

Ansible模块环境的单元测试有一些特殊情况。最常见的情况如下所述,可以通过查看现有单元测试的源代码或向社区提问找到其它建议。

模块参数处理

运行模块的主函数有两个问题

  • 由于模块应该在STDIN上接受参数,因此正确设置参数以便模块将它们作为参数获取有点困难。

  • 所有模块都应通过调用module.fail_json()module.exit_json()来结束,但在测试环境中这些方法无法正常工作。

传递参数

要正确地将参数传递给模块,请使用set_module_args方法,该方法接受字典作为其参数。模块创建和参数处理通过实用程序基本部分中的AnsibleModule对象进行处理。通常情况下,它接受STDIN上的输入,这对于单元测试来说并不方便。当设置特殊变量时,它将被视为模块在STDIN上接收输入。只需在设置模块之前调用该函数即可

import json
from units.modules.utils import set_module_args
from ansible.module_utils.common.text.converters import to_bytes

def test_already_registered(self):
    set_module_args({
        'activationkey': 'key',
        'username': 'user',
        'password': 'pass',
    })

正确处理退出

module.exit_json()函数在测试环境中无法正常工作,因为它在退出时将错误信息写入STDOUT,在那里很难检查。可以通过用引发异常的函数替换它(以及module.fail_json())来减轻这种情况

def exit_json(*args, **kwargs):
    if 'changed' not in kwargs:
        kwargs['changed'] = False
    raise AnsibleExitJson(kwargs)

现在您可以通过测试正确的异常来确保首先调用的函数是您期望的函数。

def test_returned_value(self):
    set_module_args({
        'activationkey': 'key',
        'username': 'user',
        'password': 'pass',
    })

    with self.assertRaises(AnsibleExitJson) as result:
        my_module.main()

同样的技术可以用来替换module.fail_json()(用于模块的失败返回)和aws_module.fail_json_aws()(用于Amazon Web Services的模块)。

运行主函数

如果您确实想要运行模块的实际主函数,则必须导入模块,如上所述设置参数,设置适当的退出异常,然后运行模块。

# This test is based around pytest's features for individual test functions
import pytest
import ansible.modules.module.group.my_module as my_module

def test_main_function(monkeypatch):
    monkeypatch.setattr(my_module.AnsibleModule, "exit_json", fake_exit_json)
    set_module_args({
        'activationkey': 'key',
        'username': 'user',
        'password': 'pass',
    })
    my_module.main()

处理对外部可执行文件的调用

模块必须使用AnsibleModule.run_command()来执行外部命令。此方法需要被模拟。

这是一个AnsibleModule.run_command()的简单模拟(取自test/units/modules/packaging/os/test_rhn_register.py

with patch.object(basic.AnsibleModule, 'run_command') as run_command:
    run_command.return_value = 0, '', ''  # successful execution, no output
    with self.assertRaises(AnsibleExitJson) as result:
        my_module.main()
        self.assertFalse(result.exception.args[0]['changed'])
# Check that run_command has been called
run_command.assert_called_once_with('/usr/bin/command args')
self.assertEqual(run_command.call_count, 1)
self.assertFalse(run_command.called)

一个完整的示例

下面的示例是一个完整的框架,它重用了上面解释的模拟,并为Ansible.get_bin_path()添加了一个新的模拟。

import json

from units.compat import unittest
from units.compat.mock import patch
from ansible.module_utils import basic
from ansible.module_utils.common.text.converters import to_bytes
from ansible.modules.namespace import my_module


def set_module_args(args):
    """prepare arguments so that they will be picked up during module creation"""
    args = json.dumps({'ANSIBLE_MODULE_ARGS': args})
    basic._ANSIBLE_ARGS = to_bytes(args)


class AnsibleExitJson(Exception):
    """Exception class to be raised by module.exit_json and caught by the test case"""
    pass


class AnsibleFailJson(Exception):
    """Exception class to be raised by module.fail_json and caught by the test case"""
    pass


def exit_json(*args, **kwargs):
    """function to patch over exit_json; package return data into an exception"""
    if 'changed' not in kwargs:
        kwargs['changed'] = False
    raise AnsibleExitJson(kwargs)


def fail_json(*args, **kwargs):
    """function to patch over fail_json; package return data into an exception"""
    kwargs['failed'] = True
    raise AnsibleFailJson(kwargs)


def get_bin_path(self, arg, required=False):
    """Mock AnsibleModule.get_bin_path"""
    if arg.endswith('my_command'):
        return '/usr/bin/my_command'
    else:
        if required:
            fail_json(msg='%r not found !' % arg)


class TestMyModule(unittest.TestCase):

    def setUp(self):
        self.mock_module_helper = patch.multiple(basic.AnsibleModule,
                                                 exit_json=exit_json,
                                                 fail_json=fail_json,
                                                 get_bin_path=get_bin_path)
        self.mock_module_helper.start()
        self.addCleanup(self.mock_module_helper.stop)

    def test_module_fail_when_required_args_missing(self):
        with self.assertRaises(AnsibleFailJson):
            set_module_args({})
            my_module.main()


    def test_ensure_command_called(self):
        set_module_args({
            'param1': 10,
            'param2': 'test',
        })

        with patch.object(basic.AnsibleModule, 'run_command') as mock_run_command:
            stdout = 'configuration updated'
            stderr = ''
            rc = 0
            mock_run_command.return_value = rc, stdout, stderr  # successful execution

            with self.assertRaises(AnsibleExitJson) as result:
                my_module.main()
            self.assertFalse(result.exception.args[0]['changed']) # ensure result is changed

        mock_run_command.assert_called_once_with('/usr/bin/my_command --value 10 --name test')

重构模块以启用测试模块设置和其他流程

模块通常具有一个main()函数,该函数设置模块然后执行其他操作。这使得检查参数处理变得困难。可以通过将模块配置和初始化移动到单独的函数中来简化此过程。例如

argument_spec = dict(
    # module function variables
    state=dict(choices=['absent', 'present', 'rebooted', 'restarted'], default='present'),
    apply_immediately=dict(type='bool', default=False),
    wait=dict(type='bool', default=False),
    wait_timeout=dict(type='int', default=600),
    allocated_storage=dict(type='int', aliases=['size']),
    db_instance_identifier=dict(aliases=["id"], required=True),
)

def setup_module_object():
    module = AnsibleAWSModule(
        argument_spec=argument_spec,
        required_if=required_if,
        mutually_exclusive=[['old_instance_id', 'source_db_instance_identifier',
                             'db_snapshot_identifier']],
    )
    return module

def main():
    module = setup_module_object()
    validate_parameters(module)
    conn = setup_client(module)
    return_dict = run_task(module, conn)
    module.exit_json(**return_dict)

现在可以针对模块初始化函数运行测试了。

def test_rds_module_setup_fails_if_db_instance_identifier_parameter_missing():
    # db_instance_identifier parameter is missing
    set_module_args({
        'state': 'absent',
        'apply_immediately': 'True',
     })

    with self.assertRaises(AnsibleFailJson) as result:
        my_module.setup_json

另见test/units/module_utils/aws/test_rds.py

请注意,argument_spec字典在模块变量中可见。这有两个优点:允许对参数进行显式测试,并允许轻松创建用于测试的模块对象。

同样的重构技术对于测试其他功能也可能很有价值,例如模块查询其配置对象的部件。

维护 Python 2 兼容性的陷阱

如果您使用 Python 2.6 标准库中的 mock 库,则许多 assert 函数缺失,但会返回成功的结果。这意味着测试用例应特别注意_不要_使用 Python 3 文档中标记为 _new_ 的函数,因为即使代码在旧版本的 Python 上运行时已损坏,测试也可能始终成功。

对此,一种有帮助的开发方法是确保所有测试都在 Python 2.6 下运行,并且已检查测试用例中的每个断言都能通过破坏 Ansible 中的代码来触发失败。

警告

维护 Python 2.6 兼容性

请记住,模块需要维护与 Python 2.6 的兼容性,因此模块的单元测试也应与 Python 2.6 兼容。

另请参阅

沟通

有问题?需要帮助?想分享您的想法?请访问 Ansible 沟通指南

单元测试

Ansible 单元测试文档

测试 Ansible 和集合

在本地运行测试,包括收集和报告覆盖率数据

开发模块

开始开发模块

Python 3 文档 - 26.4. unittest — 单元测试框架

Python 3 中 unittest 框架的文档

Python 2 文档 - 25.3. unittest — 单元测试框架

最早支持的 unittest 框架的文档 - 来自 Python 2.6

pytest:帮助您编写更好的程序

pytest 的文档 - 用于运行 Ansible 单元测试的实际框架

测试您的代码(来自《Python 之禅》!)

关于测试 Python 代码的常规建议

Bob 叔在 YouTube 上的许多视频

单元测试是各种软件开发理念(包括极限编程 (XP)、整洁编码)的一部分。Bob 叔讲解了如何从中受益

“为什么大多数单元测试都是浪费”

一篇关于单元测试成本的警示文章

“对“为什么大多数单元测试都是浪费”的回应”

一篇关于如何维护单元测试价值的回应