Spec-Zone.ru › Django 5.1

Написание вашего первого приложения 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 в будущем — нет:

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.1/intro/tutorial05/

Spec-Zone.ru

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