Я впервые начал заниматься разработкой через тестирование ещё когда программировал на PHP. Тогда в нашем арсенале разработчика использовался отличный фреймворк SimpleTest от Маркуса Бейкера. Мне этот инструмент очень нравился. С тех пор я имел дело с фреймворками для тестирования на C, Perl, Java и Python, при этом SimpleTest до сих пор остаётся моим главным фаворитом независимо от языка.
Но со временем я огрокался заинтересовался автоматизированным тестированием — стал читать об этом книги, статьи в блогах, экспериментировать с новыми паттернами тестирования и даже набил себе татуировки xUnit — временами меня стала охватывать фрустрация. Часто мне требовалось подобрать конкретный тест для фреймворка и языка, но либо фреймворк, либо язык, либо они оба оказывались недостаточно мощными, и я не мог чётко выразить на них мою идею.
Только когда я стал много программировать на Python, меня вдруг озарило, почему так происходит. В большинстве фреймворков xUnit, в особенности тех, где предоставляются хорошие мок-объекты, более чем адекватно поддерживаются любые паттерны тестирования, которые я только могу придумать. Фреёмворк SimpleTest определённо из их числа. Проблемы, с которыми я сталкивался, происходят из самого языка.
Сейчас я не хочу жаловаться на PHP. Может быть, только самую малость. Ладно, соглашусь. Я не выношу PHP. Но, в самом деле, это солидный и практичный язык для разработки веб-приложений. Кроме того, это высокоуровневый язык с динамической типизацией, поэтому многие тесты на нём сравнительно легко писать и, что не менее важно — параметризовать. Определённо проще, чем на таких языках как C или даже Java.
При этом должен признать, что и Python несовершенен, как бы ни было сложно признавать это человеку вроде меня, который в настоящий момент с головой погружён в этот язык. О, Python! Ты такой мечтательный.
Но. В Python есть две высокоуровневые возможности, не слишком хорошо поддерживаемые в PHP: функции как объекты первого класса и замыкания. Когда я принялся много программировать на Python, постепенно стал обнаруживать, как можно, опираясь на эти фичи, создавать красивые и мощные тестовые кейсы.
Вероятно, вы уже представляете, что такое «функции как объекты первого класса». В целом это означает, что язык позволяет обращаться с функциями так же легко, как с любым банальным экземпляром класса. Хорошо поддерживает эту концепцию. Можно присваивать функции переменным, передавать функцию методу в качестве параметра или даже создавать функцию динамически и возвращать её от другой функции. В одних языках всё это поддерживается лучше, в других – хуже. В функциональных языках, таких как SchemeandHaskellработа с функциями как с объектами первого класса поставлена хорошо, но и в любом современном языке дела с этим налажены нормально. Даже в Javascript, хотите – верьте, хотите — нет. В Cэто делается очень тихо, можно сказать, что в этом языке можно бросать в разные стороны указатели функций. Можно это сделать и в Java, если вы готовы писать неканонический код.
Замыкания — не такая известная вещь. В сущности, замыкание — это функция, вычисляемая в определённом контексте, когда имеет определённый набор локальных переменных. После этого она запоминает данный контекст, даже если будет вызвана в ином окружении. Замыкание — это своего рода функция, несущая с собой немного внешней памяти, и на первый взгляд это может показаться страшноватым. Канонический пример такого рода — функция производной:
# f - это некоторая числовая функция.
# dx должна стремиться к нулю, но не равняться нулю.
def derivative(f, dx):
def df_dx(x):
return (f(x+dx) - f(x)) / dx
return df_dx
df_dx — это замыкание. Это числовая функция с одним аргументом x, который (при очень грубом приближении) даёт производное значение f(x). Причём, эта функция будет работать корректно, даже если переместить её в совершенно другой кусок кода, где f и dx являются лексически невидимыми.
(Вот хорошая статья, где подробнее рассказано о функциях как объектах первого класса и о замыканиях. Моя любимая цитата оттуда: «Ruby — удобный язык для демонстрации тех фич, которых не должно быть в Java.)
В настоящее время я работаю QA-инженером в SnapLogic. Хороший пример вещей, о которых я здесь говорю — SnapLogic Python API, который удобно сравнивать с SnapLogic PHP API. (можете просмотреть исходный код: Python, PHP) Две этих клиентских библиотеки предоставляют простой интерфейс для обращения к ресурсам SnapLogic. Они схожи как в сигнатурах методов, так и в тех алгоритмах, которые в них заложены. Я вообще реализовывал их параллельно — добавлю фичу в один, а затем переведу на другой.
(Кстати, когда приходится думать на PHP, а потом стремительно переключаться на Python — это серьёзное приключение. У меня несколько дней голова кружилась. Что хочу сказать — хорошо, что у нас всегда делается код-ревью.)
Естественно, работая таким образом, я всегда первым делом переводил тесты. Именно за этой работой я нашёл хороший способ продемонстрировать, в каком случае вам могут пригодиться функции как объекты первого класса и замыкания.
Рассмотрим метод, действующий примерно так:
def get(self, url):
...
response = urllib2.urlopen(url)
(Кстати, это реальный пример.) urllib2.urlopen — это функция, выполняющая запрос HTTP GET по URL. (Синтаксис Python: имя этой функции "urlopen", и она находится в модуле "urllib2".) Поскольку здесь мы пишем модульный тест, а не интеграционный, я в самом деле не собираюсь ничего пинговать по сети. В таком случае заставим get() принимать мок-функцию:
def get(self, url, urlopen=urllib2.urlopen):
...
response = urlopen(url)
(Это можно сделать и другими способами. Вероятно, в данном случае было бы лучше оформить urlopen как свойство класса, а не загрязнять им сигнатуру метода get(). Ревьюер моего кода плохо справился с работой.)
Этот параметр urlopen принимает функцию как объект. По умолчанию он использует «настоящую» urllib2.urlopen. Поэтому при вызове в реальном коде она выглядит так:
foo.get(url)
Но я могу передать сюда и другую, фейковую функцию, которая только якобы делает то же самое, что и версия с urllib2. Следовательно, в рамках тестового кейса я вызываю get() вот так:
foo.get(url, mock_urlopen)
где я ранее определили этот последний параметр как:
def mock_urlopen(url):
...
return some_mock_response_object
Довольно аккуратно, правда? Но проблема в том, что в PHP вы такого сделать не сможете! И во многих других языках тоже. Ладно, технически это возможно, если решение задачи — это вопрос жизни и смерти для вас и всех кого вы любите. Но получится некрасиво. На практике вы изберёте другой подход, поскольку в предложенном варианте будет попросту слишком много шероховатостей.
Кстати, в самом чистом смысле это даже не тянет на мок-функцию, поскольку здесь нет никакой бизнес-логики, и в функции не встроено тестирование какого-либо конкретного поведения. Здесь даже никаким образом не валидируется параметр uri.
У нас в компании принято активно использовать мок-функции в модульных тестах. Как правило, для этого применяется превосходная библиотека PyMock. Кстати, я по двум причинам не пользуюсь никакими мок-библиотеками при разработке библиотек для клиента на Python. Во-первых, я хотел, чтобы любой желающий мог выполнять тесты, не устанавливая никакой другой библиотеки. (Как вы знаете, поскольку вы её уже скачали!) Вторая причина в том, что мне не приходилось имитировать при помощи моков ничего особо сложного, поэтому мне не составило труда самостоятельно реализовать что-то элегантное своими силами.
Итак, что же мне здесь нужно? Я хочу, чтобы можно было передавать в вызов get() специальную функцию, которая далее использовалась бы для открывания URL. Хотелось рассчитывать на то, что в ответ я буду получать конкретное значение/значения в зависимости от того, с каким uri происходит вызов, а затем эта функция возвращала бы конкретное значение. У меня аллергия на шаблонный код, поэтому я хотел предусмотреть возможность программно генерировать такие функции для различных тестовых кейсов.
Допустим, в качестве возвращаемого значения мы получаем экземпляр класса MockResponse. (кстати, в urllib2 используется другой механизм — создаётся экземпляр класса под названием OpenerDirector. Но поверьте — всё это гораздо сложнее, чем вам хотелось бы здесь прочитать. Поэтому я просто сделал класс с откликом в виде мока).
Мок-функция, не проверяющая собственный ввод, может выглядеть так:
def mock_urlopen(uri):
return MockResponse()
Не так много здесь делается. Но мне, в частности, требовалось, чтобы она громко сигнализировала о том, если не получит того параметра uri, который должна. Поэтому можно добавить следующее:
def mock_urlopen(uri):
if "http://google.com/search?q=why+python+programmers+are+sexy" != uri:
raise Exception("Your code doesn't work, bonehead")
return MockResponse()
Также хочется каким-то образом сконфигурировать объект MockResponse. Поскольку я не идиот, я не хочу программировать N различных вариантов mock_urlopen для N тестовых кейсов. Поэтому напишу функцию, которая будет генерировать функции:
def mk_mockurlopen(expected, req):
def mockurlopen(uri):
if expected != uri:
raise Exception("Maybe programming isn't your thing.")
return req
return mockurlopen
Пока всё нормально. Теперь можно сделать нечто подобное:
for item in testcase_data:
mockurlopen = mk_mockurlopen(item['expected_uri'], item['mock_response'])
result = agent.get(item['input_uri'], urlopen=mockurlopen)
self.assertEqual(result, item['expected_result'])
Здесь нужно улучшить одну вещь. Показанные выше тесты выполняются внутри unittest, это библиотека на Python для работы с xUnit, входящая в состав стандартного дистрибутива. Вызов self.assertEqual делается в тестовом методе экземпляра unittest.TestCase. Хотя, в данный момент значение uri при вызове мока urlopen постулируется очень грубо — здесь просто выбрасывается исключение. Проверив стектрейс, можно выяснить, что именно случилось, но гораздо удобнее было бы интегрировать этот код во фреймворк для модульного тестирования, чтобы тестовая обвязка сама за нас прослеживала, какое тестовое утверждение не оправдалось и где именно.
Это довольно просто:
# Тестовый кейс
class TestOfGet(unittest.TestCase):
# Утилита, при помощи которой создаётся мок-функция urlopen, прикреплённая
# к контексту этого кейса
def mk_mockurlopen(self, expected, req):
def mockurlopen(uri):
self.assertEqual(expected, uri)
return req
return mockurlopen
# Теперь можете попробовать использовать её в одном или нескольких реальных тестах
def test_of_some_important_thing(self):
...
mockurlopen = self.mk_mockurlopen(expected_uri, mock_request_object)
...
Это замыкание. Функция создаётся в том контексте, в котором выполняется фреймворк для модульного тестирования. Вызывается она в совершенно другом контексте. Но тот контекст, в котором делается утверждение о ней, всё равно остаётся ей доступен. Фактически, именно в нём и постулируется условие.
Догадываетесь, почему так лучше, чем просто выбрасывать исключение? Модуль unittest, как и все библиотеки xUnit, содержит встроенную возможность вывода отчётов. Этот уровень абстрагирования делает его гораздо ценнее, так как позволяет взаимодействовать с различными фронтендами, интегрироваться с IDE и т.д. Создавая такое замыкание, мы можем применить специально разработанный специфичный тест где-нибудь глубоко в коде, и сразу же настроить в нём передачу отчётов — всё это без всяких усилий с нашей стороны.
Красота, правда?
Когда тест не пройден, стектрейс выглядит примерно так:
Traceback (most recent call last):
File "test/tests.py", line 512, in testmain
actual = sr.count(td['rel_uri'], _urlopen=my_mock_urlopen)
File "/home/amax/src/snaplogic/trunk/Packages/SnapLogic-py/lib/snappy/SnapLogicAgent.py", line 141, in count
req = _urlopen(full_uri)
File "test/tests.py", line 24, in mockurlopen
self.assertEqual(expected, uri)
AssertionError: 'http://foobar.com/alpha/beta?sn.count=records' != 'http://foobar.com/alpha/beta'
Обратите внимание: здесь указано, какой именно тестовый кейс не пройден (в данном случае — в строке 512 из test/tests.py), где именно в продакшен-коде что-то пошло не так (строка 141 в SnapLogicAgent.py), и по какой именно причине произошёл отказ (не хватало параметра sn.count у метода GET).
Это по-своему красиво. Напоминает о тех днях, когда мне нравилось быть инженером.
P.S. Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.
AndyLem
Хм, выглядит как переизобретение патчей и стандартных Mock объектов. Зачем?