Тестирование в Python - объектно-ориентированный и процедурный подход (исходники)

Источник: rsdnru/
Кудрявцев С. А.

Введение

Тестирование - головная боль для любого разработчика. Каждый (или почти каждый) готов согласиться с тем, что тестирование необходимо, и абсолютно у каждого имеется парочка "уважительных причин", чтобы не писать тесты. В компилируемых языках со статической типизацией (например, C++) часть работы по проверке корректности кода "берет на себя" компилятор; концентрированным выражением идеи "языка, на котором нельзя написать ошибочный код" стал язык Ада - прямо скажем, не самый популярный среди программистов. В динамических языках, таких, как Python или Perl на этапе компиляции происходит самая минимальная проверка исходного кода, поэтому возникает необходимость (на радость адептам пресловутой методологии Test Driven Development  "разработка через тестирование") тестировать буквально каждую строчку.

К счастью, создатели динамических языков программирования поняли это достаточно быстро, и стали включать в свои стандартные библиотеки средства тестирования. В языке Python используются сразу две системы тестирования: doctest и unittest; doctest - вещь достаточно специфическая, и, хотя у нее есть свои достоинства и свои сторонники, ее рассмотрение выходит за рамки настоящей статьи. Инфраструктура unittest (ее исходный код находится в модуле unittest стандартной библиотеки Python) гораздо привычнее, особенно для тех, кто уже имел дело с JUnit, DUnit и прочими xUnit.

unittest и funtest

Рассмотрим небольшой пример использования unittest. Те, кому не по душе объектно-ориентированное программирование - а такие еще встречаются, причем не только меж закостенелых "динозавров" времен Алгола, и среди них, кстати, попадаются неплохие специалисты - могут его пропустить.

import unittest
from unittest import TestCase, main

import my_math
from my_math import factorial

class FactorialTestCase(TestCase):
    def test_fact_0(self):
        """test factorial(0)"""
        self.assertEquals(factorial(0), 1)
    def test_fact_1(self):
        """test factorial(1)"""
        self.assertEquals(factorial(1), 1)
    def test_fact_2(self):
        """test factorial(2)"""
        self.assertEquals(factorial(2), 2)
    def test_fact_3(self):
        """test factorial(3)"""
        self.assertEquals(factorial(3), 6)

if __name__ == '__main__':
    main()

В этом примере мы тестируем функцию factorial, которую импортируем из некоторого модуля my_math. Для тестирования мы импортируем из модуля unittest класс TestCase, создаем производный от него класс FactorialTestCase, определяем в классе FactorialTestCase несколько тестов. Каждый тест - это метод с единственным аргументом self, имя метода обязательно должно начинаться с префикса test (вполне логичное соглашение об именованиях). Далее мы вызываем определенную в модуле unittest функцию main, которая находит созданный нами класс, в нем находит все тесты (т.е. все методы, имена которых начинаются с приставки "test"), создает столько экземпляров класса, сколько в нем определено таких методов, и для каждого экземпляра класса вызывает один и только один из этих методов. В этом примере функция main создает четыре экземпляра класса FactorialTestCase. Для одного будет вызван метод test_fact_0, для другого - test_fact_1 и т.д. Если в ходе прохождения какого-либо теста возникает исключение, то такой тест считается неуспешным. При выполнении функции main все исключения перехватываются, неуспешные тесты регистрируются, а по окончании тестирования на экран выводится отчет, в котором указывается, сколько тестов было проведено, и какие из них оказались неуспешными. Метод assertEquals унаследован классом FactorialTestCase от своего "родителя" - класса TestCase, и всего-навсего проверяет, равны ли между собой две величины (в данном случае - результат, возвращаемый функцией factorial, и ожидаемое нами значение результата). Если они не равны, метод assertEquals генерирует исключение AssertionError; как уже говорилось, при выполнении функции main, все исключения возникающие в ходе тестирования перехватываются, а по окончании тестирования, выдается отчет о возникших проблемах.

Необходимо еще сказать пару слов о комментариях (точнее говоря, строках документации, в англоязычной литературе по Python называемых docstrings) - это тот самый текст в забавных "тройных" кавычках, который находится между заголовком метода и его телом. В unittest этот текст выводится в отчете об ошибках для того, чтобы сообщить программисту, какие именно тесты были неуспешными. Для теста, у которого строка документации отсутствует, будет выведено имя теста, что уже не так наглядно, поэтому лучше не лениться писать эти самые docstring. Кстати, по этой же причине не стоит "забивать" в один тест слишком много разных проверок - с тем же успехом можно вообще отказаться от использования unittest и писать все тесты в виде одного скрипта "в стиле Акына", без разбиения на структурные единицы. При этом мы, конечно, получим информацию о том, что при тестировании что-то пошло не так, но вот найти ответ на вопрос, где именно произошла ошибка, будет уже намного труднее.

