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() 则不是。

使用 Mock

Mock 对象(来自 https://docs.pythonlang.cn/3/library/unittest.mock.html)在为特殊/困难的情况构建单元测试时非常有用,但它们也可能导致复杂且令人困惑的编码情况。模拟的一个很好的用途是模拟 API。至于“six”,“mock”python 包与 Ansible 捆绑在一起(使用 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 调用模拟来返回数据,我们可以确保消息中只存在 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 库,则许多断言函数会丢失,但会返回为成功。这意味着测试用例应格外小心,不要 使用 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 代码的一般建议

Uncle Bob 在 YouTube 上的许多视频

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

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

一篇警告单元测试成本的文章

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

一篇指出如何保持单元测试价值的回应