Spec-Zone.ru › Django 2.0

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

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

Введение в автоматическое тестирование

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

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

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

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

Почему вам нужно создавать тесты

Итак, зачем создавать тесты, и почему сейчас?

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

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

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

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

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

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

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

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

Ошибка думать о тестах только как об отрицательном аспекте разработки.

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

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

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

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

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

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

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

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

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

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

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

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

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

Написание нашего первого теста

Мы обнаруживаем ошибку

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

Чтобы проверить, действительно ли ошибка существует, используйте Админку, создайте вопрос, дата которого находится в будущем, и проверьте метод, используя 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 приложения %%%CODE_BLOCK_13%%:

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

и вы увидите что-то вроде:

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'...

Что произошло:

  • python 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, если дата также находится в прошлом:

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(); на самом деле, было бы крайне неудобно, если при исправлении одной ошибки мы ввели другую.

Добавьте два дополнительных тестовых метода в тот же класс для более всесторонней проверки поведения метода:

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:

>>> 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&#39;s up?</a></li>\n    \n    </ul>\n\n'
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

Улучшение нашего представления

Список опросов отображает опросы, которые ещё не опубликованы (т. е. те, у которых pub_date в будущем). Исправим это.

В Учебнике 4 мы представили представление на основе класса, основанное на ListView:

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(). Сначала нам нужно добавить импорт:

from django.utils import timezone

а затем нам нужно изменить метод get_queryset следующим образом:

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:

from django.urls import reverse

и мы создадим функцию-обёртку для создания вопросов, а также новый класс теста:

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.
        """
        create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past 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.
        """
        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: Past question.>']
        )

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

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

Сначала это функция-обёртка вопроса, create_question, чтобы избежать повторений при создании вопросов.

test_no_questions не создаёт никаких вопросов, но проверяет сообщение: «Доступны опросы.» и проверяет, что latest_question_list пусто. Обратите внимание, что класс django.test.TestCase предоставляет дополнительные методы утверждения. В этих примерах мы используем assertContains() и assertQuerysetEqual().

В test_past_question, мы создаём вопрос и проверяем, что он появляется в списке.

В test_future_question, мы создаём вопрос с pub_date в будущем. База данных сбрасывается для каждого тестового метода, поэтому первый вопрос больше не существует, и поэтому индекс снова не должен содержать вопросов.

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

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

Всё работает хорошо; однако, даже если опросы в будущем не отображаются в списке, пользователи всё ещё могут получить к ним доступ, если знают или угадывают правильный URL-адрес. Поэтому нам нужно добавить аналогичное ограничение к DetailView:

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 находится в будущем, нет:

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

Spec-Zone.ru

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