Написание вашего первого приложения 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 >>> from polls.models import Question >>> # create a Question instance with pub_date 30 days in the future >>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30)) >>> # was it published recently? >>> future_question.was_published_recently() True
Поскольку вещи в будущем не являются «недавними», это явно неправильно.
Создайте тест, чтобы выявить ошибку
То, что мы только что сделали в shell, чтобы проверить проблему, — это именно то, что мы можем сделать в автоматическом тесте, поэтому давайте превратим это в автоматический тест.
Традиционное место для тестов приложения находится в файле tests.py приложения; система тестирования автоматически найдёт тесты в любом файле, имя которого начинается с test.
Вставьте следующее в файл tests.py приложения polls:
polls/tests.pyimport datetime
from django.test import TestCase
from django.utils import timezone
from .models import Question
class QuestionModelTests(TestCase):
def test_was_published_recently_with_future_question(self):
"""
was_published_recently() returns False for questions whose pub_date
is in the future.
"""
time = timezone.now() + datetime.timedelta(days=30)
future_question = Question(pub_date=time)
self.assertIs(future_question.was_published_recently(), False)
Здесь мы создали подкласс django.test.TestCase с методом, который создаёт экземпляр Question с pub_date в будущем. Затем мы проверяем результат was_published_recently() — который должен быть False.
Запуск тестов
В терминале мы можем запустить наш тест:
$ python manage.py test polls
...\> py manage.py test polls
и вы увидите что-то вроде:
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
Destroying test database for alias 'default'...
Другая ошибка?
Если вместо этого вы получаете NameError, вы, возможно, пропустили шаг в Части 2, где мы добавили импорты datetime и timezone в polls/models.py. Скопируйте импорты из этого раздела и попробуйте запустить тесты ещё раз.
Что произошло:
-
manage.py test pollsискал тесты в приложенииpolls - он нашёл подкласс класса
django.test.TestCase - он создал специальную базу данных для тестирования
- он искал тестовые методы — те, имена которых начинаются с
test - в
test_was_published_recently_with_future_questionон создал экземплярQuestion, полеpub_dateкоторого находится на 30 дней в будущем - … и с помощью метода
assertIs()он обнаружил, чтоwas_published_recently()возвращаетTrue, хотя мы хотели, чтобы он возвращалFalse
Тест информирует нас о том, какой тест не прошёл, и даже о строке, в которой произошла ошибка.
Исправление ошибки
Мы уже знаем, в чём проблема: Question.was_published_recently() должен возвращать False, если его pub_date находится в будущем. Измените метод в models.py так, чтобы он возвращал только True, если дата также находится в прошлом:
polls/models.pydef 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.pydef 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'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.pyclass 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.pyfrom django.utils import timezone
и затем мы должны изменить метод get_queryset следующим образом:
polls/views.pydef 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.pyfrom django.urls import reverse
и мы создадим функцию-короткое имя для создания вопросов, а также новый класс тестов:
polls/tests.pydef 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.pyclass 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, у которого pub_date находится в будущем, не отображается:
polls/tests.pyclass 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/4.1/intro/tutorial05/