20 апреля 2010 в 09:50

Пишем функциональные/интеграционные тесты для проекта на django

В этой захватывающей статье я расскажу про инструменты, с помощью которых можно писать функциональные тесты для django-проекта. Есть куча разных других способов это делать, но я опишу один — тот, который, на мой взгляд, самый простой. Между делом создадим красивый отчет по code coverage (субъективно — приятнее тех, что делает coverage.py). И еще, в качестве приправы, будет немного болтовни про тестирование.



С места в карьер.

1. Устанавливаем необходимые пакеты


$ pip install coverage >= 3.0
$ pip install -e hg+http://bitbucket.org/kmike/webtest
$ pip install django-webtest
$ pip install django-coverage

WebTest — это библиотека для функционального тестирования wsgi-приложений от Ian Bicking (автора pip и virtualenv). Я на днях дописал туда поддержку юникода (что для нас очень важно, не так ли?), это пока не вошло в основной репозиторий, поэтому ставим из моего пока что (потребуется установленный mercurial, но можно и без него — скачать с битбакета zip-архив, распаковыть и запустить setup.py install). Потом, думаю, можно будет c pypi ставить. Можно и сейчас оттуда ставить (pip install webtest), только юникод в формах и ссылках работать не будет. (UPD: уже давно все вошло, можно все ставить с pypi спокойно)

Почему WebTest, а не twill? Twill тоже не поддерживает юникод (только там все хуже, не только юникод, но и даже и просто нелатинские буквы в utf8, насколько могу судить — UPD в комментариях meako пишет, что просто строки работают, как минимум в связке с tddspry), последний релиз был в 2007 году, там много кода, устаревшие версии библиотек в поставке, и показалось, что чтобы прикрутить туда что-то, потребуется больше усилий. А вообще twill хороший, и парсинг html там лучше, так что если что — имейте его тоже в виду (и пакет django-test-utils (или tddspry?) тогда тоже).

Почему не встроенный джанговский тест-Client? У WebTest значительно более мощный API, функциональные тесты с его помощью писать проще. Почитайте docstring к django.test.client.Client:

This is not intended as a replacement for Twill/Selenium or the like — it is here to allow testing against the contexts and templates produced by a view, rather than the HTML rendered to the end-user.

Почему не Selenium/windmill/..? Одно другому не мешает. Они тестируют другое все-таки. Для того, для чего можно использовать twill/WebTest, лучше использовать twill/WebTest, т.к. это будет работать гораздо быстрее, иметь лучшую интеграцию с другим кодом и более простую настройку.

2. Настраиваем проект


Небольшая настройка потребуется для django-coverage. Не пугайтесь, не большая) Следует:

1. добавить 'django_coverage' в INSTALLED_APPS и
2. в settings.py указать, куда сохранять html-отчеты. Папку под это дело хорошо бы создать.

COVERAGE_REPORT_HTML_OUTPUT_DIR = os.path.join(PROJECT_PATH, 'cover')

3. Пишем тесты


Вот, к примеру, функциональный тест для регистрации/авторизации:

Copy Source | Copy HTML<br/># coding: utf-8<br/>import re<br/>from django.core import mail<br/>from django_webtest import WebTest<br/> <br/>class AuthTest(WebTest):<br/>    fixtures = ['users.json']<br/> <br/>    def testLogoutAndLogin(self): <br/>        page = self.app.get('/', user='kmike')<br/>        page = page.click(u'Выйти').follow()<br/>        assert u'Выйти' not in page<br/>        login_form = page.click(u'Войти', index= 0).form<br/>        login_form['email'] = 'example@example.com'<br/>        login_form['password'] = '123'<br/>        result_page = login_form.submit().follow()<br/>        assert u'Войти' not in result_page<br/>        assert u'Выйти' in result_page<br/> <br/>    def testEmailRegister(self):<br/>        register_form = self.app.get('/').click(u'Регистрация').form<br/>        self.assertEqual(len(mail.outbox),  0)<br/>        register_form['email'] = 'example2@example.com'<br/>        register_form['password'] = '123'<br/>        assert u'Регистрация завершена' in register_form.submit().follow()<br/>        self.assertEqual(len(mail.outbox), 1)<br/> <br/>        # активируем аккаунт и проверяем, что после активации <br/>        # пользователь сразу видит свои покупки<br/>        mail_body = unicode(mail.outbox[ 0].body)<br/>        activate_link = re.search('(/activate/.*/)', mail_body).group(1)<br/>        activated_page = self.app.get(activate_link).follow()<br/>        assert u'<h1>Мои покупки</h1>' in activated_page<br/> <br/>


