Spec-Zone.ru › Ansible 2.4

Тестирование модулей Ansible

  • Введение
  • Что такое модульные тесты?
  • Зачем использовать модульные тесты?
  • Когда использовать модульные тесты
    • Быстрая обратная связь
    • Обеспечение правильного использования внешних интерфейсов
    • Проведение специфических тестов на дизайн
  • Как протестировать модули Ansible
    • Именование модульных тестов
    • Использование эмуляторов
    • Обеспечение видимости случаев сбоя с помощью эмуляторов
    • Эмуляция фактического модуля
    • Определение API с тестовыми случаями
      • Определение модуля по спецификации API
      • Определение модуля для работы с несколькими версиями API
  • Специальные случаи Ansible для модульного тестирования
    • Обработка аргументов модуля
    • Передача аргументов
    • Правильная обработка завершения
    • Запуск основной функции
    • Обработка вызовов внешних исполняемых файлов
    • Полный пример
    • Реструктуризация модулей для включения тестирования настройки модуля и других процессов
  • Особенности для поддержания совместимости с Python 2

Введение

Этот документ объясняет, зачем, как и когда следует использовать модульные тесты для модулей Ansible. Документ не относится к другим частям Ansible, для которых рекомендации обычно ближе к стандартам Python. Основная документация по модульным тестам Ansible есть в руководстве разработчика Модульные тесты. Этот документ должен быть понятен новому автору модулей Ansible. Если вы считаете его неполным или запутанным, пожалуйста, откройте баг-репорт или обратитесь за помощью на Ansible IRC.

Что такое модульные тесты?

Ansible содержит набор модульных тестов в каталоге test/unit. Эти тесты в основном покрывают внутреннюю логику, но также могут охватывать модули Ansible. Структура модульных тестов соответствует структуре кодовой базы, поэтому тесты, расположенные в каталоге test/unit/modules/, организованы по группам модулей.

Интеграционные тесты могут быть использованы для большинства модулей, но есть ситуации, когда случаи не могут быть проверены с помощью интеграционных тестов. Это означает, что модульные тесты Ansible могут выходить за рамки тестирования только минимальных единиц и в некоторых случаях будут включать некоторый уровень функционального тестирования.

Зачем использовать модульные тесты?

Модульные тесты Ansible имеют преимущества и недостатки. Важно понимать эти аспекты. Преимущества включают:

  • Большинство модульных тестов намного быстрее, чем большинство интеграционных тестов Ansible. Полный набор модульных тестов можно регулярно запускать разработчику на его локальной системе.
  • Модульные тесты могут запускаться разработчиками, у которых нет доступа к системе, для которой предназначен модуль, что позволяет проверить, не нарушили ли изменения основных функций ожидания модуля.
  • Модульные тесты могут легко заменить системные функции, позволяя тестировать программное обеспечение, которое было бы непрактично. Например, функция sleep() может быть заменена, и мы проверим, что вызов десять минутного сна был сделан, не дожидаясь фактически десяти минут.
  • Модульные тесты выполняются на разных версиях Python. Это позволяет гарантировать, что код ведет себя одинаково на разных версиях Python.

Также существуют потенциальные недостатки модульных тестов. Модульные тесты обычно не тестируют непосредственно полезные функции программного обеспечения, а вместо этого тестируют только внутреннюю реализацию.

  • Модульные тесты, которые тестируют внутренние, невидимые функции программного обеспечения, могут затруднить рефакторинг, если эти внутренние функции необходимо изменить (см. также именование в разделе «Как» ниже).
  • Даже если внутренняя функция работает правильно, возможно, возникнет проблема между внутренним кодом, протестированным, и фактическим результатом, предоставляемым пользователю.

Обычно интеграционные тесты Ansible (которые написаны на Ansible YAML) обеспечивают лучшее тестирование большинства функций модулей. Если эти тесты уже тестируют функцию и работают хорошо, может быть мало смысла предоставлять модульный тест, охватывающий ту же область.

Когда использовать модульные тесты

Есть ряд ситуаций, где модульные тесты являются лучшим выбором, чем интеграционные тесты. Например, тестирование вещей, которые невозможно, медленно или очень сложно проверить с помощью интеграционных тестов, таких как:

  • Инициирование редких/странных/случайных ситуаций, которые невозможно воспроизвести, например, специфические сетевые сбои и исключения
  • Расширенное тестирование медленных API конфигурации
  • Ситуации, когда интеграционные тесты нельзя запустить в рамках основного непрерывного интегрирования Ansible, выполняемого в Shippable.

Быстрая обратная связь

Пример:
Один шаг в тестах rds_instance может занимать до 20 минут (время создания экземпляра RDS в Amazon). Весь запуск тестов может длиться более часа. Все 16 модульных тестов завершают выполнение менее чем за 2 секунды.

