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

Мы создали приложение для веб-опроса (Часть 4) и теперь создадим для него несколько автоматических тестов.

Где получить помощь:

Если у вас возникли проблемы с просмотром этого учебника, перейдите в раздел Получение справки FAQ.

Представляем автоматизированное тестирование

Что такое автоматизированные тесты?

Тесты - это процедуры, которые проверяют работу вашего кода.

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

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

Почему нужно создавать тесты

Так зачем создавать тесты и почему сейчас?

Может сложиться впечатление, что вы уже освоили Python/Django и изучение ещё каких-то вещей может быть ненужным. После того, как мы завершили разработку веб-приложения для проведения опроса (и оно даже работает!), написание тестов не сделает его лучше. Если создание приложения для голосования это последнее, что вы собираетесь делать с Django, то тесты вам действительно не нужны, в ином случае приступим к изучению.

Тесты сэкономят ваше время

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

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

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

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

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

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

Ошибочно думать о тестах просто как о негативном аспекте развития.

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

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

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

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

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

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

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

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

Есть много способов подойти к написанию тестов.

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

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

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

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

Написание нашего первого теста

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

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

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

$ python manage.py shell
...\> py 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

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

Создание теста для выявления ошибки

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

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

Поместите следующий код в файл tests.py в приложении polls:

polls/tests.py
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() - который должен быть ложным.

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

В терминале мы можем запустить наш тест:

$ python manage.py test polls
...\> py 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, вы, возможно, пропустили шаг Часть 2, где мы добавили импорт datetime и timezone в polls/models.py. Скопируйте импорт из этого раздела и попробуйте снова запустить тесты.

Вот что произошло:

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

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

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

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

polls/models.py
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(); на самом деле, было бы весьма неловко, если бы при исправлении одной ошибки мы ввели другую.

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

polls/tests.py
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 - небольшое приложение, но каким бы сложным оно ни стало в будущем, и с каким бы другим кодом не взаимодействовало, у нас теперь есть некоторая гарантия того, что метод, для которого мы написали тесты, будет вести себя ожидаемым образом.

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

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

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

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

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

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

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

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

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

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

setup_test_environment() устанавливает средство визуализации шаблонов, которое позволит нам изучить некоторые дополнительные атрибуты ответов, такие как response.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&#x27;s up?</a></li>\n    \n    </ul>\n\n'
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

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

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

В Tutorial 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(). Сначала нам нужно добавить импорт:

polls/views.py
from django.utils import timezone

и затем мы должны изменить метод 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()) возвращает набор запросов, содержащий Question, чье pub_date меньше или равно - то есть раньше или равно - timezone.now.

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

Теперь вы можете убедиться, что оно ведет себя так, как ожидалось, запустив runserver, загрузив сайт в браузере, создав Questions с датами в прошлом и будущем и проверив, что только те, которые были опубликованы отображаются в списке. Вам не нужно делать это каждый раз, когда вы вносите какие-либо изменения, которые могут повлиять на это - поэтому давайте также создадим тест, основанный на нашем сеансе 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

Вроде у нас все хорошо работает; однако, даже если будущие вопросы не появятся в index, пользователи все равно смогут их найти, если знают или угадывают правильный 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())

Затем мы должны добавить несколько тестов, чтобы убедиться, что Question, чье 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 к ResultsView и создать новый тестовый класс для этого представления. Это будет очень похоже на то, что мы только что создали; на самом деле будет много повторений.

Мы также могли бы улучшить наше приложение другими способами, добавив в него тесты. Например, глупо, что Question могут публиковаться на сайте, у которого нет «Вариантов выбора». Таким образом, наши представления могут проверить это и исключить такие Question. Наши тесты должны создать Question без Choices, а затем проверить, что он не опубликован, а также создать аналогичный Question с `` Choices`` и проверить, что он опубликован.

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

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

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

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

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

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

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

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

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

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

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

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

Если у вас сложное приложение, вы можете запускать тесты автоматически с каждым коммитом в целях «непрерывной интеграции» (continuous integration), так что контроль качества сам по себе - хотя бы частично - автоматизирован.

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

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

Что дальше?

Для получения полной информации о тестировании смотрите Тестирование в Django.

Когда вы освоите тестирование представлений Django, прочитайте Часть 6 этого руководства, чтобы узнать об управлении статическими файлами.

Вернуться на верх