Spec-Zone.ru › Django 5.0

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

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

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

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

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

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

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

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

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

Приложение опросов довольно нетребовательное: оно будет публиковать любой вопрос, включая вопросы, чье поле 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.
        """
        question = create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse("polls:index"))
        self.assertQuerySetEqual(
            response.context["latest_question_list"],
            [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.
        """
        question = 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],
        )

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

Давайте более подробно рассмотрим некоторые из них.

Первая — это функция-обёртка для вопросов, 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, чьё 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/5.0/intro/tutorial05/

Spec-Zone.ru

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