Экономия времени, которую дает возможность запускать код в модульном тесте, делает создание модульного теста целесообразным при исправлении ошибок в модуле, даже если эти тесты не всегда выявляют проблемы позже. В качестве базовой цели каждый модуль должен иметь по крайней мере один модульный тест, который предоставит быструю обратную связь в простых случаях, не дожидаясь завершения интеграционных тестов.

Обеспечение правильного использования внешних интерфейсов

Модульные тесты могут проверять способ выполнения внешних служб, чтобы убедиться, что они соответствуют спецификациям или являются максимально эффективными, *даже если конечный результат не изменится*.

Пример:
Утилиты управления пакетами часто значительно эффективнее при установке нескольких пакетов одновременно, чем установка каждого пакета по отдельности. Конечный результат остается одинаковым: все пакеты установлены, поэтому эффективность сложно проверить с помощью интеграционных тестов. Предоставив эмуляцию утилиты управления пакетами и проверив, что она вызывается один раз, мы можем создать ценный тест для эффективности модуля.

Еще одно связанное применение — ситуации, когда API имеет версии, которые ведут себя по-разному. Разработчик, работающий над новой версией, может изменить модуль для работы с новой версией API и непреднамеренно сломать старую версию. Тестовый случай, который проверяет правильный вызов для старой версии, может помочь избежать проблемы. В этой ситуации очень важно включать номера версий в имя тестового случая (см. Именование модульных тестов ниже).

Проведение специфических тестов на дизайн

Создав требования к определенной части кода и разработав код в соответствии с этими требованиями, модульные тесты _могут_ иногда улучшить код и помочь будущим разработчикам понять этот код.

Модульные тесты, проверяющие внутренние детали реализации кода, почти всегда приносят больше вреда, чем пользы. Проверка того, что ваши пакеты для установки хранятся в списке, замедлит и спутает будущего разработчика, которому может потребоваться изменить этот список на словарь для повышения эффективности. Эту проблему можно несколько уменьшить с помощью ясного именования тестов, чтобы будущий разработчик сразу понял, что следует удалить тестовый случай, но часто лучше просто оставить тестовый случай и проверить реальную ценную функцию кода, например, установку всех пакетов, предоставленных в качестве аргументов модулю.

Как протестировать модули Ansible

Существует ряд техник для тестирования модулей. Обратите внимание, что большинство модулей без модульных тестов структурированы таким образом, что тестирование достаточно сложно и может привести к очень сложным тестам, требующим больше усилий, чем сам код. Эффективное использование модульных тестов может привести к реструктуризации вашего кода. Это часто хорошо и ведет к лучшему коду в целом. Хорошая реструктуризация может сделать ваш код более понятным и легким для понимания.

Именование модульных тестов

Модульные тесты должны иметь логичные имена. Если разработчик, работающий над тестируемым модулем, сломает тестовый случай, легко должно быть понять, что охватывает модульный тест из его имени. Если модульный тест предназначен для проверки совместимости с конкретной версией программного обеспечения или API, укажите версию в имени модульного теста.

В качестве примера test_v2_state_present_should_call_create_server_with_name() — хорошее имя, test_create_server() — нет.

Использование эмуляторов