Сохраняем его в файле tests.py нужного приложения в проекте. Вроде все понятно тут должно быть. Проверяем, может ли зарегистрированный человек выйти с сайта, потом зайти на него (введя свои email и пароль), может ли зарегистрироваться (письмо получит? ссылка на активацию верная?) и попадает ли на нужную страницу после активации аккаунта. Для удобства использовалась также фикстура, в которой уже подготовлен пользователь kmike с email=example@example.com (и которая используется и в других тестах). Можно было этого пользователя прямо в тесте создать, это не суть.

Обратите внимание на API: по ссылкам мы ходим, указывая их имя (.click(u'Регистрация'), например), т.е. то, на что на самом деле жмет пользователь (есть и другие возможности). При каждом переходе WebTest автоматом проверяет, что нам вернулся код 200 или 302 (это настраивается). Для отправки форм не нужно конструировать POST-запросы вручную, формы подхватываются из html-кода ответа, достаточно присвоить значения нужным полям и выполнить метод submit(). Переходы по редиректам после POST-запросов делаются руками (и это полезно, т.к. если редиректа нет — например, ошибка при заполнении формы, то тест это покажет).

django_webtest.WebTest — это наследник от джанговского TestCase, умеет все то же. Но главное — в нем доступна переменная self.app типа DjangoTestApp (это наследник webtest.TestApp), через которую можно получить доступ к API WebTest. Подробнее про то, что умеет WebTest, лучше почитать у них на сайте. Там простой и приятный API, можно ходить по ссылкам, сабмитить формы, загружать файлы, парсить ответ (значительно более высокоуровневый и лаконичный, чем у джанговского тест-клиента). django_webtest добавляет к API одну фичу, специфичную для джанги: методы self.app.get и self.app.post принимают необязательный параметр user. Если user передан, то запрос (ну и все последующие переходы по ссылкам, отправки форм и тд) будет выполнен от имени джанговского пользователя с этим username'ом.

Ясно, что тут можно было протестировать больше всего, а можно было меньше, и тут хорошо соблюсти какой-то баланс: чтобы тесты было несложно писать и поддерживать, чтобы они проверяли все, что нужно, но не проверяли того, что не нужно. Иногда будет неправильно кликать по ссылке через ее имя, иногда будет недостаточно простой проверки, есть ли текст на странице, иногда даже эта проверка будет лишней. Это, думаю, называется опытом, когда понимаешь, как лучше. То, как я это написал данные тесты — не обязательно лучший способ в данной ситуации (хотя imho вполне адекватный), рассматривайте просто как пример, а не как пример для подражания, думайте над тем, что пишете. Одно из преимуществ простых API — программист начинает думать, что писать и как лучше писать, а не «как-бы дописать-то уже наконец..».

4. Запускаем тесты


Создаем файл test_settings.py (в корне проекта) примерно такого содержания (синтаксис для django 1.1):

from settings import *
DATABASE_ENGINE = 'sqlite3'
DATABASE_NAME = 'testdb.sqlite'

А потом запускаем тесты:

$ python manage.py test_coverage myapp1 myapp2 myapp3 --settings=test_settings

Можно и без test_settings обойтись (запускать просто $ python manage.py test_coverage myapp и никаких доп. файлов не создавать), просто с ним удобнее: можно туда любые специфичные для тестов настройки написать, например, использовать другую СУБД для более быстрого выполнения тестов или подменять URLOpener для urllib2, чтобы тесты не лезли в интернет. Команду для запуска тестов удобно обернуть в shell-скрипт (или bat-файл, если кто-то имеет несчастье писать на питоне под windows)

5. Смотрим картинки


Отчет по code coverage сохранился в указанной ранее папке. Открываем его (файл cover/index.html) и видим что-то вроде этого:


Переходим по какой-нибудь ссылке и видим, какой код у нас выполнился во время тестов, а какой — не выполнился (и, следовательно, никак не мог быть протестирован):


… много строк…

… много строк…

Ага! Сразу видно, что ситуацию, когда человек ввел email уже зарегистрированного пользователя, мы не проверяли.

Важно помнить, что функциональные/интеграционные тесты — это не замена юнит-тестам, а только дополнение к ним, и что 100% покрытие никак не гарантирует отсутствия ошибок. Юнит-тесты — точные, они говорят, ЧТО поломалось, они крайне полезны при рефакторинге и в сложных местах проекта. Функциональные — грубые, они говорят только «похоже, что-то где-то поломалось» и уберегают от дурацких ошибок. Но даже если тесты будут просто кликать по всем ссылкам на сайте и проверять, не выпало ли где исключение, то это уже будут очень полезные тесты, которые могут уберечь от кучи неприятностей.

Чтобы проиллюстрировать различие: в юнит-тесте для формы регистрации мы бы создали объект класса EmailRegistrationForm, передавали бы в него разные словари с данными и смотрели бы, какие вызываются исключения, например. Или бы проверяли отдельные методы этой формы. Юнит-тесты максимально приближены к коду (хотя и имеет смысл не пускать их за пределы публичного API), тестируют отдельный его кусок, и позволяют проверить, что все части системы по отдельности работаю корректно. Функциональные/интеграционные тесты помогают проверять, что и вместе они работают тоже правильно.

6. Все ссылки


docs.djangoproject.com/en/dev/topics/testing
pythonpaste.org/webtest
bitbucket.org/ianb/webtest
bitbucket.org/kmike/webtest
bitbucket.org/kmike/django-webtest
bitbucket.org/kmike/django-coverage
github.com/ericholscher/django-test-utils
twill.idyll.org
nedbatchelder.com/code/coverage

Да, все это можно так же легко использовать и без django, WebTest очень просто прикручивается к любому фреймворку, который поддерживает wsgi (а его поддерживают «все фреймворки, достойные внимания»), coverage.py отлично работает для любых тестов. Все эти django-… приложения — просто чтобы максимально упростить установку и настройку. Ну и django-coverage, если что, никакого отношения к webtest не имеет, он тут просто так затесался, до кучи уж.

7. Краткая инструкция


1. устанавливаем пакеты
2. добавляем 'django_coverage' в INSTALLED_APPS
3. в settings.py указываем, куда сохранять html-отчеты. Папку под это дело хорошо бы создать.
COVERAGE_REPORT_HTML_OUTPUT_DIR = os.path.join(PROJECT_PATH, 'cover')
4. пишем тесты, наследуя наш тест-кейс от django_webtest.WebTest и используя self.app
5. запускаем их: $ python manage.py test_coverage myapp1 myapp2 myapp3 --settings=test_settings

Если что-то не работает в webtest, django-webtest и django-coverage — пишите в Issues на bitbucket, постараюсь помочь.
Коробов Михаил @kmike
карма
339,7
рейтинг 0,0
Пользователь
Самое читаемое Разработка