Дополнительно unittest предоставляет возможность реализовать метод, "подготавливающий" систему к каждому новому тесту (метод setUp), и метод, выполняющий освобождение ресурсов (например, удаление временных файлов) после каждого теста, независимо от того, успешен тест или нет (метод tearDown).

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

Вот как будет выглядеть предыдущий пример при использовании предлагаемого процедурно-ориентированного модуля funtest:

import funtest
from funtest import add_OK_test, do_tests

import my_math
from my_math import factorial

def main()
    add_OK_test(factorial, 1, 0)
    add_OK_test(factorial, 1, 1)
    add_OK_test(factorial, 2, 2)
    add_OK_test(factorial, 6, 3)
    do_tests()

if __name__ == '__main__':
    main()

Выглядит такой код несколько проще, чем предыдущий, хотя, конечно, это было бы заметнее, если бы перед нами был файл, содержащий несколько сотен тестов. Впрочем, простота - понятие относительное, и кое-кто из адептов объектно-ориентированного программирования, глядя на этот код, пожмет плечами и скажет: "Очередной велосипед". Ну и ладно ;-)

Мы импортируем из модуля funtest функции add_OK_test и do_tests (там есть и другие полезные функции, они будут описаны немного дальше). Сначала мы при помощи функции add_OK_test добавляем тесты в некоторый набор тестов (набор тестов - о, ужас! - реализован, как статическая переменная в модуле funtest; некрасиво, зато соответствует известной идиоме "KISS - Keep It Simple, Stupid"). Затем вызываем функцию do_tests, которая проводит тестирование по всем тестам, которые мы добавили.

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

Снабжать каждый тест описанием не требуется, т.к. исходный код достаточно прост, а в финальном отчете об ошибках вместо строк документации используется автоматически создаваемое описание теста. Оно содержит имя тестируемой функции (в данном примере - factorial), имя модуля, в котором эта функция определена, набор входных данных, на котором была обнаружена ошибка, ожидаемое и реальное значения результата.

Не очень красивым выглядит формат вызова функции add_OK_test: сначала тестируемая функция, затем (внимание!) ожидаемый результат, и только после - передаваемые функции аргументы, через запятую. Любой нормальный человек предпочел бы, чтобы ожидаемый результат находился в конце списка аргументов. Увы, но при вызове функции add_OK_test, как, впрочем, и любой другой, мы должны сначала передать обязательные аргументы, и только затем - необязательные (если заглянуть в исходный код модуля funtest, то можно увидеть, что у функции add_OK_test произвольное число аргументов - механизм, аналогичный недоброй памяти параметру "..." в языке C). Конечно, мы могли бы передавать все аргументы тестируемой функции в виде одной переменной (списка или кортежа), но тогда было бы затруднительно использовать "именованную" передачу аргументов в стиле Ада и VHDL (механизм довольно экзотический, но иногда весьма полезный):

add_OK_test(some_function, 1, A=0, B=1, C=3)
add_OK_test(some_function, 2, A=0, B=1, C=4)
add_OK_test(some_function, 0, A=1, B=3, C=5)
add_OK_test(some_function, 6, A=3, B=2, C=1)

(В данном примере мы считаем, что функция some_function принимает три аргумента с именами A, B и C).

Можно ли добавить несколько тестов "за один раз"? Да, можно, но при этом нам придется довольствоваться позиционной передачей аргументов в стиле C/C++:

add_OK_suite(some_function,
             (1, 0, 1, 3),
             (2, 0, 1, 4),
             (0, 1, 3, 5),
             (6, 3, 2, 1))

Функция add_OK_suite (она тоже находится в модуле funtest) добавляет в набор тестов сразу несколько тестов для одной тестируемой функции. Первый аргумент функции add_OK_suite - тестируемая функция, второй и последующие аргументы - данные для тестов. Данные для каждого теста мы передаем в виде списка, который начинается с ожидаемого результата, после которого идут аргументы (в порядке их следования в списке аргументов в объявлении тестируемой функции).

