Ansible 模块单元测试

简介

本文档解释了为什么、如何以及何时为 Ansible 模块使用单元测试。本文档不适用于 Ansible 的其他部分,这些部分通常更接近 Python 标准。开发者指南中包含有关 Ansible 单元测试的基本文档 单元测试。本文档应该是新 Ansible 模块作者可以阅读的。如果你发现它不完整或令人困惑,请打开一个错误或在 #ansible-devel 聊天频道 (使用 Matrix 在 ansible.im 或使用 IRC 在 irc.libera.chat) 中寻求帮助。

什么是单元测试?

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 模块的环境进行单元测试时,存在一些特殊情况。 下面记录了最常见的特殊情况,可以通过查看现有单元测试的源代码或在 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 服务的模块)。

运行主函数

如果你确实想要运行模块的实际主函数,你必须导入模块,像上面那样设置参数,设置适当的退出异常,然后运行模块

# 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 库,许多断言函数将缺失,但会像成功一样返回。 这意味着测试用例应该非常小心 *不* 使用 Python 3 文档中标记为 _new_ 的函数,因为即使代码在运行旧版本的 Python 时出现故障,测试也可能始终成功。

一种有益的开发方法是确保所有测试都在 Python 2.6 下运行,并且测试用例中的每个断言都已通过破坏 Ansible 中的代码以触发该故障来检查是否有效。

警告

维护 Python 2.6 兼容性

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

另请参见

单元测试

Ansible 单元测试文档

测试 Ansible 和集合

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

开发模块

开始开发模块

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

Python 3 中单元测试框架的文档

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

最早支持的单元测试框架的文档 - 来自 Python 2.6

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

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

开发邮件列表

开发主题的邮件列表

测试你的代码(来自《Hitchhiker’s Guide to Python》!)

关于测试 Python 代码的一般建议

Bob 叔叔在 YouTube 上的许多视频

单元测试是各种软件开发理念的一部分,包括极限编程 (XP)、整洁代码。 Bob 叔叔谈论了如何从中学到好处

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

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

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

一篇针对如何维护单元测试价值的回应