Комментарии (19)

  • 0
    Спасибо! Я думаю, это отличное дополнение к Selenium.
  • 0
    Спасибо, много полезной инфы для меня.
  • НЛО прилетело и опубликовало эту надпись здесь
  • +1
    Спасибо за свежий взгляд на Django тестирование.

    Моя статья про coverage, но со стандартным клиентом,
    alarin.blogspot.com/2009/06/django-coverage.html
    • 0
      django-coverage — тоже, кстати, на coverage.py работает — и отчеты можно стандартные получать, указав COVERAGE_CUSTOM_REPORTS = False. django-coverage состоит из генератора этих кастомных отчетов (опционального) и клеевого кода для джанги, чтобы этот клеевой код не писать каждый раз, для простоты настройки вообщем.
  • 0
    Очень здорово, не знал о существовании это либы. Спасибо!
    • 0
      Лох не парсер, а я. Прошу прощения за незакрытый тег.
    • 0
      Это интересно! У меня никак не работает вот это:

      twill.follow(u'Форум')

      и даже вот это:

      twill.follow(u'Форум'.encode('utf8'))

      twill подключен через wsgi_intercept, кодировка страниц — utf8. Если это все можно заставить работать и лох не твилл, то я бы убрал из статьи про нерабочий уникод, не хочется распространять несправедливые слухи.
      • 0
        Сейчас по быстрому попробовал в шелле

        >> [meako@meako_inspiron meako]$ twill-sh 
         -= Welcome to twill! =-
        
        current page:  *empty page* 
        >> go "http://habr.ru"
        ==> at http://habrahabr.ru/
        current page: http://habrahabr.ru/
        >> follow "Люди"
        ==> at http://habrahabr.ru/people/
        current page: http://habrahabr.ru/people/
        >> 
        


        Чуть позже попробую в тестах, поскольку мы обычно пользуемся конструкцией go('django_view_name', [args]).
        • 0
          Только что набросал елементарный tddspry-тест, чтоб проверить на проекте над которым работаю:
              def test_advsearch_link(self):
                  self.go200('index')
                  self.follow("Поиск")
                  self.url('search')
                  self.find("Введите запрос")
          

          Прошел корректно, все работает.
      • 0
        Попробовал в обыкновенном питоньем шелле:) Вышло довольно смешно:
        >>> twill.commands.go("http://habr.ru")
        ==> at http://habrahabr.ru/
        'http://habrahabr.ru/'
        >>> twill.commands.follow("Люди")
        ==> at http://habrahabr.ru/people/
        'http://habrahabr.ru/people/'
        >>> twill.commands.follow(u"Люди")
        Traceback (most recent call last):
          File "<input>", line 1, in <module>
          File "/usr/lib/python2.6/site-packages/twill-0.9-py2.6.egg/twill/commands.py", line 202, in follow
            raise TwillAssertionError("no links match to '%s'" % (what,))
        TwillAssertionError: <unprintable TwillAssertionError object>
        
        

        Как вы поняли, передавать нужно строку а не юникодовый обьект.
        • 0
          Возможно, что-то в клеевом коде из django-test-utils все ломает, не знаю.

          #coding: utf-8
          from django_webtest import WebTest
          from test_utils.utils import twill_runner as twill
          
          class TestTest(WebTest):
              fixtures = ['cities.city.json', 'users.json']
          
              def testTwill(self):
                  twill.setup()
                  try:
                      twill.go('/login/') 
                      twill.code(200) # все ок
                      
                      twill.showlinks() # печатает какую-то перекодированную несколько раз белиберду
                      twill.follow('Регистрация') # валится с no links match to Регистрация
                  finally:
                      twill.teardown()
          
              def testWeb(self):
                  page = self.app.get('/login/')
                  page.click(u'Регистрация') # работает
          
          


          twill пробовал и с pypi, и из репозитория на гуглокоде (там пробовали доделать, но так и бросили опять год назад). Интересно, что последний mechanize сам по себе, без twill, ссылки печатает правильно.
        • 0
          Да, еще момент — у вас общение идет по http, я проверял работу через wsgi, может еще в этом косяк.
          • 0
            У tddspry wsgi-intercept который мокает http соединение.
            В двух словах:
            from twill import add_wsgi_intercept, commands
            
                def setup(self):
                    super(BaseHttpTestCase, self).setup()
            
                    app = AdminMediaHandler(WSGIHandler())
                    add_wsgi_intercept(self.IP, self.PORT, lambda: app)
            


            Хотя, я посмотрел, автор tddspry недавно обновил код, и там уже по другому. Нужно будет разбираться:)
  • +1
    >по ссылкам мы ходим, указывая их имя (.click(u'Регистрация'), например), т.е. то, на что на самом деле жмет пользователь (есть и другие возможности).

    вот это круто
    • –1
      ага, все сразу побежали делать ботов)
  • 0
    что-то битбакет плющит последние пару дней. где еще можно скачать твою ветку вебтеста?
    • +1
      Битбакет последнюю пару месяцев колбасит что-то) Кинул к себе снимок репозитория.

      Установка:

      mkdir webtest
      cd webtest
      hg init
      hg unbundle http://kmike.ru/webtest-r92.bundle
      hg up
      python setup.py install
      

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