Создание первого Django приложения, часть 5

Это часть руководства начинается с места, гда закончилась 4я часть. Мы построили веб приложение голосовалки, и сейчас создадим некоторые автоматические тесты для него.

Введение в автоматические тесты

Что такое автоматические тесты?

Тесты — это простые процедуры, которые проверяют работу кода

Тестирование работает на нескольких уровнях. Одни тесты могут относится к мелким деталям (проверяет корректность возвращаемого значения конкретного метода) тогда как другие проверяют общую работу программного обеспечения (дает ли последовательность пользовательских входов на сайт желаемый результат). Это ничем не отличается от тестирования, показанного ранее в главе 2, с использованием командной строки Python для проверки поведения методов, или запуска приложения и ввода данных для проверки поведения.

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

Почему необходимо создавать тесты?

Итак, почему нужно создавать тесты, и почему сейчас?

Можно подумать, что достаточно просто изучения Python/Django, и необходимоть изучения дополнительного инструмента может удивить показаться не нужной. В конце концов, наше приложение работает довольно хорошо и создание дополнительных тестов не улучшит его работу. Если создание учебного приложения для опросов это все, что вы изучите, и больше не будете программировать с Django то тогда да, вам не нужно знать как создавать автоматические тесты. Но если это не так, то сейчас самое время учиться.

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

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

Тесты не только выявляют, но и предотвращают проблемы.

Ошибочно считать, будто бы тесты являются негативной стороной разработки.

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

Тести же меняют это: Они словно освещают код изнутри и когда что то идет не так, они фокусируются на той части кода, которая работает не по плану еще до того, как вы могли бы понять, что программа работает не так.

Тесты делают код более привлекательным

Возможно, вы создали блестящее программное обеспечение, но вы обнаружите что другие разработчики просто откажутся смотреть его из за того, что в нем нет тестов; без тестов к коду не будет доверия. Джейком Капла-Мосс, один из первых разработчиков Django, говорит «У кода без тестов нарушен дизайн»

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

Тесты помогают командной работе

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

Базовые стратегии тестирования

Есть много способов написания тестов.

Некоторые программисты следуют дисциплине, называемой «Разработка через тестирование». Тесты они пишут до того, как напишут свой код. Это может показаться нелогичным, но на самом деле это сродни тому, что большинство делают в любом случае: Они описывают проблему, затем реализуют код, который решает её. Разработка через тестирование просто формализует проблему в разделе тестирования Python.

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

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

Итак, давайте сделаем это прямо сейчас.

Пишем первый тест

Идентифицируем ошибку

К счастью, приложение для опросов есть небольшая ошибка, которую мы должны исправить: Метод Question.was_published_recently() возвращает True, если вопрос опубликован в течении дня (что верно), однако, если значение поля pub_date находится в будущем, возвращается то же самое (что не верно)

Используйте оболочку, что бы проверить ошибку на вопросе, дата которого находится в будущем:

$ python manage.py shell
>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # create a Question instance with pub_date 30 days in the future
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # was it published recently?
>>> future_question.was_published_recently()
True

Поскольку события в будущем не являются «недавними», это явно не верно.

Пишем тест для выявления ошибки

То, что мы сделали в оболочке — ровно то, что мы можем сделать в автоматическом режиме тестирования, поэтому, давайте превратим это в автоматический тест

Обычное место для тестов приложения — файл tests.py. Система тестирования автоматически найдет все тесты в файлах, имена которых начинаются с test.

Вставьте следующий код в файл tests.py приложнеия pools.

import datetime

from django.test import TestCase
from django.utils import timezone

from .models import Question


