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

Note

Статья была написана для habrahabr.

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

Note

У нас есть отдел QA и Selenium автотесты, но это отдельно.

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

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

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

http://static.ozone.ru/multimedia/books_covers//1000074893.jpg

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

$ ./manage.pt test --help
...
--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 в несколько раз и это казалось уже неплохо. Плюс уникальной базы на тест в том, что вероятность каких-то коллизий в базе нулевая, а с транзакциями они возможны. В конце концов пришли к варианту: по умолчанию работа в транзакции, т.к. это быстрее, а если у каких-то тестов проблемы - то уникальная база.

Note

Для ускорения тестовой базы можно еще поставить в 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 отличные инструменты - используйте.