Spec-Zone.ru › Django 3.0

Написание вашей первой Django-приложения, часть 5

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

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

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

Введение в автоматизированное тестирование

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Существует множество способов подхода к написанию тестов.

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

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

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

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

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

Мы обнаруживаем ошибку

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

Подтвердите ошибку, используя 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() — который должен быть False.

Выполнение тестов

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

$ 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 находится в будущем). Давайте исправим это.

В Учебнике 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

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

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

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

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

Когда больше тестов — лучше

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

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

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

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

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

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

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

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

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

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

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

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

Что дальше?

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

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

© Django Software Foundation and individual contributors
Licensed under the BSD License.
https://docs.djangoproject.com/en/3.0/intro/tutorial05/

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API