Spec-Zone.ru › Django 4.2

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

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

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

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

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

Приложение опросов довольно неразборчиво: оно опубликует любой вопрос, включая вопросы, у которых поле 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 в будущем — нет:

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/4.2/intro/tutorial05/

Spec-Zone.ru

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