Функции add_OK_test и add_OK_suite сравнивают результат, возвращаемый тестируемой функцией, с ожидаемым значением "в лоб", при помощи оператора "==", и, если оператор "==" вернет False - тест считается неуспешным. В то же время, в unittest реализованы методы, если можно их так назвать, сравнения с допуском, когда задаются пределы допустимого отклонения результата от ожидаемого значения. Честно говоря, мне почти не приходилось использовать этот механизм, и мне не кажется, что он очень уж полезен. Да и допустимое отклонение можно выразить по-разному: задать число значащих цифр (такой метод применен в unittest), абсолютную погрешность, относительную погрешность, и т.д. Подобные возможности в модуле funtest не реализованы, но, если понадобится, эта проблема решается довольно просто, и даже без модификации исходного кода модуля, с использованием хорошо известного паттерна Стратегия.

Тесты должны проверять не только возвращаемое функцией значение. Иногда вместо того, чтобы вернуть результат, функция генерирует исключение (например, факториал определен только на множестве натуральных чисел, и при попытке вычислить факториал вещественного, отрицательного или комплексного числа будет вполне разумно со стороны функции сгенерировать исключение ValueError или TypeError). А как проверить, действительно ли возникло исключение? Для этой цели в модуле funtest определены функции add_E_test и add_E_suit.

Функция add_E_test аналогична функции add_OK_test, но в отличие от последней, она добавляет в список тестов тест, который перехватывает исключения. В таком тесте предполагается что, если вызвать тестируемую функцию с заданным набором аргументов, то тестируемая функция сгенерирует исключение заданного типа. Если же исключение не возникло, или тип исключения не тот, который ожидался - тест считается неуспешным. Список аргументов функции add_E_test практически такой же, как список аргументов функции add_OK_test, только вместо ожидаемого значения результата мы указываем класс ожидаемого исключения:

add_E_test(factorial, ValueError, -1)
add_E_test(factorial, TypeError, 1.1)
add_E_test(factorial, TypeError, 1.1+1j*1.0)

(В данном примере мы считаем, что попытка вычислить factorial(-1) вызовет исключение класса ValueError, а factorial(1.1) - исключение класса TypeError).

Естественно, функция add_E_test, как и функция add_OK_test, поддерживает как позиционную передачу аргументов тестируемой функции в стиле C/C++, так и передачу "именованных" аргументов тестируемой функции в стиле Ада.

Функция add_E_suite, в свою очередь - прямой аналог функции add_OK_suite, только, опять-таки, вместо ожидаемого результата в списке параметров мы указываем класс ожидаемого исключения:

add_E_suite(factorial, 
            (ValueError, -1),
            (TypeError, 1.1),
            (TypeError, 1.1+1j*1.0))

Исходные коды: модуль funtest, тесты для модуля funtest и пример использования

Исходный код модуля открыт и общедоступен. Никакого инсталлятора не прилагается - инсталляция вещь хорошая, но, "всякая хорошая вещь..." (см. выше). Чтобы провести тестирование модуля или посмотреть пример, достаточно просто скопировать файлы с исходным кодом во временный рабочий каталог. Для "опытной эксплуатации" модуля в составе какого-либо проекта следует перенести модуль funtest в каталог с исходными кодами этого проекта (переносить тесты модуля или пример использования необязательно). Ну и наконец, чтобы установить модуль "по-взрослому", в виде библиотечного, нужно поместить его туда, где лежат прочие библиотечные модули, и где его сможет найти любая программа на Python. Для ОС Windows, как правило, пользовательская библиотека располагается в каталоге, в котором установлен Python, подкаталог Lib\site-packages. Для Linux это обычно каталог /usr/lib/python/site-packages.

ПРЕДУПРЕЖДЕНИЕ

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

Исходный код оформлен в стиле, принятом в сообществе Python, однако кое-где имеются отступления от этого стиля. Во-первых, имена глобальных переменных набраны в верхнем регистре с разделением символом "подчеркивание" ("_") - так, мне кажется, все же нагляднее. Во-вторых, в классах, порожденных от класса TestCase, имена методов оформлены в стиле Кемел - просто потому, что такой стиль принят в самом модуле unittest. Исходный код обильно снабжен комментариями, все комментарии на английском языке, и я подозреваю, что они содержат некоторое количество ошибок (не смысловых, а синтаксических, естественно).

