Тестирование проектов 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, нагрузочное тестирование.
    Поделиться публикацией

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

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

      +7
      Классная статья!
        +1
        Спасибо, ждал!
          +5
          Мой глаз радуют любые статьи по Python/Django. Не останавливайтесь.
            0
            А как вы обходите проблему жестко прописанных параметров при POST запросах?
            Я так и не нашел легкого и удачного решения.
              +1
              Не уверен, что правильно понимаю вопрос. Для создания разных пользователей написал такую функцию, например:

              def _create_user():
                  username = ''.join(random.sample(string.lowercase, 8))
                  email = username + '@gmail.com'
                  password = username.upper()
                  user = User.objects.create_user(**locals())
                  user.data = {'username': username, 'password': password}
                  return user
              
              # и потом в коде
              user = _create_user()
              response = self.client.post(settings.LOGIN_URL, user.data)
              ...
              

              Не венец творения, но работает.
                0
                Поясняю вопрос. У меня в приложении есть форма, которая динамически генерируется. Я сам с трудом понимаю какие там имена у полей формы. Да, и сами значения могут меняться у селектов, например.
                Суть в том что я не могу написать тест, который не будет ломаться при каждой модификации кода, т.к. наименования полей и всякие другие условия меняются.
                Минутка антирекламы, никого не хочу обидеть. Считайте, что я просто не научился их готовить. Селениум считаю слишком радикальным решением, twill инороден. Django-webtest неплохое решение, но тоже чем-то не устроил.
                Вот и спрашиваю, что выбрать, дополнительно хотелось бы все таки обертку над стандартным django-unittest'ом.
                  +1
                  Ох, я не уверен что так бывает. Можно смотреть, какие у сгенерированной формы есть поля, но если их назначение меняется, то непонятно, что с этой информацией дальше делать.
                    0
                    Вот пример из Django webtest в котором отражена проблема, поля берутся из формы. Например если у вас там свои какие-то проверки.
                    class AuthTest(WebTest):
                        fixtures = ['users.json']
                        def test_login(self)
                            form = self.app.get(reverse('auth_login')).form
                            form['username'] = 'foo'
                            form['password'] = 'bar'
                            response = form.submit().follow()
                            self.assertEqual(response.context['user'].username, 'foo')
                    

                    Вот я и думаю, может у кого-то есть рецепт более менее подходящий мне. Потому как я когда попробовал, понял что не так тут все просто.
                      +1
                      думаю проблема не в django-webtest, а в том, что:
                      Я сам с трудом понимаю какие там имена у полей формы.

                      написание теста является одним из способов формализации требований. тест обязан ломаться, когда требования меняются и код рефачится — это фича. :)

                      если переделка кода вызывает ломку тестов, которые по логике не связанны с текущим рефакторингом, значит сами тесты составлены неправильно — проблема или в неправильно декомпозиции или в неправильно выбранном уровне абстракции. обычно это происходит, когда тест безосновательно пытается тестировать требования, которые не связаны с фичей, либо привязывается к каким-то низкоуровневым деталям реализации.
              0
              В свое время перешел на django-nose и django-webtest, внутренняя система тестирования не совсем то что требовалось, к примеру расположение всех тестов в отдельной папке, а не в каждом приложении по отдельности.
                +1
                Разве http-клиент относится к юнит-тестам? Всегда считал это интеграционным тестом. Даже импорт для него в вашем примере указан не из django.utils.unittest.
                  +1
                  Да, строго говоря, это интеграционный тест. В контексте веб-приложения выделить «чистые» юнит-тесты не всегда возможно.
                    0
                    Как правило, такое бывает, если писать тесты после кода ;)
                  +1
                  Я для тестирования создаю sqlite базу прямо в памяти вот таким образом:

                  DATABASES = {
                      'default': {
                          #...           
                      },
                  
                      # база для тестирования приложения
                      'slave': {
                          'ENGINE': 'django.db.backends.sqlite3',
                          'NAME': ':memory:',
                          'TEST_MIRROR': 'default', 
                          # если не добавить эту строку, тесты не будут выполняться
                          # бд, содержащаяся в памяти, не поддерживает транзакции
                          'SUPPORTS_TRANSACTIONS': 'False',
                      },
                  }
                  
                    0
                    А я по привычке так gist.github.com/269919
                      +1
                      К сожалению, все так протестировать нельзя. У меня PostgreSQL и сложный проект: используются триггеры, хранимые процедуры, window functions (через raw sql, конечно). Каждый прогон тесто занимает несколько минут. :(
                        +1
                        sqlite слишком многое прощает, когда-то кажется даже максимальная длина строки игнорировалась, да и с ключами постоянно какие-то непонятнки, в итоге на настоящем постгресе вылезали integirtyerror
                          +1
                          Ну вот да, я это примерно имел в виду, когда писал, что лучше разрабатывать и тестировать в окружении, максимально близком к продакшену. Скажем, CharField(max_length=1024) можно сделать уникальным в PostgreSQL, но нельзя в MySQL (MyISAM).
                        +1
                        Спасибо автору! Еще можно посмотреть в сторону mock тестирования сложных частей и взаимодействий www.voidspace.org.uk/python/mock/index.html
                          +1
                          > И сразу ложка дегтя: с Django 1.3 (и dev-версией) ни одно готовое решение для code coverage, похоже, не работает (поправьте меня, если это не так). Что, впрочем, не помешает нам запустить coverage.py руками.

                          C Django 1.3 у нас работает coverage 3.4 + django-coverage 1.0.3. С dev-версией нужно пробовать django-coverage 1.2 + coverage 3.4 или 3.5b1. Сам пока на dev-версии не пробовал настроить.
                            +1
                            Надо попробовать, спасибо. Печально, что django-test-extensions никто не чинит, там CI-штука даже есть (runtester).
                            0
                            А можно ещё взять django-jenkins и пусть он сам гоняет тесты и разработчиков.

                            Как это выглядит вживую: www.vimeo.com/20396959
                              0
                              Пускалка (на языке оригинала — test runner) найдет юнит- и доктесты

                              Это поведение изменилось в Django 1.6:

                              Doctests will no longer be automatically discovered. To integrate doctests in your test suite, follow the recommendations in the Python documentation

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

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