Статьи

Динамическое создание тестовых случаев Python

Тестирование имеет решающее значение. Хотя существует много разных видов и уровней тестирования, хорошая библиотека поддерживает только для модульных тестов (пакет юнит-тестов Python и его моральные эквиваленты в других языках). Тем не менее, модульное тестирование не охватывает все виды тестирования, которые мы можем захотеть сделать, например, все виды целых программных тестов и интеграционных тестов. Именно здесь мы обычно получаем собственный скрипт «runner».

Написав свою долю таких пользовательских тестов, я недавно обратился к очень удобному подходу, которым хочу поделиться здесь. Короче говоря, я использую юнит-тесты Python в сочетании с динамической природой языка для запуска всех видов тестов.

Давайте предположим, что мои тесты представляют собой какие-то файлы данных, которые должны быть переданы в программу. Вывод программы сравнивается с некоторым файлом «ожидаемых результатов» или, возможно, каким-то образом кодируется в самом файле данных. Детали этого не имеют значения, но опытные программисты обычно очень часто сталкиваются с такими тестовыми установками. Обычно это происходит, когда тестируемая программа представляет собой механизм преобразования данных (компилятор, шифратор, кодировщик, компрессор, транслятор и т. Д.).

Итак, вы пишете «тестовый бегун». Скрипт, который просматривает какое-то дерево каталогов, находит там все «тестовые файлы», запускает каждый через преобразование, сравнивает, сообщает и т. Д. Я уверен, что все эти тестовые прогоны имеют общую инфраструктуру — я знаю, что мои ,

Почему бы не использовать существующие возможности Python для «тестирования бегуна», чтобы сделать то же самое?

Вот очень короткий фрагмент кода, который может служить шаблоном для достижения этой цели:

import unittest

class TestsContainer(unittest.TestCase):
    longMessage = True

def make_test_function(description, a, b):
    def test(self):
        self.assertEqual(a, b, description)
    return test

if __name__ == '__main__':
    testsmap = {
        'foo': [1, 1],
        'bar': [1, 2],
        'baz': [5, 5]}

    for name, params in testsmap.iteritems():
        test_func = make_test_function(name, params[0], params[1])
        setattr(TestsContainer, 'test_{0}'.format(name), test_func)

    unittest.main()

Что здесь происходит:

  1. Тестовый класс TestsContainer будет содержать динамически генерируемые тестовые методы.
  2. make_test_function создает тестовую функцию (точнее, метод), которая сравнивает ее входные данные. Это всего лишь тривиальный шаблон — он может делать что угодно, или таких «создателей» может быть несколько.
  3. Цикл создает тестовые функции из описания данных в testmap и присоединяет их к тестовому классу.

Имейте в виду, что это очень простой пример. Я надеюсь, что очевидно, что testmap действительно может быть тестовыми файлами, найденными на диске, или чем-то еще. Основная идея здесь — создание метода динамического тестирования.

Итак, что мы можем извлечь из этого, спросите вы? Достаточно много. unittest мощен — вооружен до зубов полезными инструментами для тестирования. Теперь вы можете вызывать тесты из командной строки, контролировать многословность, управлять поведением «быстрых сбоев», легко фильтровать, какие тесты запускать, а какие не запускать, использовать всевозможные методы утверждений для удобства чтения и составления отчетов (зачем писать собственное сравнение со смарт-списками) утверждения?). Более того, вы можете использовать любое количество сторонних инструментов для работы с результатами тестирования юнитов — отчеты HTML / XML, ведение журнала, автоматическая интеграция CI и так далее. Возможности безграничны.

Одна интересная вариация на эту тему — направить динамическое поколение на другой «слой» тестирования. unittest определяет любое количество «тестовых случаев» (классов), каждое из которых содержит любое количество «тестов» (методов). В приведенном выше коде мы генерируем несколько тестов в одном тестовом примере. Вот пример вызова, чтобы увидеть это в действии:

$ python dynamic_test_methods.py -v
test_bar (__main__.TestsContainer) ... FAIL
test_baz (__main__.TestsContainer) ... ok
test_foo (__main__.TestsContainer) ... ok

======================================================================
FAIL: test_bar (__main__.TestsContainer)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "dynamic_test_methods.py", line 8, in test
    self.assertEqual(a, b, description)
AssertionError: 1 != 2 : bar

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1)

Как вы можете видеть, все пары данных в тестовой карте преобразуются в однозначно названные методы тестирования в одном тестовом примере TestsContainer.

Очень легко, мы можем сократить это по-другому, сгенерировав целый контрольный пример для каждого элемента данных:

import unittest

class DynamicClassBase(unittest.TestCase):
    longMessage = True

def make_test_function(description, a, b):
    def test(self):
        self.assertEqual(a, b, description)
    return test

if __name__ == '__main__':
    testsmap = {
        'foo': [1, 1],
        'bar': [1, 2],
        'baz': [5, 5]}

    for name, params in testsmap.iteritems():
        test_func = make_test_function(name, params[0], params[1])
        klassname = 'Test_{0}'.format(name)
        globals()[klassname] = type(klassname,
                                   (DynamicClassBase,),
                                   {'test_gen_{0}'.format(name): test_func})

    unittest.main()

Большая часть кода здесь остается прежней. Разница заключается в строках внутри цикла: теперь вместо динамического создания методов тестирования и их присоединения к тестовому сценарию мы создаем целые тестовые наборы — по одному на элемент данных с одним методом тестирования. Все тестовые случаи происходят от DynamicClassBase и, следовательно, от unittest.TestCase, поэтому они будут автоматически обнаружены механизмом unittest. Теперь выполнение будет выглядеть так:

$ python dynamic_test_classes.py -v
test_gen_bar (__main__.Test_bar) ... FAIL
test_gen_baz (__main__.Test_baz) ... ok
test_gen_foo (__main__.Test_foo) ... ok

======================================================================
FAIL: test_gen_bar (__main__.Test_bar)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "dynamic_test_classes.py", line 8, in test
    self.assertEqual(a, b, description)
AssertionError: 1 != 2 : bar

----------------------------------------------------------------------
Ran 3 tests in 0.000s

FAILED (failures=1)

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

За последние пару лет я использовал эту технику во многих проектах и ​​нашел ее очень полезной; я неоднократно заменял целую сложную программу для выполнения тестов примерно 20-30 строками кода, используя эту технику, и получал доступ ко многим другим возможностям бесплатно.

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