Spec-Zone.ru › Django 6.0

Создание первого приложения Django, часть 5

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

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

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

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

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

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

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

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

Зачем писать тесты

Так зачем писать тесты и почему именно сейчас?

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

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

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

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

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

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

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

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

Ошибочно считать тесты лишь негативной стороной разработки.

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

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

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

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

То, что другие разработчики хотят видеть тесты в вашем программном обеспечении, прежде чем отнестись к нему серьёзно, — ещё одна причина начать писать тесты.

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

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

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

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

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

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

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

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

Пишем первый тест

Находим ошибку

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

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

Тестируем представление

Приложение для опросов довольно неприхотливо: оно публикует любые вопросы, в том числе те, поле 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()) возвращает queryset, содержащий объекты Question, у которых значение pub_date меньше или равно timezone.now(), то есть дата наступила раньше или совпадает с текущим моментом.

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

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

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

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

В какой-то момент вы наверняка посмотрите на свои тесты и задумаетесь, не страдает ли ваш код от их избытка. Это подводит нас к следующему:

В тестировании больше — лучше

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

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

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

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

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

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

Дополнительные сведения о тестировании

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

Например, наши тесты проверяли внутреннюю логику модели и то, как представления публикуют информацию. Но для проверки того, как HTML отображается в браузере, можно использовать такие инструменты тестирования «в браузере», как Selenium. С их помощью можно проверять не только поведение кода 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/6.0/intro/tutorial05/

Spec-Zone.ru

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