Написание вашей первой 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:
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/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 только в том случае, если дата также находится в прошлом:
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:
$ 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:
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.
"""
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:
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/3.2/intro/tutorial05/