Spec-Zone.ru › Django 5.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
>>> # 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/djangotutorial/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.
        """
        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.2/intro/tutorial05/

Spec-Zone.ru

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