Создание вашей первой Django-приложения, часть 5
Этот учебник начинается там, где Учебник 4 закончился. Мы создали приложение Web-опросов, и теперь мы создадим для него автоматические тесты.
Введение в автоматическое тестирование
Что такое автоматические тесты?
Тесты — это простые процедуры, которые проверяют работу вашего кода.
Тестирование выполняется на разных уровнях. Некоторые тесты могут касаться мельчайших деталей (возвращает ли определенный метод модели ожидаемые значения?), в то время как другие проверяют общую работу программного обеспечения (последовательность действий пользователя на сайте приводит к желаемому результату?). Это ничем не отличается от вида тестирования, которое вы проводили ранее в Учебнике 1, используя shell для проверки поведения метода или запуская приложение и вводя данные, чтобы проверить его поведение.
Различие в автоматизированных тестах заключается в том, что система выполняет работу по тестированию за вас. Вы создаёте набор тестов один раз, а затем, когда вы вносите изменения в своё приложение, можете проверить, работает ли ваш код так, как вы изначально задумывали, не тратя время на ручное тестирование.
Зачем вам нужно создавать тесты
Итак, почему создавать тесты, и почему сейчас?
Вы можете чувствовать, что у вас достаточно работы, просто изучая Python/Django, и добавление ещё одной вещи может показаться излишним и, возможно, ненужным. В конце концов, наше приложение опросов работает вполне успешно; трата времени на создание автоматических тестов не улучшит его работу. Если создание приложения опросов — последняя задача по программированию Django, которую вы будете выполнять, то, да, вам не нужно знать, как создавать автоматические тесты. Но если это не так, сейчас — отличное время для обучения.
Тесты сэкономят вам время
До определённого момента проверка «кажется, что работает» будет удовлетворительным тестом. В более сложном приложении у вас может быть десятки сложных взаимодействий между компонентами.
Изменение любого из этих компонентов может иметь непредвиденные последствия для поведения приложения. Проверка того, что оно всё ещё «кажется рабочим», может означать прохождение функциональности вашего кода с двадцатью различными вариациями ваших тестовых данных, чтобы убедиться, что вы ничего не сломали — не рациональное использование вашего времени.
Это особенно актуально, когда автоматизированные тесты могут сделать это за секунды. Если что-то пошло не так, тесты также помогут в определении кода, вызвавшего неожиданное поведение.
Иногда может показаться, что это надоедливое занятие — отвлечься от продуктивной, творческой работы по программированию, чтобы столкнуться с непривлекательной и неинтересной задачей написания тестов, особенно когда вы знаете, что ваш код работает правильно.
Однако задача написания тестов гораздо более увлекательна, чем тратить часы на ручное тестирование вашего приложения или пытаться определить причину возникшей проблемы.
Тесты не только обнаруживают проблемы, они их предотвращают
Ошибка думать о тестах только как об отрицательном аспекте разработки.
Без тестов назначение или предполагаемое поведение приложения может быть довольно непрозрачным. Даже когда это ваш собственный код, вы иногда будете искать в нём, пытаясь понять, что именно он делает.
Тесты меняют это; они освещают ваш код изнутри, а когда что-то идет не так, они сосредотачивают свет на той части, которая вышла из строя — даже если вы и не осознавали, что она вышла из строя.
Тесты делают ваш код более привлекательным
Вы можете создать блестящий программный продукт, но вы обнаружите, что многие другие разработчики просто откажутся смотреть на него, потому что в нём нет тестов; без тестов они не будут ему доверять. Якоб Каплан-Мосс, один из первоначальных разработчиков Django, говорит: «Код без тестов по умолчанию сломан».
То, что другие разработчики хотят увидеть тесты в вашем программном обеспечении, прежде чем они его серьезно воспримут, — еще одна причина для вас начать писать тесты.
Тесты помогают командам работать вместе
Предыдущие пункты написаны с точки зрения одного разработчика, поддерживающего приложение. Сложные приложения будут поддерживаться командами. Тесты гарантируют, что коллеги не нарушают ваш код случайно (и что вы не нарушаете их код, не зная об этом). Если вы хотите зарабатывать на жизнь как программист Django, вы должны уметь писать тесты!
Основные стратегии тестирования
Существует много способов подхода к написанию тестов.
Некоторые программисты следуют методу «разработка через тестирование»; они фактически пишут свои тесты до написания кода. Это может показаться нелогичным, но на самом деле это похоже на то, что большинство людей часто делают: они описывают проблему, затем создают код для её решения. Разработка через тестирование просто формализует проблему в тестовом случае Python.
Чаще всего новичок в тестировании создаст некоторый код и позже решит, что ему нужны тесты. Возможно, было бы лучше написать тесты раньше, но начинать никогда не поздно.
Иногда сложно понять, с чего начать написание тестов. Если вы написали несколько тысяч строк Python, выбрать что-то для тестирования может быть непросто. В таком случае полезно написать свой первый тест в следующий раз, когда вы внесёте изменение, добавив новую функцию или исправив ошибку.
Итак, давайте сделаем это прямо сейчас.
Написание нашего первого теста
Мы обнаруживаем ошибку
К счастью, в приложении polls есть небольшая ошибка, которую нам нужно исправить сразу: метод Question.was_published_recently() возвращает True, если вопрос был опубликован в течение последнего дня (что правильно), а также если поле Question поля pub_date находится в будущем (чего определённо не должно быть).
Вы можете увидеть это в администрировании; создайте вопрос, дата которого находится в будущем; вы увидите, что список изменений Question утверждает, что он был опубликован недавно.
Вы также можете увидеть это с помощью 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:
import datetime
from django.utils import timezone
from django.test import TestCase
from .models import Question
class QuestionMethodTests(TestCase):
def test_was_published_recently_with_future_question(self):
"""
was_published_recently() should return False for questions whose
pub_date is in the future.
"""
time = timezone.now() + datetime.timedelta(days=30)
future_question = Question(pub_date=time)
self.assertEqual(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'...
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionMethodTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
self.assertEqual(future_question.was_published_recently(), False)
AssertionError: True != 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 дней в будущем - ... и с помощью метода
assertEqual()он обнаружил, что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'... . ---------------------------------------------------------------------- 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() should return False for questions whose
pub_date is older than 1 day.
"""
time = timezone.now() - datetime.timedelta(days=30)
old_question = Question(pub_date=time)
self.assertEqual(old_question.was_published_recently(), False)
def test_was_published_recently_with_recent_question(self):
"""
was_published_recently() should return True for questions whose
pub_date is within the last day.
"""
time = timezone.now() - datetime.timedelta(hours=1)
recent_question = Question(pub_date=time)
self.assertEqual(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, которые в противном случае были бы недоступны. Обратите внимание, что этот метод не настраивает тестовую базу данных, поэтому последующие действия будут выполняться относительно существующей базы данных, и результат может незначительно отличаться в зависимости от того, какие вопросы вы уже создали.
Далее нам нужно импортировать класс тестового клиента (позже в 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('/')
>>> # we should expect a 404 from that address
>>> 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.core.urlresolvers import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
b'\n\n\n <p>No polls are available.</p>\n\n'
>>> # note - you might get unexpected results if your ``TIME_ZONE``
>>> # in ``settings.py`` is not correct. If you need to change it,
>>> # you will also need to restart your shell session
>>> from polls.models import Question
>>> from django.utils import timezone
>>> # create a Question and save it
>>> q = Question(question_text="Who is your favorite Beatle?", pub_date=timezone.now())
>>> q.save()
>>> # check the response once again
>>> response = client.get('/polls/')
>>> response.content
b'\n\n\n <ul>\n \n <li><a href="/polls/1/">Who is your favorite Beatle?</a></li>\n \n </ul>\n\n'
>>> # If the following doesn't work, you probably omitted the call to
>>> # setup_test_environment() described above
>>> response.context['latest_question_list']
[<Question: Who is your favorite Beatle?>]
Улучшение представления
Список опросов показывает опросы, которые ещё не опубликованы (т.е. те, у которых 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.core.urlresolvers import reverse
и мы создадим функцию-обёртку для создания вопросов, а также новый класс теста:
def create_question(question_text, days):
"""
Creates a question with the given `question_text` 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 QuestionViewTests(TestCase):
def test_index_view_with_no_questions(self):
"""
If no questions exist, an appropriate message should be 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_index_view_with_a_past_question(self):
"""
Questions with a pub_date in the past should be 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_index_view_with_a_future_question(self):
"""
Questions with a pub_date in the future should not be 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.",
status_code=200)
self.assertQuerysetEqual(response.context['latest_question_list'], [])
def test_index_view_with_future_question_and_past_question(self):
"""
Even if both past and future questions exist, only past questions
should be 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_index_view_with_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_index_view_with_no_questions не создаёт вопросы, а проверяет сообщение: «Нет доступных опросов» и проверяет, что latest_question_list пусто. Обратите внимание, что класс django.test.TestCase предоставляет дополнительные методы утверждения. В этих примерах мы используем assertContains() и assertQuerysetEqual().
В test_index_view_with_a_past_question, мы создаём вопрос и проверяем, что он появляется в списке.
В test_index_view_with_a_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 QuestionIndexDetailTests(TestCase):
def test_detail_view_with_a_future_question(self):
"""
The detail view of a question with a pub_date in the future should
return a 404 not found.
"""
future_question = create_question(question_text='Future question.',
days=5)
response = self.client.get(reverse('polls:detail',
args=(future_question.id,)))
self.assertEqual(response.status_code, 404)
def test_detail_view_with_a_past_question(self):
"""
The detail view of a question with a pub_date in the past should
display the question's text.
"""
past_question = create_question(question_text='Past Question.',
days=-5)
response = self.client.get(reverse('polls:detail',
args=(past_question.id,)))
self.assertContains(response, past_question.question_text,
status_code=200)
Идеи для дополнительных тестов
Мы должны добавить аналогичный метод 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/1.8/intro/tutorial05/