Объекты-эмуляторы (https://docs.python.org/3/library/unittest.mock.html) могут быть очень полезны при создании модульных тестов для особых/сложных случаев, но они также могут привести к сложным и запутанным ситуациям программирования. Одно хорошее применение эмуляторов — моделирование API. Что касается «six», пакет «mock» включён в Ansible (используйте «import ansible.compat.tests.mock»). См., например

Обеспечение видимости случаев сбоя с помощью эмуляторов

Функции, такие как module.fail_json(), обычно ожидаются для завершения выполнения. При запуске с объектом-эмулятором модуля это не происходит, поскольку эмулятор всегда возвращает другой эмулятор из вызова функции. Вы можете настроить эмулятор на генерацию исключения, как показано выше, или можно утверждать, что эти функции не были вызваны в каждом тесте. Например:

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

Это относится не только к вызову основного модуля, но и практически к любой другой функции в модуле, которая получает объект модуля.

Эмуляция фактического модуля

Настройка реального модуля довольно сложна (см. Передача аргументов ниже) и часто не требуется для большинства функций, использующих модуль. Вместо этого вы можете использовать эмуляцию (mock object) как модуль и создавать любые необходимые модульные атрибуты для тестируемой функции. Если вы это делаете, имейте в виду, что функции выхода модуля требуют специальной обработки, как указано выше, либо путем выброса исключения, либо путем обеспечения того, что они не были вызваны. Например:

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:
    return = my_module.test_this_function(module, argument)
module.fail_json.assert_not_called()
assert return["changed"] == True

Определение API с тестовыми случаями

Взаимодействие с API обычно лучше всего тестировать с помощью функциональных тестов, определённых в разделе интеграционного тестирования Ansible, которые выполняются против реального API. Есть несколько случаев, когда модульные тесты могут работать лучше.

Определение модуля на основе спецификации API

Этот случай особенно важен для модулей, взаимодействующих с веб-сервисами, которые предоставляют API, используемый Ansible, но которые находятся вне контроля пользователя.

Написав пользовательскую эмуляцию вызовов, возвращающих данные из 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 IRC или на списках рассылки.

Обработка аргументов модуля

Есть две проблемы с запуском основной функции модуля:

  • Поскольку модуль должен принимать аргументы в STDIN , немного сложно правильно настроить аргументы, чтобы модуль получил их как параметры.
  • Все модули должны завершаться вызовом либо module.fail_json , либо module.exit_json , но в тестовой среде они не будут работать корректно.

Передача аргументов

Чтобы правильно передать аргументы модулю, используйте функцию, которая сохраняет параметры в специальной строковой переменной. Создание модуля и обработка аргументов обрабатываются объектом AnsibleModule в базовой части утилиты. Обычно он принимает входные данные в STDIN , что неудобно для модульного тестирования. Когда специальная переменная устанавливается, она будет обрабатываться так, как будто входные данные были получены в STDIN модуля:

import json
from ansible.module_utils._text import to_bytes

def set_module_args(args):
    args = json.dumps({'ANSIBLE_MODULE_ARGS': args})
    basic._ANSIBLE_ARGS = to_bytes(args)

simply call that function before setting up your module

    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_modulle 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 и test/units/modules/packaging/os/rhn_utils.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:
            self.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 ansible.compat.tests import unittest
from ansible.compat.tests.mock import patch
from ansible.module_utils import basic
from ansible.module_utils._text 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({})
            self.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:
         self.module.setup_json

См. также test/units/module_utils/aws/test_rds.py

Обратите внимание, что словарь argument_spec виден в переменной модуля. Это имеет преимущества, как в отношении явного тестирования аргументов, так и в отношении простого создания объектов модуля для тестирования.

Та же техника реструктуризации может быть полезна для тестирования другой функциональности, например, части модуля, которая запрашивает объект, который конфигурирует модуль.

Ловушки при поддержании совместимости с Python 2

Если вы используете библиотеку mock из стандартной библиотеки Python 2.6, несколько функций assert отсутствуют, но вернутся как если бы прошли успешно. Это означает, что тестовые случаи должны проявлять особую осторожность, чтобы не использовать функции, помеченные как _new_ в документации Python 3, поскольку тесты, вероятно, всегда будут успешными, даже если код сломан при запуске на более старых версиях Python.

Полезным подходом к развитию в этом случае должно быть обеспечение того, что все тесты были выполнены в Python 2.6, и что каждый assert в тестовых случаях был проверен на работоспособность, сломав код Ansible для вызова этого сбоя.

См. также

Модульные тесты
Документация по модульным тестам Ansible
Тестирование Ansible
Запуск тестов локально, включая сбор и отчеты о покрытии кода
Разработка модулей
Как разрабатывать модули
Документация Python 3 — 26.4. unittest — Фреймворк для модульного тестирования
Документация фреймворка unittest в Python 3
Документация Python 2 — 25.3. unittest — Фреймворк для модульного тестирования
Документация самого раннего поддерживаемого фреймворка unittest — из Python 2.6
pytest: помогает вам писать лучшие программы
Документация фреймворка pytest — фреймворка, фактически используемого для запуска модульных тестов Ansible
Список рассылки по вопросам разработки
Список рассылки по темам разработки
Тестирование вашего кода (из "Путеводителя по Python!")
Общие советы по тестированию кода Python
Множество видео Uncle Bob на YouTube
Модульное тестирование — часть различных философий разработки программного обеспечения, включая Extreme Programming (XP), Clean Coding. Uncle Bob рассказывает о том, как извлечь выгоду из этого
“Why Most Unit Testing is Waste” http://rbcs-us.com/documents/Why-Most-Unit-Testing-is-Waste.pdf
Статья, предупреждающая об издержках модульного тестирования
‘A Response to “Why Most Unit Testing is Waste”’ https://henrikwarne.com/2014/09/04/a-response-to-why-most-unit-testing-is-waste/
Ответ, указывающий на то, как сохранить ценность модульных тестов

© 2012–2018 Michael DeHaan
© 2018–2019 Red Hat, Inc.
Licensed under the GNU General Public License version 3.
https://docs.ansible.com/ansible/2.4/dev_guide/testing_units_modules.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API