К модулю funtest прилагаются тесты (модули funtest_tests и funtest_tests_aux), а также дополнительный пример использования модуля funtest - модуль prod.

Для тестирования модуля funtest следует запустить из командной строки модуль funtest_tests (это основной модуль с тестами). Отмечу, что традиционная для Unix строка с указанием пути к интерпретатору в исходных кодах отсутствует, поэтому для запуска следует явно указать интерпретатор (например: python ./funtest_tests.py). При этом вы увидите в окне консоли что-то вроде следующего:

\/\/\/\/\/\/\/\/\/\/\/call do_tests()\/\/\/\/\/\/\/\/\/\/
.........................
---------------------------------------------------------
Ran 21 tests in 0.000s

OK
<><><>do_tests() finished<><><>

Демонстрационный модуль prod содержит функцию prod, а также набор тестов для нее, использующий, естественно, модуль funtest. Функция prod перемножает несколько чисел, причем количество аргументов у нее переменное; ее можно вызывать с двумя и более числовыми аргументами, или с одним аргументом - последовательностью чисел (переменной типа "кортеж" или "список"), в этом случае функция вернет произведение чисел, содержащихся в последовательности.

Для тестирования модуля prod следует запустить его из командной строки, при этом появится стандартное сообщение unittest о числе проведенных тестов и несколько сообщений об ошибках - так и должно быть, в модуль преднамеренно введены несколько заведомо неуспешных тестов, в качестве примера.

Детали реализации

Ничего революционного модуль funtest, разумеется, не предлагает. Все, что в нем есть, основано на unittest. Для каждого теста создается экземпляр класса - потомка TestCase. Хранятся они все в контейнере - глобальной переменной, экземпляре класса TestSuite, определенного в модуле unittest. Функция do_tests проводит тестирование, используя методы, определенные в классе TestSuite. В общих чертах, все практически так же, как и при использовании unittest, за исключением того, что модуль funtest имеет процедурно-ориентированный интерфейс и не проводит автоматический поиск тестов.

В модуле funtest определен класс - потомок TestCase, который также называется TestCase (конфликта не происходит благодаря тому, что каждый модуль в Python имеет собственное пространство имен; чтобы избежать путаницы, мы в дальнейшем будем использовать его полное имя, funtest.TestCase). В этом классе переопределен унаследованный от предка метод shortDescription (метод shortDescription возвращает, как следует из его названия, строку с описанием теста; он используется при создании отчета о результатах тестирования), и определен новый метод - runCall. Метод runCall вызывает тестируемую функцию, передавая ей необходимые аргументы (набор аргументов и ссылка на тестируемую функцию передаются конструктору при создании экземпляра класса и сохраняются в частных атрибутах экземпляра класса). Результат, возвращаемый методом runCall - это результат, возвращаемый тестируемой функцией. Отмечу, что метод runCall не пытается перехватывать исключения, которые могут возникнуть при выполнении тестируемой функции.

Также определены два потомка класса funtest.TestCase: OKTestCase и ExcTestCase. Экземпляры класса OKTestCase создаются функциями add_OK_test и add_OK_suite, а экземпляры класса ExcTestCase - функциями add_E_test и add_E_suite. В обоих классах определен метод runTest - метод, вызываемый при проведении теста.

В классе OKTestCase метод runTest вызывает унаследованный от предка метод runCall, и сравнивает результат его выполнения с ожидаемым значением (которое передается конструктору при создании экземпляра класса и сохраняется в частном атрибуте экземпляра класса). Если они не равны, генерируется исключение класса AssertionError.

В классе ExcTestCase метод runTest также вызывает унаследованный от предка метод runCall, и ожидает, что вызов runCall приведет к возникновению исключения некоторого класса (ожидаемый класс исключения передается конструктору при создании экземпляра класса и сохраняется в частном атрибуте экземпляра класса). Если возникнет исключение не того класса, что ожидается, то оно будет перехвачено не методом runTest, а вызывающим метод runTest кодом, и информация о нем попадет в финальный отчет о тестировании. Если исключение не возникнет вовсе, то метод runTest сгенерирует исключение класса AssertionError, которое сообщит о неуспехе теста. Отмечу, что для перехвата исключения, генерируемого тестируемой функцией, используется метод failUnlessRaises унаследованный классом ExcTestCase от своего "дедушки" - класса TestCase, определенного в модуле unittest.


Страница сайта http://test.interface.ru
Оригинал находится по адресу http://test.interface.ru/home.asp?artId=21511