class QuestionModelTests(TestCase):

    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently() returns False for questions whose pub_date
        is in the future.
        """
        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertIs(future_question.was_published_recently(), False)

Мы создали подкласс для django.test.TestCase с методом, создающим экземпляр Question со значением pub_date в будущем. Мы проверяем вывод was_published_recently(), который должен быть False

Запуск тестов

Запустить тесты мы можем в терминале:

$ python manage.py test polls

и увидеть сообщение похожее на:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
    self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)
Destroying test database for alias 'default'...

Другая ошибка?
Если вместо этого вы получаете здесь NameError, возможно, вы пропустили шаг во второй части, где мы добавили импорт datetime и timezone в файл polls/models.py. Скопируйте импорт из этого раздела и попробуйте снова запустить тесты.

Что здесь происходит?

  • manage.py test polls ищет тесты в приложении polls
  • Находит подкласс класса django.test.TestCase
  • Для тестирования создается своя базаданных
  • В функции test_was_published_recently_with_future_question создается экземпляр Question, значение даты которого устанавливается в будущем на 30 дней от текущей даты.
  • … и с использованием метода assertIs() получается True, хотя ожидалось False

Тест информирует нас о том, какой конкретно тест не пройден и указывает строку, в которой произошел сбой.

Исправление ошибки

Теперь мы значем в чем проблема: Question.was_published_recently() должен быть вернуть False в случае, если значение pub_date находится в будущем. Измените метод в pub_date так, что бы он возвращал True для дат, находящихся в прошлом.

def was_published_recently(self):
    now = timezone.now()
    return now - datetime.timedelta(days=1) <= self.pub_date <= now

и запустите тест снова

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

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

После выявление ошибки мы написали тест, который выявил ее а так же исправили ошибку кода, что бы тест прошел

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

Более комплексные тесты

Пока мы здесь, мы можем дополнительно укрепить метод was_published_recently(): На самом деле, было бы весьма неловко, если бы при исправлении одной ошибки мы ввели другую.

Добавим еще два метода тестирования к тому же классу, что бы более широко протестировать поведение метода:

def test_was_published_recently_with_old_question(self):
    """
    was_published_recently() returns False for questions whose pub_date
    is older than 1 day.
    """
    time = timezone.now() - datetime.timedelta(days=1, seconds=1)
    old_question = Question(pub_date=time)
    self.assertIs(old_question.was_published_recently(), False)

def test_was_published_recently_with_recent_question(self):
    """
    was_published_recently() returns True for questions whose pub_date
    is within the last day.
    """
    time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
    recent_question = Question(pub_date=time)
    self.assertIs(recent_question.was_published_recently(), True)

И теперь у нас есть три теста, которые подтверждают, что Question.was_published_recently() возвращает разумные значения для прошедших, недавних и будущих запросов.

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

Тестирование представлений

Приложение опросов довольно неразборчиво. Оно будет публиковать все вопросы, включая те, для которых значение поля pub_date лежит в будущем. Мы должны исправить это. Установка pub_date в будущем что вопрос будет опубликован в тот момент, но не виден до тех пор.

Тест для представления

Когда мы исправляли ошибку выше, мы сначала писали тест, а затем код для исправления ошибки. На самом деле это был пристой пример разработки на основе тестов, и не имеет значения, в каком порядке мы выполним эту работу

В первом тесте мы сосредоточились на внутреннем поведени кода. Этим же тестом мы хотим проверить то, как будет восприниматься поведение пользователем через web-браузер.

Прежде, чем пытаться что-то исправить, давайте посмотрим на инструменты, имеющиеся в нашем распоряжении.

Тестовый клиент Django

Djnago предоставляет тестовый класс Client, для имитации взаимодействия пользователя с кодом на уровне представления. Мы можем использовать его в файле tests.py или даже в оболочке shell

Вновь, мы начнем с оболочки shell, где нам нужно сделать пару вещей, которые не понадобятся в tests.py. Первое — настроить тестовую среду:

$ python manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

setup_test_environment() устанавливает средство визуализации шаблонов, которое позволит нам проверить некоторые дополнительные атрибуты в ответах, такие как respnse.context, которые в противном случае были бы не доступны. Обратите внимание, что этот метод не устанавливает тестовую базу данных, так что последующие действия будут выполнены для существующей базы данных и их результат может немного отличаться в зависимости от того, какие вопросы вы уже создали. Так же Вы можете получить неожиданные результаты, если ваш TIME_ZONE в settings.py не верен. Проверьте эту настройку, прежде чем продолжить.

Затем нам нужно импортировать тестовый клиентский класс (позже в tests.py мы будем использовать класс django.test.TestCase, который поставляется со своим собственным клиентом, поэтому там это не требуется):

>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = Client()

Псое этого мы можем попросить клиента сделать для нас некоторую работу:

>>> # get a response from '/'
>>> response = client.get('/')
Not Found: /
>>> # we should expect a 404 from that address; if you instead see an
>>> # "Invalid HTTP_HOST header" error and a 400 response, you probably
>>> # omitted the setup_test_environment() call described earlier.
>>> response.status_code
404
>>> # on the other hand we should expect to find something at '/polls/'
>>> # we'll use 'reverse()' rather than a hardcoded URL
>>> from django.urls import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
b'\n    <ul>\n    \n        <li><a href="/polls/1/">What's up?</a></li>\n    \n    </ul>\n\n'
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

Улучшение нашего представления

Список опросов возвращает опросы, которые еще не опубликованы (т.е. те, у которых значение pub_date находится в будущем). Давайте исправим это:

В уроке 4 мы предоставили представление, унаследованое от класса ListView:

#polls/views.py
class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_question_list'

    def get_queryset(self):
        """Return the last five published questions."""
        return Question.objects.order_by('-pub_date')[:5]

Нам нужно изменить методе get_queryset() т.о., что бы он проверял дату, сравнивая ее с timezone.now(). Сначал нужно добавить import


затем мы можем изменить метод get_queryset следующим образом:

#polls/views.py
def get_queryset(self):
    """
    Return the last five published questions (not including those set to be
    published in the future).
    """
    return Question.objects.filter(
        pub_date__lte=timezone.now()
    ).order_by('-pub_date')[:5]

Question.objects.filter(pub_date__lte = timezone.now()) возвращает набор, содержащий вопросы, где pub_date меньше или равна, то есть раньше или равна timezone.now.

Тестирование нового представления

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

Добавьте следующее в polls/tests.py:

# polls/tests.py
from django.urls import reverse

и создадим небольшую функцию создания вопросов, а так же новый тестовый класс:

# polls/tests.py
def create_question(question_text, days):
    """
    Create a question with the given `question_text` and published the
    given number of `days` offset to now (negative for questions published
    in the past, positive for questions that have yet to be published).
    """
    time = timezone.now() + datetime.timedelta(days=days)
    return Question.objects.create(question_text=question_text, pub_date=time)


class QuestionIndexViewTests(TestCase):
    def test_no_questions(self):
        """
        If no questions exist, an appropriate message is displayed.
        """
        response = self.client.get(reverse('polls:index'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_past_question(self):
        """
        Questions with a pub_date in the past are displayed on the
        index page.
        """
        create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question.>']
        )

    def test_future_question(self):
        """
        Questions with a pub_date in the future aren't displayed on
        the index page.
        """
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        are displayed.
        """
        create_question(question_text="Past question.", days=-30)
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question.>']
        )

    def test_two_past_questions(self):
        """
        The questions index page may display multiple questions.
        """
        create_question(question_text="Past question 1.", days=-30)
        create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question 2.>', '<Question: Past question 1.>']
        )

