Когда и как вводить тестирование
Писать тесты нужно. Это в последние годы столько повторяют, что ни у кого не хватит смелости отрицать вроде бы очевидное.
Существует даже известная максима: сначала тесты, а потом код.
С моей точки зрения она абсурдна: никто не утверждает, что ходить всегда нужно только с правой ноги - следует задействовать обе и почаще их менять.
Так и с тестами: код и тесты создаются одновременно.
Собственно говоря, тестирование не ограничивается одними юниттестами - но об этом как-нибудь в другой раз.
И, тем не менее, при переходе к практической стороне дела возникают сложности.
Во первых, всегда ли тесты оправданы?
Для любой более или менее большой поделки - да!
Совсем недавно я работал над одним небольшим проектом, предназначенным для публикования статей в этом блоге.
Дело в том, что blogspot.com, которым я пользуюсь, имеет довольно убогий редактор постов (веб-интерфейс не всегда хорошее решение). То есть написать короткую заметку еще как-то можно, но готовить к публикации объемную статью крайне неудобно.
Сначала проблема решалась написанием статьи в любимом текстовом редакторе в формате
Markdown, затем $ markdown_py article.rst > article.html
перегонял ее в html
,
который легко и просто вставлялся как тело поста.
Удобно? Не очень. При мелких правках нужно делать много ручных операций, активно орудуя мышью и клавиатурой. А посты, даже опубликованные, требуют исправлений - в них всегда обнаруживаются ошибки, неточности, и т.д.
В общем, нужен автоматический инструмент, умеющий:
- создать проект
- настроить его
- добавить пост
- отредактировать текст
- опубликовать
- внести правки
- увидеть список рассинхронизированных постов
- перезалить нужные посты на сервер (отредактировать записи на blogspot.com)
В голове сразу же возникают десяток команд вроде:
$ blog add article.rst
$ blog publish article.rst
$ blog ls
и так далее.
Общее направление движения известно, проблемы будут решаться по мере возникновения.
Под этим девизом я и начал работу. Тесты были абсолютно не нужны: создавался первый прототип, очень неясный и текучий. Конечно, мой опыт позволил мне избежать многих ошибок проектирования - но тем не менее в прототипе их оказалось немало.
К тому же это был этап первичного изучения предметной области. Изначально было не совсем ясно, что именно должно объединятся под понятием "пост", какой должен быть список команд, как работать с blogger.com (так называется Google сервис для blogspot.com).
Интенсивное программирование прототипа заняло около двух дней. Самым важным (и сложным) было удержать в голове нечетко сформулированную идею, позволив ей обрести твердые очертания в коде.
На этом этапе очень важна скорость: быстрее кодировать, чтобы не потерять основную мысль!
Все второстепенне детали - потом!
Оформление проекта - отложить!
Тесты - только мешают!
Кода немного, и он постоянно плывет.
Но вот прототип заработал, я получил базовый функционал, который меня более или менее устраивал. Возникло довольно много пожеланий и предложений, но основа уже есть.
Пришла пора создать нормальный проект, сделать кое-какой рефакторинг.
Вообще-то уже на этом этапе следовало вводить тесты. Но я поленился, мне хотелось как можно быстрее учесть замечания и реализовать серию очень удобных и полезных дополнений.
И тут понемногу стали возникать проблемы: изменение в одной команде могло затронуть несколько других. Проверять работоспособность ручным тестированием стало всё сложнее: количество команд достигло полутора десятков, каждая обросла флажками и дополнительными режимами.
Я попросту начал тратить на тестирование непозволительно много времени в ущерб собственно программированию. При этом всегда оставался очень неплохой шанс что-либо упустить.
Пришла пора тестов. Общий объем кода уже перевалил за 2000 строк, что не так уж и мало. Покрыть всё тестами за один присест требует недюжинной силы воли - у меня её, видимо, недостаточно.
И тем не менее решение есть, и оно очень простое.
- Начинаем работать над следующим тикетом - ведь вы пользуетесь каким-нибудь баг-трекером и храните код в системе контроля версий, верно?
- Покрываем тестами затронуную часть (можно и выйти за рамки - это не страшно).
- Реализуем требуемую функциональность.
- Переходим к следующему тикету.
Очень полезно прикрутить coverage -
он умеет генерировать очень симпатичный html
, показывающий покрытие кода тестами.
Для запуска тестов я использую nose,
к которому coverage пристёгивается стандартным плагином.
Довольно быстро уровень этого покрытия становится адекватным, а разработка - удобной и приятной.
Несколько полезных трюков
Хорошо, вы прочитали первую часть и прониклись идеей. Вообще-то говоря юниттесты в любом изложении звучат привлекательно. Это способ уйти от постоянной боязни "что-то поломать". Чем больше людей работает над проектом - тем опасения выше.
И как, взяли и написали тесты, добившись стопроцентного покрытия?
Не тут-то было!
Очевидно, как написать тест для функции сложения двух чисел. Современные программы зачастую сложнее. Они обращаются к системным вызовам, работают с сетью и базами данных. Создание "правильного" тестового окружения может быть дорогостоящей операцией как с точки зрения простоты написания теста, так и времени, затрачиваемого на его исполнение.
А тесты должны писаться легко и выполнятся быстро - иначе кому они нужны?
Давайте перейдем к практике.
Вот класс, содержащий запись из базы данных. Это пост из моего инструмента, описанного выше. Я немного почистил код, избавившись от несущественных деталей.
class Post(object):
MARKDOWN_EXTS = ('codehighlight', 'meta')
def __init__(self, config, name, rst_file,
title=None, slug=None, labels=None):
self.config = config
self.name = name
self.rst_file = rst_file
self.title = title
self.slug = slug
self.labels = frozenset(labels or ())
@property
def full_path(self):
f = self.rst_file
return os.path.join(self.config.root, f)
@property
def html_path(self):
return self.full_path[:-4] + '.html'
@property
def is_html_fresh(self):
if not os.path.exists(self.html_path):
return False
rst_time = os.path.getmtime(self.full_path)
html_time = os.path.getmtime(self.html_path)
if rst_time > html_time:
return False
return True
def refresh_html(self, force=False):
if not force:
if self.is_html_fresh:
return False
with codecs.open(self.full_path, 'r', 'utf-8') as rst:
md = markdown.Markdown(extensions=self.MARKDOWN_EXTS)
source = rst.read()
html = md.convert(source)
if 'title' in md.Meta:
title = ' '.join(md.Meta['title'])
self.title = title
with codecs.open(self.html_path, 'w', 'utf-8') as f:
f.write(html)
return True
Конструктор простой как пять копеек, но тест нужен и для него:
class TestPost(unittest.TestCase):
def test_ctor(self):
cfg = object()
post = Post(cfg, 'name', 'file.rst', 'Title', 'slug', ['label1'])
self.assertEqual(cfg, post.config)
self.assertEqual('name', post.name)
self.assertEqual('file.rst', post.file)
self.assertEqual('Title', post.title)
self.assertEqual('slug', post.slug)
self.assertEqual(frozenset(['label1']), post.labels)
Тест на full_path
не намного сложнее:
def test_full_path(self):
class Config(object):
root = 'config-root'
cfg = Config()
post = Post(cfg, 'name', 'file.rst')
self.assertEqual('config-root/file.rst', post.full_path)
От объекта config
для этого теста требуется только root
, вот и подставим его.
Создавать настоящий объект Config
не всегда удобно.
Если можете легко его сделать и передать в конструктор Post
- так и стоит
поступать. На самом деле мой настоящий тест так и делает.
Для статьи сойдет и облегченный вариант.
html_path
пропустим ввиду его тривиальности. Т.е. тест для него прийдется тоже
создать, но в этой статье ему не место.
Переходим к следующему свойству, is_html_fresh
. Неприятности начались.
Вызываются функции os.path.exists
и os.path.getmtime
, которые требуют
настоящего файла, даже двух - rst
и html
.
1. Временные файлы
Конечно, можно их создать где-нибудь во временной папке, дать тесту отработать -
а затем уничтожить. Звучит как-то грустновато, много работы для такого простого
теста. Если и вторая неприятность: проверяются даты настоящих файлов.
Значит, нужно использовать os.utime
.
И, наконец, работа с файловой системой немного замедлит скорость выполнения тестов.
2. Monkey patching
Перед выполнением теста сделать monkey patching модуля os.path
,
а потом вернуть всё назад. Помимо того, что monkey patching - это нехорошо,
каждый раз писать установку и восстановление довольно утомительно.
3. Необязательные параметры
Передать необязательные параметры, использующиеся только в тесте.
Так как is_html_fresh
- property
,
не допускающее параметров - придется переделать в метод:
def is_html_fresh(self, is_html_exists=None, rst_time=None, html_time=None):
if is_html_exists is None:
is_html_exists = os.path.exists(self.path_path)
if not is_html_exists:
return False
if rst_time is None:
rst_time = os.path.getmtime(self.full_path)
if html_time is None:
html_time = os.path.getmtime(self.html_path)
if rst_time > html_time:
return False
return True
Что ж, способ работает. Но он - кривой. Код стал сложнее читаться, его объем вырос. В то время как ясные и понятные исходники значат очень много в жизни как программ, так и программистов. Хуже другое: без всяких на то причин изменилась сигнатура функции, в угоду одному единственному тесту.
Если бы такое изменение диктовалось внутренней логикой развития - я бы всё понял. А так имеем часть функционала, которая на самом деле не востребована.
Каждый раз глаз цепляется за этот длинный список необязательных параметров - а мозг пытается вспомнить, где же они применяются. Только в тестах? Не факт... Вы не сможете ответить на этот вопрос, не просканировав все имеющиеся исходники.
Если у меня во время работы над проектом появится потребность добавить еще один файл в список сравниваемых - что делать? Добавлять еще один необязательный параметр, или он таки не нужен?
Резюмируя, могу сказать что это - очень плохое решение. К тому же таким образом не добиться 100% тестового покрытия этого метода - надеюсь, понятно почему.
4. Использование атрибутов класса
Делаем наши глобальные функции атрибутами класса. Тест сможет их перекрыть, создавая атрибуты экземпляра.
Вот как это выглядит:
class Post(object):
exists = os.path.exists
getmtime = os.path.getmtime
@property
def is_html_fresh(self):
if not self.exists(self.html_path):
return False
rst_time = self.getmtime(self.full_path)
html_time = self.getmtime(self.html_path)
if rst_time > html_time:
return False
return True
class TestPost(unittest.TestCase):
def test_is_html_fresh_not_found(self):
mocker = mocker.Mocker()
exists = mocker.mock()
config = mocker.mock()
exists('root/file.html')
mocker.result(False)
config.root
mocker.result('root')
mocker.count(1, None)
with mocker:
post = Post(config, 'file.rst')
post.exists = exists
self.assertEqual(False, post.is_html_fresh)
def test_is_html_fresh_yes(self):
mocker = mocker.Mocker()
exists = mocker.mock()
config = mocker.mock()
getmtime = mocker.mock()
exists('root/file.html')
mocker.result(True)
config.root
mocker.result('root')
mocker.count(1, None)
getmtime('root/file.rst')
mocker.result(5)
getmtime('root/file.html')
mocker.result(10)
with mocker:
post = Post(config, 'file.rst')
post.exists = exists
post.getmtime = getmtime
self.assertEqual(True, post.is_html_fresh)
Я использовал библиотеку mocker для создания обманок.
Вкратце она работает так: записывается сценарий работы, а
with mocker:
его проигрывает, выполняя собственно тест.
Детальное описание (к слову, исчерпывающее и довольно небольшое - за что и люблю)
расположенно на странице, указанной в ссылке.
Поскольку реализация is_html_fresh
использовала доступ к .exists
и .getmtime
через self
, то вызовутся перекрытые мокером реализации, и всё будет хорошо.
Восстанавливать после теста ничего не нужно, он изменял только локально созданный
объект post
не трогая класс Post
.
Это - хороший способ? Когда как... Если таких заглушек немного - он вполне оправдан и достаточно элегантен. Но для нашего случая есть вариант и получше.
5. Последний вариант
Обратите внимание: os.path.exists и os.path.getmtime относятся к одной и той же
предметной области, как и применяющийся ниже codecs.open
.
Поэтому выделяем их в отдельный класс FileSystem
.
А Config.file_system
- отличное место для хранения этого класса.
У такого подхода есть и дополнительная выгода: FileSystem
- удобный контейнер
для разных полезных функций,
которых нет в стандартной библиотеке (вычисление относительного пути, например).
При тестировании можно эмулировать мокером этот config.file_system
, подменяя
все вызовы на наши реализации.
Остается последний момент: Post
использует мокер для тестов файловой системы,
в то время как реальный код будет обращаться к настоящему FileSystem
.
Реализация FileSystem
может изменится, тесты ничего не заметят - а "живой"
запуск сломается.
Решение очень простое. Создаём внутри FileSystem
мост
на внутренний класс реализации,
который и будет содержать эти наши настоящие вызовы.
Для тестирования нужно будет подменить этот класс.
class FileSystem(object):
class Impl(object):
exists = staticmethod(os.path.exists)
getmtime = staticmethod(os.path.getmtime)
open = staticmethod(codecs.open)
def __init__(self, config):
self._config = config
self._impl = Impl()
def open(fname, mode):
return self._impl.open(fname, mode, 'utf-8')
def exists(self, fname):
return self._impl.exists(fname)
def getmtime(self, fname):
return self._impl.getmtime(fname)
def full_name(self, rel_name):
return os.path.join(self._config.root, rel_name)
def replace_ext(self, fname, new_ext):
root, old_ext = os.path.splitext(fname)
return root + new_ext
class Post(object):
MARKDOWN_EXTS = ('codehighlight', 'meta')
def __init__(self, config, name, rst_file,
title=None, slug=None, labels=None,
blogid=None
postid=None):
self.config = config
self.name = name
self.rst_file = rst_file
self.title = title
self.slug = slug
self.blogid = blogid
self.postid = postid
self.labels = frozenset(labels or ())
@property
def full_path(self):
f = self.rst_file
return os.path.join(self.config.root, f)
@property
def html_path(self):
return self.config.file_system.replace_ext(self.full_path, '.html')
@property
def is_html_fresh(self):
fs = self.config.file_system
if not fs.exists(self.html_path):
return False
rst_time = fs.getmtime(self.full_path)
html_time = fs.getmtime(self.html_path)
if rst_time > html_time:
return False
return True
def refresh_html(self, force=False):
fs = self.config.file_system
if not force:
if self.is_html_fresh:
return False
with fs.open(self.full_path, 'r') as rst:
md = markdown.Markdown(extensions=self.MARKDOWN_EXTS)
source = rst.read()
html = md.convert(source)
if 'title' in md.Meta:
title = ' '.join(md.Meta['title'])
self.title = title
with fs.open(self.html_path, 'w') as f:
f.write(html)
return True
class TestPost(unittest.TestCase):
def setUp(self):
self.mocker = mocker
self.config = Config('root')
self.fs = self.mocker.mock()
self.config.file_system._impl = self.fs
self.post = Post(self.config, 'name', 'file.rst')
def test_is_html_fresh_yes(self):
self.fs.exists('root/file.rst')
self.mocker.result(True)
self.fs.getmtime('root/file.rst')
self.mocker.result(5)
self.fs.getmtime('root/file.html')
self.mocker.result(10)
with self.mocker:
self.assertEqual(True, self.post.is_html_fresh)
Длинно? Я гораздо дольше писал эту статью,
чем создавал тесты для "настоящего" модуля.
Обратите внимение на последний вариант теста: использование setUp
заметно
сократило объем тестового кода.
Покрыть тестами метод refresh_html
теперь не составляет никакого труда, но
я не стану приводить код - поберегу ваше время, статья и так получилась не
очень короткой.
Заключение
Я рассказал о том, как появляются тесты на новом проекте, почему и как это происходит.
Код не всегда удобен для тестирования - изменяйте его по мере необходимости. Здесь нужно искать баланс: все детали дизайна должны быть понятны и объяснимы без отсылок к тестам, и вместе с тем дизайн должен строится с учетом простоты тестового покрытия.
Хорошая архитектура непринужденно удовлетворяет обоим этим условиям, а также еще многим-многим другим. Ищите её, и у вас получится, а результат будет радовать.
Используйте coverage
чтобы убедиться, что весь код протестирован.
Кроме того, на его вывод просто интересно смотреть.
При выявлении бага старайтесь сначала обнаружить его тестами, а уже потом устранять. Соблюдение этого простого правила позволяет убедиться в двух вещах:
- Баг больше не повторится.
- Вы починили именно его, а не то о чём подумали. Нередко в попытках воспроизвести проблему на тестовом окружении с удивлением обнаруживал: код, который я собирался править - работал, а ошибка находилась совсем в другом месте.
Новый тест может выполнятся в крайне тяжело воспроизводимой вручную ситуации, и проверять очень редко исполняемые части кода - это просто замечательно!
Вот и всё. Удачного тестирования!
В продолжение - регрессионное тестирование.
Литература
Как мне подсказал уважаемый Kogrom, у такой статьи должен быть список используемой литературы.
-
Кент Бек, Экстремальное программирование: разработка через тестирование. Впрочем, рекомендую практически любую книгу этого автора - он пишет почти исключительно о юниттестировании, и делает это увлекательно.
-
Мартин Фаулер, Рефакторинг. Улучшение существующего кода. Напрямую к тестам не относится, но может служить хорошим справочником по работе над улучшением кода.
-
Майкл Физерс, Эффективная работа с унаследованным кодом. К статье близка глава 15. Как раз рассмотрен подход с обёрткой и альтернатива. Книга хороша тем, что решает задачи более близкие к практике, чем известная книга Кента Бека.
-
Стив Макконнелл, Совершенный код, Главы 22 (22.2, 22.3). Для полной картины можно читать с 20-й главы. Книга хороша своим критическим подходом к тестированию. Есть некоторая статистика по эффективности юниттестирования.
Очень хорошо написано! Читал с большим удовольствием и интересом, хотя не программил на питоне уже три года.
ОтветитьУдалитьСтатья построена в режиме диалога с читателем, что делает её очень живой и интересной.
Мне нравится как ты пишешь. У тебя отлично выходит!
Спасибо
ОтветитьУдалитьА этот инструмент для публикации статей -- он реально существует? будешь ли ты выкладывать его в открытй доступ?
ОтветитьУдалитьFileSystem? Смеялся :-). Нет, честно, даёшь по реализации FileSystem на каждый пакет!!! В данной ситуации я бы патчил os.path и не е#@л мозги.
ОтветитьУдалитьПару слов про mocker, вешь вроде не плохая, но я так и не смог привыкнуть к ней, к счастью. натолкнулся на fudge, который оказался как раз там инструментом, который будто был создан для тебя.
И что касается тестов до кода. Я иногда так делаю, часто код (хотя нет, функционал) настолько хорошо продуман, что каких-то сомнений относительно его интерфейса не возникает, т.е. после написания теста не приходится его приводить в соответствие с API по 10 раз. Разумеется я не считаю TDD святым граалем в разработке надёжного ПО... в общем, всему своё место... Короче пошёл писать тесты для смены механизма ротации VPN шлюзов :-).
https://launchpad.net/bloggertool
ОтветитьУдалитьСаня, он пока еще сырой. И, уверен, на Windows не заработает без мелких допиливаний.
Я планирую в ближайшее время добить его до такого состояния, чтобы было не стыдно показать.
И тем не менее да - это статья готовилась и публиковалась именно чудо-инструментом.
Конечно, создавать FileSystem для каждого проекта - дико смешная идея.
ОтветитьУдалитьНе хуже, например, чем создание отдельных классов для каждой таблицы в sqlalchemy. Ведь есть такое и простое DB API, зачем огород городить?
В FileSystem появилось довольно много кода, специфичного для проекта. А тот факт, что это оказалось еще и удобно для тестирования - идею ведь не портит, верно.
Давайте вместе посмеёмся над чудаками, придумывающими высокоуровневые абстракции при каждом удобном случае.
fudge - неплохая штука. Библиотек для мокинга существует как бы не десяток, выбирай себе по душе.
Андрей, для моего любопытства годится и сырой. :-)
ОтветитьУдалитьНасчет последнего варианта. Перечитал несколько раз, пока не врубился. А насколько хорошо в тестах подменять приватные вещи, типа _impl?
ОтветитьУдалитьЕще вопрос, возможно тривиальный.
ОтветитьУдалитьА что такое slug в конструкторе Post и для чего он нужен?
Нууу, они такие приватные...
ОтветитьУдалитьМожно рассматривать так:
_impl спрятан от кода приложения - программисту незачем об этом знать, забивая голову ненужными вещами.
Тесту так или иначе приходится иметь с этим дело. Лучше уж перекрыть _impl в одном месте, чем городить рассыпуху.
В любом случае это - дело вкуса.
Кстати, такие классы-мосты, содержащие внутри ссылку на реализацию - на самом деле ведь очень распространенная вещь.
Например, мне потребовалось замотать объекты google data api - они кривые и неудобные, совсем недалеко ушли от xml dom.
slug - это последняя часть url. Обычно совпадает с именем rst файла - но можно задать и вручную. Нужен на момент публикации поста.
В питоне есть os.utime(), который работает и в POSIX-системах, и в Windows.
ОтветитьУдалитьОоо. Спасибо. Совсем про неё забыл. Исправил.
ОтветитьУдалить>Сначала проблема решалась написанием статьи в любимом текстовом редакторе в формате Markdown, затем $ markdown_py article.rst > article.html перегонял ее в html, который легко и просто вставлялся как тело поста.
ОтветитьУдалитьА что мешало последовать примеру Алексея Отта и использовать Emacs + Muse?
http://alexott.net/ru/writings/EmacsMuseMyPage.html
P.S. Да, я в курсе, что это offtopic, но просто интересно.
ОтветитьУдалитьА разве muse умеет работать с blogspot.com?
ОтветитьУдалитьПроблема не в генерации html - как я писал, markdown_py делает вполне устраивающий меня html.
Мне куда важнее другая часть функционала: увидеть несинхронизированные посты и автоматически залить их на сервер.
Немного утрируя, похоже на разницу между tar+gzip и git в благородном деле резервного копирования.
Насчет FileSystem это единственно правильный подход. os.path пользуются те кто не потрудился написать нормальные абстракции или когда их нельзя тянуть в проект.
ОтветитьУдалитьПо коду бросилось в глаза `frozenset(labels) if labels else frozenset()` => `frozenset(labels or ())`, не?
И self.assertEqual действительно используешь в тестах? Не хочется заменить на eq_ из nose.tools?
Привет, Серега!
ОтветитьУдалитьДа, твой вариант с frozenset лучше. Мне почему-то это в голову не пришло.
Исправлю.
И да, использую self.assertEqual на полную катушку. Дело в том, что я рассматриваю nose скорее как удобную запускалку чем как нечто иное. А так тесты unittest совместимые, без специфичных для nose штучек.
Привет :)
ОтветитьУдалитьself.assertEqual как-то длинновато. Сам я до eq(..) сокращаю.
Если бы это было самое большое неудобство юниттестов....
ОтветитьУдалить