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

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

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

$ pip install coverage >= 3.0
$ pip install webtest
$ pip install django-webtest
$ pip install django-coverage

WebTest - это библиотека для функционального тестирования wsgi-приложений от Ian Bicking (автора pip и virtualenv).

Почему WebTest, а не twill? Twill не поддерживает юникод (только там все хуже, не только юникод, но и даже и просто нелатинские буквы в utf8, насколько могу судить [1]), последний релиз был в 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. Пишем тесты

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

# coding: utf-8
import re
from django.core import mail
from django_webtest import WebTest

class AuthTest(WebTest):
    fixtures = ['users.json']

    def testLogoutAndLogin(self):
        page = self.app.get('/', user='kmike')
        page = page.click(u'Выйти').follow()
        assert u'Выйти' not in page
        login_form = page.click(u'Войти', index= 0).form
        login_form['email'] = 'example@example.com'
        login_form['password'] = '123'
        result_page = login_form.submit().follow()
        assert u'Войти' not in result_page
        assert u'Выйти' in result_page

    def testEmailRegister(self):
        register_form = self.app.get('/').click(u'Регистрация').form
        self.assertEqual(len(mail.outbox),  0)
        register_form['email'] = 'example2@example.com'
        register_form['password'] = '123'
        assert u'Регистрация завершена' in register_form.submit().follow()
        self.assertEqual(len(mail.outbox), 1)

        # активируем аккаунт и проверяем, что после активации
        # пользователь сразу видит свои покупки
        mail_body = unicode(mail.outbox[ 0].body)
        activate_link = re.search('(/activate/.*/)', mail_body).group(1)
        activated_page = self.app.get(activate_link).follow()
        assert u'<h1>Мои покупки</h1>' in activated_page

Сохраняем его в файле 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) и видим что-то вроде этого:

http://kmike.ru/img/habr/testing/mac_screenshot.png

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

http://kmike.ru/img/habr/testing/mac_screenshot-2.png

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

http://kmike.ru/img/habr/testing/mac_screenshot-1.png

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

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

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

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

6. Все ссылки

Да, все это можно так же легко использовать и без 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, постараюсь помочь.

Note

Статья и комментарии на хабре: http://habrahabr.ru/blogs/django/91471/

[1]В комментариях meako пишет, что просто строки работают, как минимум в связке с tddspry.