Давайте рассмотрим некоторые части более внимательно.

Первое — это функция create_question созданная для того, что бы вывести некоторые повторения из процесса создания вопросов.

test_no_questions не создает никаких вопросов, но проверяет сообщение: «Нет доступных опросов» и проверяет, что latest_question_list пуст. Обратите внимание, что класс django.test.TestCase предоставляет несколько дополнительных методов утверждения. В этих примерах мы используем assertContains() и assertQuerysetEqual().

В test_past_question мы создаем вопрос и проверяем, что он появляется в списке.

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

И так далее. По сути, мы используем тесты, чтобы рассказать историю ввода администратора и взаимодействия с пользователем на сайте, и проверяем, что при каждом состоянии и при каждом новом изменении состояния системы публикуются ожидаемые результаты.

Тестирование DetailView

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

# polls/views.py
class DetailView(generic.DetailView):
    ...
    def get_queryset(self):
        """
        Excludes any questions that aren't published yet.
        """
        return Question.objects.filter(pub_date__lte=timezone.now())

И кончено же добавим несколько тестов что бы убедиться, что вопрос, значение pub_date которого находится в прошлом, будет отображен, а тот для которого pub_date задано в будущем отображен не будет:

# polls/tests.py
class QuestionDetailViewTests(TestCase):
    def test_future_question(self):
        """
        The detail view of a question with a pub_date in the future
        returns a 404 not found.
        """
        future_question = create_question(question_text='Future question.', days=5)
        url = reverse('polls:detail', args=(future_question.id,))
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)

    def test_past_question(self):
        """
        The detail view of a question with a pub_date in the past
        displays the question's text.
        """
        past_question = create_question(question_text='Past Question.', days=-5)
        url = reverse('polls:detail', args=(past_question.id,))
        response = self.client.get(url)
        self.assertContains(response, past_question.question_text)

Идеи для других тестов

Мы должны добавить метод, аналогичный get_queryset в ResultView и создать новый тестовый класс для этого представления. Это будет очень похоже на то что мы сделали выше; На самом деле будет много повторений

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

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

В какой то момет вы обязательно взглянете на свои тесты и зададитесь вопросом, не страдает ли программный код от раздутых тестов, что приводит на к тому, что

При тестировании — чем больше, тем лучше

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

Это не имеет значения. Пусть они растут. По большей части вы можете написать тест один раз, а затем забыть о нем. Он продолжит выполнять свою полезную функцию, пока вы продолжаете развивать свою программу.

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

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

Пока ваши тесты разумно организованы, они не станут неуправляемыми. Хорошие практические правила включают наличие:

  • отдельный TestClass для каждой модели или представления
  • отдельный метод тестирования для каждого набора условий, которые вы хотите проверить
  • Именование методов тестов в соответсвии с их функцией

Дальнейшее тестирование

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

Например, в то время как наши тесты здесь охватили некоторую внутреннюю логику модели и то, как наши представления публикуют информацию, вы можете использовать инфраструктуру «в браузере», такую ​​как Selenium, чтобы проверить, как ваш HTML действительно отображается в браузере. Эти инструменты позволяют вам проверять не только поведение вашего кода Django, но и, например, ваш JavaScript. Совершенно очевидно, что тесты запускают браузер и начинают взаимодействовать с вашим сайтом, как если бы его управлял человек! Django включает LiveServerTestCase для облегчения интеграции с такими инструментами, как Selenium.

Если у вас сложное приложение, вы можете запускать тесты автоматически с каждым коммитом в целях непрерывной интеграции, так что контроль качества сам по себе — хотя бы частично — автоматизирован.

Хороший способ обнаружить непроверенные части вашего приложения — проверить покрытие кода. Это также помогает идентифицировать хрупкий или даже мертвый код. Если вы не можете протестировать кусок кода, это обычно означает, что код должен быть реорганизован или удален. Покрытие поможет идентифицировать мертвый код. См. Интеграция с cover.py для подробностей.

Тестирование в Django содержит исчерпывающую информацию о тестировании.

Закладка Постоянная ссылка.