Когда и как вводить тестирование
Писать тесты нужно. Это в последние годы столько повторяют, что ни у кого
не хватит смелости отрицать вроде бы очевидное.
Существует даже известная максима: сначала тесты, а потом код.
С моей точки зрения она абсурдна: никто не утверждает,
что ходить всегда нужно только
с правой ноги - следует задействовать обе и почаще их менять.
Так и с тестами: код и тесты создаются одновременно.
Собственно говоря, тестирование не ограничивается одними юниттестами - но об
этом как-нибудь в другой раз.
И, тем не менее, при переходе к практической стороне дела возникают сложности.
Во первых, всегда ли тесты оправданы?
Для любой более или менее большой поделки - да!
Совсем недавно я работал над одним небольшим проектом, предназначенным для
публикования статей в этом блоге.
Дело в том, что 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-й главы.
Книга хороша своим критическим подходом к тестированию.
Есть некоторая статистика по эффективности юниттестирования.