Внедряем TDD с django и postgres

    В Островке есть два основных продукта: для пользователей (ostrovok.ru) и «админка» для отелей (экстранет), куда подписанные нами отели заносят данные. Это отдельные продукты, со своими командами и различным отношением к разработке через тестирование (TDD). Одинаковая платформа: django и postgres. В экстранете используют TDD и у них куча тестов. Поначалу тесты были и в ostrovok.ru, но ввиду ухода части адептов в экстранет и очень интенсивного развития их перестали поддерживать. В общем передо мной встала задача внедрить тестирование. Первые шаги сделаны и хочу поделиться этим опытом и решениями, которые были применены.
    У нас есть отдел QA и Selenium автотесты, но это отдельно.

    С django и тестами вообще дела обстоят довольно хорошо и конечно лучше с самого начала все покрывать тестами, наращивая функционал и делая рефакторинги.

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


    Какие тесты мне нравятся и почему TDD



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

    По изоляции мне нравятся больше интеграционные тесты, по тестируемому объекту — функциональные.

    У таких тестов очень большое покрытие кода, это и плюс, и минус одновременно.

    Минус:
    • найти сломанное место иногда трудно

    Плюсы:
    • python интерпретируемый язык, ошибки могут вылезти в момент исполнения кода, а если он покрыт тестами, то можно уверено сказать, что этот код не падает;

    • такие тесты — высокоуровневые, и нам зачастую не страшны детали реализации, т.е. нужно меньше их править, хотя править конечно же приходится.

    Мы разрабатываем веб и в идеале мне не хочется открывать браузер для ручного тестирования моего кода. Хочется записать в тест все действия в браузере и добавить ряд проверок (отправка письма, наличие лога или какого-то объекта в базе). Когда буду писать код, мне нужно провести все эти действия вручную один раз точно, но в большинстве случаев это будет несколько раз. Записать действия в тест и прогнать десять раз по несколько секунд это намного круче, чем вручную сделать десять проверок. В браузере кроме основной разметки еще подгружаются стили, картинки, javascript и все это обычно сваливается на наш локальный runserver, а он не самый шустрый и зачастую работает в одном потоке, т.к. настраивать для разработки связку uwsgi и nginx как-то не хочется… Ну и вдобавок выгода в том, что написанный тест, который помог в разработке, остается и играет важную роль в регрессионном тестировании.

    Кроме тестирования http запросов есть и другие тесты, например, тестирование django команд, с ними все аналогично. Обычные юниттесты тоже полезны. Когда привыкаешь пускать и писать тесты, то и стиль разработки меняется, процесс скорее будет итеративным: простой тест — нужный код, усложняем тест — дописываем код. Например: можно сделать опечатку, быстро запустить тест и увидеть опечатку и что тест не прошел.

    Да и конечно есть места, где ручное тестирование сложно или даже почти невозможно, в этом случае тесты — это необходимость. Например: проверка правильного перехвата исключений и обработки ошибок, тонкие места логики…

    В идеале — сначала тест.

    Расписывать все преимущества разработки через тестирование не цель данной статьи, оставим это другим, например, Кенту Беку.

    Как сделать запуск тестов быстрее?


    В TDD очень важная операция — запуск тестов. Обычно это даже не все тесты, а какой-то набор: тесты из пакета, отдельного модуля или вообще отдельный тест. Поэтому запуск отдельных тест кейсов должен быть быстрым.

    В django с этим проблема, в ней перед каждым запуском теста создается база, и если схема большая, то это может занять и 30 секунд, а выполнение конкретного теста — меньше секунды. Не хочу ждать пока создается база.

    Решение: вынести создание базы в отдельный шаг (использовать базу из предыдущих прогонов).

    В рамках наших условий кроме схемы базы нам еще понадобились начальные данные:
    • с отдельного внутреннего ГИС сервиса, сам сервис живет своей жизнью, предоставляя REST интерфейс;

    • в http тестах часто нужны загруженные отели.

    Кажется что тут нового:
    • в django есть фикстуры, правда они статические и их не очень приятно поддерживать — поэтому нет;
    • есть ряд библиотек для генерации динамических фикстур: раз, два, три. Они имеют право на жизнь, но у нас отель — довольно сложная сущность, поэтому автоматической генерации — нет.


    Используй существующий код!



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

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

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

    В итоге у нас получились свой раннер и ряд плагинов, решающие несколько проблем:
    • независимый шаг создания базы;

    • обвертка для сброса базы (транзакции или уникальная база на тест);

    • слежение за состоянием базы после теста в режиме транзакций;

    • изолированность от внешнего мира (внешние http запросы должны мокаться);


    Есть процесс создания базы в зависимости от параметров командной строки:

    ...
    --with-reuse-db  # включает реиспользование базы, можно включить в настройках
    --create-db      # при включенном первом флаге пересоздает базу
    ...


    В этом подходе есть минус: нужно помнить, что если меняется схема базы, то нужно базу пересоздать. Это не критично, важнее быстрый запуск.

    Процесс создания начальной базы у нас уже может занимать до минуты при импорте ГИСа и отелей. Причем мы сохраняем две начальные базы: с отелями и без, т.к. при тестировании импортов нам отели не нужны. В конкретных TestCase мы задаем нужный нам шаблон базы.

    В стандартном django подходе из TransactionTestCase делается flush (полная очистка базы), потом восстанавливается начальная. Этот подход не работает, т.к. у нас отдельный шаг по созданию базы, и чистить ее не нужно. При опции autocommit для postgres, flush выполнялся на каждый тест и это плохо — он долгий.

    Чтоб ускорить тесты (относительно flush) мы использовали уникальную базу, которая создавалась по шаблону, postgres такое умеет:

    src = self.db_conf['TEST_NAME']
    new = '{0}_{1}'.format(src, uuid.uuid4().hex)
    psql(
        'DROP DATABASE IF EXISTS "{0}";'
        'CREATE DATABASE "{0}" WITH TEMPLATE "{1}";'
        .format(new, src)
    )

    Прирост был относительно flush в несколько раз и это казалось уже неплохо. Плюс уникальной базы на тест в том, что вероятность каких-то коллизий в базе нулевая, а с транзакциями они возможны. В конце концов пришли к варианту: по умолчанию работа в транзакции, т.к. это быстрее, а если у каких-то тестов проблемы — то уникальная база.
    Для ускорения тестовой базы можно еще поставить в postgresql.conf:
    fsync = off  # turns forced synchronization on or off 

    Прирост тоже ощущается. Ну и SSD винчестеры тоже хорошо :).


    Такие тесты проще включить в процесс сборки, они достаточно быстро проходят (3-4 минуты ~250 тестов) и не задерживают особо релиз, они рядом с кодом. За временем выполнения тестов нужно следить и принимать меры по ускорению, т.к. количество тестов будет только расти, а значит — и время их выполнения.

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

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

    Что мы имеем?


    Благодаря существенному ускорению запуска тестов теперь они участвуют в сборке пакета: релиз не выкатывается, если есть упавшие тесты. Это тоже очень важный момент, т.к. есть явная связь: работающие тесты — релиз, неработающие тесты — нет релиза (релизы у нас частые, бывают несколько раз в день). Selenium автотесты живут пока отдельной жизнью, но команда работает над включением их в процесс непрерывной интеграции.

    Тесты нам уже помогают:
    • ловят некоторые баги, на этапе сборки релиза;

    • переезд с django 1.3 на 1.4, частично заслуга тестов;

    • некоторую логику вручную проверить сложно, а в тестах нет (касается наших импортов отелей);

    • с тестами стало немного уверенней.

    В принципе начало положено, решения приняты, что будет дальше — время покажет.

    P.S. python и postgres отличные инструменты — используйте.

    Автор: naspeh
    Ostrovok.ru
    55,43
    Компания
    Поделиться публикацией

    Похожие публикации

    Комментарии 47

      0
      раз, два, три

      То-ли ссылки не вставились, то ли ещё что. А какие есть, а то я кроме django-any и его форка больше ничего не знаю?
        +2
        поправили ссылки, спасибо. Я знаю хорошие отзывы про django-dynamic-fixture :)
          +2
          Есть ещё factory_boy и как по мне, с ним удобнее
          0
          Еще по теме TDD с django тут несколько уроков.
            +2
            Перед каждым запуском теста база не создается. Она создается один раз, потом перед каждым тестом говорится start transaction, а после tearDown() говорится rollback;
              +2
              при каждом запуске ./manage.py test — база создается :)

              между тестами да обычно транзакции, но может быть и flush — в статье это опоминалось…
              +1
              > Для ускорения тестовой базы можно еще поставить в postgresql.conf:
              > Прирост тоже ощущается. Ну и SSD винчестеры тоже хорошо :).

              Просто инициализируйте тестовый PG-кластер в /dev/shm
                0
                Кстати,

                >Такие тесты проще включить в процесс сборки, они достаточно быстро проходят (3-4 минуты ~250 тестов) и не задерживают особо релиз, они рядом с кодом. За временем выполнения тестов нужно следить и принимать меры по ускорению, т.к. количество тестов будет только расти, а значит — и время их выполнения.

                Вполне логично в таких случаях сразу ориентироваться на распределённое тестирование. Для py.test существуют простые плагины, которые реализуют такую возможность. Для nose довольно легко написать похожий плагин, взяв за основу pytest-xdist.
                0
                0
                По изоляции мне нравятся больше интеграционные тесты, по тестируемому объекту — функциональные. У таких тестов очень большое покрытие кода, это и плюс и минус одновременно… они достаточно быстро проходят (3-4 минуты ~250 тестов)
                как будто про нас. :) тоже с этого начинали. и щастье при первом рефакторинге. и разочарование, когда более-менее освоили метод, а количество тестов перевалило за сотню — запуск занимает какое-то неразумное время, при этом видно невооруженным глазом, что получаемая польза не оправдывает вложения.

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

                мне кажется, что «большое покрытие кода» интеграционными тестами — иллюзия. как раз о коде они дают лишь крайне поверхностное, оценочное представление. по сто раз дергают одни и те же куски, пропуская значимые граничные аспекты. отсюда эффективность. данный вид тестов хоть и хорош в начале, но в качестве основного средства tdd-разработки не особо практичен. вроде как хождение на четвереньках.
                  0
                  Это вопрос о гранулярности тестов. Потому что

                  > чем крупнее кусок системы, охватываемый тестом, тем больше у него параметров состояния

                  >по сто раз дергают одни и те же куски

                  — это те самые симптомы, о которых начинают упоминать в случаях неправильной гранулярности.
                    0
                    у меня опыт интенсивного TDD в python уже пару лет точно — это осознанный выбор, с учетом опыта…

                    Да покрытие кода не говорит про качество тестов, но я описал плюс покрытия кода для питона.

                    Нужно писать хорошие тесты, как и нужно писать хороший код :).

                    Если поверх WSGI трудно тестировать какую-то логику, нужно спускаться ниже, и ниже уже проверять тонкую логику (например: на джанго форме или моделе) — это уже ближе к юнит тестам…

                    Тест поверх WSGI все равно нужен — он проверит код в реальном воркфлоу :), пусть даже не со всей логикой.
                    0
                    на случай, если кто будет тестить

                    без fsync = off база создаетс адски долго. А с ним — реально секунды
                      0
                      Я так понял- автор выставил код уже с привязкой к совему проекту, а не реюзабельное решение
                        0
                        в приципе там особо привязок нет, я немного обновил gist, добавил requirements.txt и пару коментов. У нас это пакет, поэтому нужно его создать… Но для публикации идеи мне казалось gist-a достаточно…
                          0
                          psql( 'create extension cube;' 'create extension earthdistance;' )

                          Про редис в статье ни слова

                          def mock_http(self): self.mock_func('urllib2.urlopen') self.mock_func('urllib2.build_opener') self.mock_func('requests.api.request')

                          Вот это зачем?
                            0
                            1. setup_databases пишем под себя, написал об этом в шапке раннера

                            2. да про редис забыл :), для зачистки обвертка…

                            3. mock_http — тесты не должны сутчатся на внешний http, т.к. он может отвалиться или сети вообще не быть, а тесты должны ходить в не зависимости, отвечает сайт или нет. Поэтому мы мокаем http запросы. mock_http нужен, чтоб видеть что тест делает http и свалиться, в трейсбеке можно увидеть, где возник этот запрос — потом идем мокаем :)… так же можно запустить с ключем --without-mock-http :)

                            ок, согласен gist заточен под проект :)
                        0
                        Просто у меня в голове крутилась именно идея того, что в репе лежит sql ый дамп постгри и его используют вместо фикстур.

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

                          0
                          sql — дамп нужно также поддерживать, как и статические фикстуры, что тут другого?

                          В setup_databases(это из gist) можно все что угодно сделать с базой, в том числе и подтянуть sql дамп с диска, или прям строкой записать в базу тригер — это не важно.

                          Потом между тестами мы ее будем создавать с WITH TEMPLATE (мы ведь про postgres) и там у нас будут уже все тригиры и данные, которые мы туда запихнем на шаге создания бд… Эта операция (создание бд с WITH TEMPLATE) быстрее чем flush на большой базе, но медленее транзакций. Про это в статье говориться.
                            0
                            Выигрыш будет не только между тестами но и в первом тесте. А при TDD это реально выигрыш во времени. Нет?

                            Не вижу сложностей в поддержке sql дампа в репе. Саус накатывает миграции. Все инсерты идут один за другим в отиличии от фикстур, поэтому легко резолвить конфликты в дампах. Помоему сказка.
                              0
                              у нас есть отдельно данные для теста, там дамп мама не горюй :)

                              — при кажой миграции схемы, нужно переливать дамп
                              — одновременное редактирование схемы — что тогда, делать мердж будет напряжно?

                              у нас большая достаточно команда, гит и куча бранчей, тестовая база — дамп на 5 гиг :)… Все не так-то просто :)
                                0
                                5ти гиговая база для тестов? Мы же сейчас не про стресс тестирование говорим?

                                А как вы ее поддерживаете? По сути тоже самое, только без дампа. Под каждый бранч отдельная 5ти гиговая дура висит :)
                                  0
                                  Ремарка: для ручного тестирования :)

                                  там данные, которые похожи на прод и их там много, на них проверяем миграции…

                                  Нет не под каждый бранч :), одна на все бренчи — но это отдельная история :)
                          0
                          а без django_nose можно это реализовать наследником от обыкновенного DjangoTestSuiteRunner?
                            0
                            если без django_nose и nose так понимаю, то наследник DjangoTestSuiteRunner + свой TestCase со всей логикой из плагинов
                              0
                              да я ща кручусь — похоже на то, что да. Надо еще TestCase переписывать, хотя пробую один хук еще заюзать. Может получится
                                0
                                можно вместо TestCase — свой DataBaseWraper для тестов.
                              0
                              А в каких случаях вы используете флаг _test_unique_db а в каких нет?
                                0
                                У нас был момент, когда мы использовали только уникальную базу, а когда подключали транзакции, то часть тестов не работали в транзакции и почему было не очевидно :)… Поэтому те которые не работали оставили с уникальной базой. В общем пока точно не знаю, предстоит разобраться… :)
                                  0
                                  Вот и мне тоже стало интересно :) Может внутри кода создавалось еще одно соединение с базой?

                                  Но мне кажется разобраться стоит, т.к. на транзакциях работает в 10 раз быстрее чем с клонированием.

                                  Незабудте сообщить, как выясните :)
                                0
                                а свойство _test_db — нужно на случай использования различных Баз?
                                  0
                                  да, в setup_databeses я сохраняю несколько шаблонов баз, по этому атрибуту получаю какую базу нужно клонировать для конкретного теста.
                                  0
                                  а можешь про вот это объяснить по подробнее?
                                  * изолированность от внешнего мира (внешние http запросы должны мокаться);

                                  Я не очень понял.
                                    0
                                    тут 3 пункт habrahabr.ru/company/ostrovok/blog/146552/#comment_4936729, вроде ответил :)

                                    у нас много внешних зависимостей: шлюзы оплаты, провайдеры, просто какие-то внешние сервисы с апи… это все нужно мокать — мы мокаем… И для этого обвертка mock_http, чтоб разработчики понимали, что тест стучится во внешний мир…
                                      0
                                      Да, прости, этот ответ не увидел. Спасибо
                                    0
                                    gist.github.com/3017754 небольшая вариация на тему :)

                                    За основу — взял Ваш. Правда убрал пару, как мне показалось, не нужных вещей.

                                    Можете глянуть? Что я могу упустить?
                                      0
                                      сразу что в глаза бросается, это если у нескольких людей одинаковый TEST_DB_PREFIX

                                      TEST_DB_PREFIX = settings.DATABASES['default']['NAME'] + '__'

                                      то так нельзя запустить тесты нескольким пользователям с базой на одном серваке… т.к. все базы у первого попытаются грохнуть Runner второго…

                                      Ну и зря убрали REUSE_DB, я про это в статье писал, запуск одного отдельного теста должен быть 1-2 секунды, а не даже 10-20сек, а создание базы может занять и больше времени…
                                        0
                                        Если тип БД не критичен, то при разработке для тестов можно использовать sqlite3 и в качестве имени БД ':memory:' — тесты выполняются на порядок быстрее.
                                          0
                                          > Про быстрый SQLite в памяти можно забыть, в проекте есть привязки к особенностям postgres, да и идентичность тестового окружения все-таки важна, поэтому тесты тоже работают на postgres.

                                          :) критичен
                                          +1
                                          Один из признаков псевдо-TDD — необходимость «поддерживать тесты, чтобы не стали мертвым грузом»:
                                          В TDD тесты правятся первыми не потому, что их обязательно надо поддерживать, а потому, что они определяют требования/спецификации: изменились бизнес-требования (или другой код) -> изменить формальные спецификации (а это и есть тесты) -> изменить сам код так, чтобы он удовлетворял новым спецификациям.

                                          Ключевая разница между написанием любых автоматизированных тестов и именно TDD — в голове: тесты воспринимаются не как груз для проверки кода, который еще почему-то нужно писать перед самим кодом, а как естественный этап проектирования: от спецификаций на естественном языке переходим к спецификациям в программном коде (вместо или в дополнение к рисованию на бумажке), которые и помогают написать правильный код.
                                            0
                                            Согласен. Поэтому статья и называется — внедряем TDD :).
                                            0
                                            А вы пробовали смонтировать ramdisk и положить туда БД? Пример для мускула github.com/miracle2k/linuxutils/blob/master/mysqld-ram.sh
                                              0
                                              fsync=off это почти тоже что рам. Я пробовал и чисто рам, но что-то прироста от него было мало, пробовал правда давненько.
                                              0
                                              «найти сломанное место иногда трудно»
                                              Не согласен, использую TDD около 3ёх лет и за редким исключением проблемы находятся мгновенно.
                                                0
                                                в функциональных тестах с большим покрытием кода? в каждом куске кода, который такой тест покрывает потенциально может быть ошибка, если код знаешь с нуля, то да все найдется быстро, если в проект пришел через код его существования, то все тонкости не будешь знать…

                                                с юнит тестами да все просто — но на то они и юнит…

                                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                              Самое читаемое