среда, 16 февраля 2011 г.

Поговорим о юниттестах

Когда и как вводить тестирование

Писать тесты нужно. Это в последние годы столько повторяют, что ни у кого не хватит смелости отрицать вроде бы очевидное.

Существует даже известная максима: сначала тесты, а потом код.

С моей точки зрения она абсурдна: никто не утверждает, что ходить всегда нужно только с правой ноги - следует задействовать обе и почаще их менять.

Так и с тестами: код и тесты создаются одновременно.

Собственно говоря, тестирование не ограничивается одними юниттестами - но об этом как-нибудь в другой раз.

И, тем не менее, при переходе к практической стороне дела возникают сложности.

Во первых, всегда ли тесты оправданы?

Для любой более или менее большой поделки - да!

Совсем недавно я работал над одним небольшим проектом, предназначенным для публикования статей в этом блоге.

Дело в том, что 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 чтобы убедиться, что весь код протестирован. Кроме того, на его вывод просто интересно смотреть.

При выявлении бага старайтесь сначала обнаружить его тестами, а уже потом устранять. Соблюдение этого простого правила позволяет убедиться в двух вещах:

  1. Баг больше не повторится.
  2. Вы починили именно его, а не то о чём подумали. Нередко в попытках воспроизвести проблему на тестовом окружении с удивлением обнаруживал: код, который я собирался править - работал, а ошибка находилась совсем в другом месте.

Новый тест может выполнятся в крайне тяжело воспроизводимой вручную ситуации, и проверять очень редко исполняемые части кода - это просто замечательно!

Вот и всё. Удачного тестирования!

В продолжение - регрессионное тестирование.

Литература

Как мне подсказал уважаемый Kogrom, у такой статьи должен быть список используемой литературы.

19 комментариев:

  1. Очень хорошо написано! Читал с большим удовольствием и интересом, хотя не программил на питоне уже три года.

    Статья построена в режиме диалога с читателем, что делает её очень живой и интересной.

    Мне нравится как ты пишешь. У тебя отлично выходит!

    ОтветитьУдалить
  2. А этот инструмент для публикации статей -- он реально существует? будешь ли ты выкладывать его в открытй доступ?

    ОтветитьУдалить
  3. FileSystem? Смеялся :-). Нет, честно, даёшь по реализации FileSystem на каждый пакет!!! В данной ситуации я бы патчил os.path и не е#@л мозги.
    Пару слов про mocker, вешь вроде не плохая, но я так и не смог привыкнуть к ней, к счастью. натолкнулся на fudge, который оказался как раз там инструментом, который будто был создан для тебя.
    И что касается тестов до кода. Я иногда так делаю, часто код (хотя нет, функционал) настолько хорошо продуман, что каких-то сомнений относительно его интерфейса не возникает, т.е. после написания теста не приходится его приводить в соответствие с API по 10 раз. Разумеется я не считаю TDD святым граалем в разработке надёжного ПО... в общем, всему своё место... Короче пошёл писать тесты для смены механизма ротации VPN шлюзов :-).

    ОтветитьУдалить
  4. https://launchpad.net/bloggertool

    Саня, он пока еще сырой. И, уверен, на Windows не заработает без мелких допиливаний.

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

    И тем не менее да - это статья готовилась и публиковалась именно чудо-инструментом.

    ОтветитьУдалить
  5. Конечно, создавать FileSystem для каждого проекта - дико смешная идея.
    Не хуже, например, чем создание отдельных классов для каждой таблицы в sqlalchemy. Ведь есть такое и простое DB API, зачем огород городить?
    В FileSystem появилось довольно много кода, специфичного для проекта. А тот факт, что это оказалось еще и удобно для тестирования - идею ведь не портит, верно.

    Давайте вместе посмеёмся над чудаками, придумывающими высокоуровневые абстракции при каждом удобном случае.

    fudge - неплохая штука. Библиотек для мокинга существует как бы не десяток, выбирай себе по душе.

    ОтветитьУдалить
  6. Андрей, для моего любопытства годится и сырой. :-)

    ОтветитьУдалить
  7. Насчет последнего варианта. Перечитал несколько раз, пока не врубился. А насколько хорошо в тестах подменять приватные вещи, типа _impl?

    ОтветитьУдалить
  8. Еще вопрос, возможно тривиальный.
    А что такое slug в конструкторе Post и для чего он нужен?

    ОтветитьУдалить
  9. Нууу, они такие приватные...

    Можно рассматривать так:
    _impl спрятан от кода приложения - программисту незачем об этом знать, забивая голову ненужными вещами.

    Тесту так или иначе приходится иметь с этим дело. Лучше уж перекрыть _impl в одном месте, чем городить рассыпуху.

    В любом случае это - дело вкуса.

    Кстати, такие классы-мосты, содержащие внутри ссылку на реализацию - на самом деле ведь очень распространенная вещь.
    Например, мне потребовалось замотать объекты google data api - они кривые и неудобные, совсем недалеко ушли от xml dom.


    slug - это последняя часть url. Обычно совпадает с именем rst файла - но можно задать и вручную. Нужен на момент публикации поста.

    ОтветитьУдалить
  10. В питоне есть os.utime(), который работает и в POSIX-системах, и в Windows.

    ОтветитьУдалить
  11. Ооо. Спасибо. Совсем про неё забыл. Исправил.

    ОтветитьУдалить
  12. >Сначала проблема решалась написанием статьи в любимом текстовом редакторе в формате Markdown, затем $ markdown_py article.rst > article.html перегонял ее в html, который легко и просто вставлялся как тело поста.

    А что мешало последовать примеру Алексея Отта и использовать Emacs + Muse?

    http://alexott.net/ru/writings/EmacsMuseMyPage.html

    ОтветитьУдалить
  13. P.S. Да, я в курсе, что это offtopic, но просто интересно.

    ОтветитьУдалить
  14. А разве muse умеет работать с blogspot.com?

    Проблема не в генерации html - как я писал, markdown_py делает вполне устраивающий меня html.

    Мне куда важнее другая часть функционала: увидеть несинхронизированные посты и автоматически залить их на сервер.

    Немного утрируя, похоже на разницу между tar+gzip и git в благородном деле резервного копирования.

    ОтветитьУдалить
  15. Насчет FileSystem это единственно правильный подход. os.path пользуются те кто не потрудился написать нормальные абстракции или когда их нельзя тянуть в проект.

    По коду бросилось в глаза `frozenset(labels) if labels else frozenset()` => `frozenset(labels or ())`, не?

    И self.assertEqual действительно используешь в тестах? Не хочется заменить на eq_ из nose.tools?

    ОтветитьУдалить
  16. Привет, Серега!

    Да, твой вариант с frozenset лучше. Мне почему-то это в голову не пришло.
    Исправлю.

    И да, использую self.assertEqual на полную катушку. Дело в том, что я рассматриваю nose скорее как удобную запускалку чем как нечто иное. А так тесты unittest совместимые, без специфичных для nose штучек.

    ОтветитьУдалить
  17. Привет :)

    self.assertEqual как-то длинновато. Сам я до eq(..) сокращаю.

    ОтветитьУдалить
  18. Если бы это было самое большое неудобство юниттестов....

    ОтветитьУдалить