Django
June 2011 18

Тестирование проектов Django

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

Краткое содержание поста:
  1. тестирование веб-сайтов — это сложно и непонятно
  2. юнит-тесты в django
  3. тестовая БД и как с ней бороться
  4. smoke testing
  5. покрытие кода (code coverage)

Тестирование веб-сайтов


Самый главный подводный айсберг тестирования Django-проектов заключается в том, что недостаточно написать тесты для питонокода. Разваливается верстка, JavaScript живет своей жизнью, веб-сервер не выдержал нагрузки и превратился в тыкву — все эти вещи выявить при помощи тестирования несколько сложнее, чем отследить неверный результат функции.

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

Начнем же мы с (относительно) простых и понятных юнит-тестов.

Юнит-тесты в Django


Юнит-тесты в Django живут в модуле django.utils.unittest и являют собой расширение стандартного модуля unittest из поставки python 2.7 (unittest2). Что добавлено:

Тестовый HTTP-клиент. Имитирует работу браузера, может отправлять get- и post-запросы, сохраняет cookies между вызовами.

>>> from django.test.client import Client
>>> c = Client()
>>> response = c.post('/login/', {'username': 'admin', 'password': 'qwerty'})
>>> response.status_code
200

С тестовым клиентом связан ряд ограничений. Например, запросить можно только относительный путь, URL вида http:/​/localhost:8000/ не сработает (по понятным причинам).

Расширенный набор проверок. Помимо стандартного набора, класс django.test.TestCase содержит также django-специфичные методы assert*, например:

assertContains(response, text, ...)  # проверяет, что в ответе сервера содержится указанный текст;
assertTemplateUsed(response, template_name, ...)  # проверяет, что при рендеринге страницы использовался указанный шаблон;
assertRedirects(response, expected_url, ...)  # проверяет, было ли перенаправление;

и другие полезные вещи.

Тестирование почты. Модуль django.core.mail сохраняет в переменной outbox список всех отправленных посредством send_mail() писем.

Условное исключение тестов. В случае, если выбранная СУБД не поддерживает (или, наоборот, поддерживает) транзакционность, можно исключить заведомо сломанные тесты при помощи декоратора @skipUnlessDBFeature('supports_transactions') или @skipIfDBFeature('supports_transactions').

Тестирование запускается вот так:

$ ./manage.py test [список приложений]

По умолчанию прогоняются все тесты для всех приложений, перечисленных в INSTALLED_APPS. Пускалка (на языке оригинала — test runner) найдет юнит- и доктесты в файлах models.py и tests.py внутри каждого приложения. Чтобы импортировать доктесты из других модулей, можно использовать следующую запись:

from utils import func_a, func_b
__test__ = {"func_a": func_a, "func_b": func_b}

Здесь func_* — функция (или другая сущность), docstring которой нас интересует.

Для наблюдателя процесс тестирования выглядит следующим образом:

$ ./manage.py test main
Creating test database for alias 'default'...
..........
Ran 10 tests in 0.790s

OK
Destroying test database for alias 'default'...

Тестовая БД и как с ней бороться


Для запуска тестов Django всегда создает новую БД, чтобы исключить вероятность уничтожения данных в рабочем окружении. Если в settings.py не указано иное, тестовая БД предваряется словом test_. Применимо к MySQL, привилегии обычно задаются как-то так:

GRANT ALL PRIVILEGES ON `project`.* TO 'user'@'localhost';
GRANT ALL PRIVILEGES ON `test_project`.* TO 'user'@'localhost';

Создавать саму БД test_project при этом не нужно.

Хозяйке на заметку. Все работает быстрее, если добавить в конфиг MySQL строку

[mysqld]
skip-sync-frm=OFF

Умозрительно, что сразу после создания никаких полезных данных в БД нет. Чтобы не порождать тестовый набор данных внутри каждого теста в отдельности, можно сделать это один раз и сохранить в fixture:

$ ./manage.py dumpdata > app/fixtures/test_data.json

В коде:

class HelloTestCase(TestCase):
    fixtures = ['test_data.json', 'moar_data.json']

И еще. Старайтесь использовать для разработки и тестирования ту же СУБД, что и на production-сервере. Это сделает ваш сон на 28%* спокойнее.

* научно доказано, что 87.56% статистики берется с потолка.

Smoke testing


В среде радиолюбителей термин smoke test означает буквально следующее: подключаем к свежесобранной схеме питание и наблюдаем, в каком месте из нее пошел дым. Если дым не пошел, можно приступать к более наукообразной проверке правильности работы схемы.

Описанный подход практикуют также при тестировании приложений. Применимо к Django имеет определенный смысл описывать в tests.py точки входа из URLconf, например, так:

urls.py
urlpatterns = patterns(None,
    url(r'^registration/$', registration, name='registration'),
    url(r'^login/$', ..., name='login'),
    url(r'^logout/$', logout_then_login, name='logout'),
)

tests.py
from django import test
from django.core.urlresolvers import reverse

__test__ = {"urls": """
>>> c = test.Client()
>>> c.get(reverse('registration')).status_code
200
>>> c.get(reverse('login')).status_code
200
>>> c.get(reverse('logout')).status_code
302
"""}

Безусловно, такая проверка не заменит функционального тестирования регистрации и логина. Полковник Очевидность пост принял.

Покрытие кода (code coverage)


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

Хозяйке на заметку-2. Высокое покрытие кода не говорит об отсутствии ошибок (ни в коде, ни в тестах), это вымысел.

Для измерения покрытия кода на питоне существует coverage.py. Гугл помнит много попыток подружить coverage.py и Django, есть даже тикет #4501 (ему четыре года).

И сразу ложка дегтя: с Django 1.3 (и dev-версией) ни одно готовое решение для code coverage, похоже, не работает (поправьте меня, если это не так). Что, впрочем, не помешает нам запустить coverage.py руками.

$ coverage run --source=main,users manage.py test main users
$ coverage html  # генерация отчета

Перечислим только интересующие нас модули (ключ --source); если не указать, там будет в том числе django, mysqldb и половина стандартной поставки питона.

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

В следующем выпуске: статический анализ как превентивная мера, тестирование верстки и JS, нагрузочное тестирование.
+66
36.2k 265
Comments 23
Top of the day