Написание вашего первого приложения Django, часть 5
Этот учебник начинается там, где закончился Урок 4. Мы создали веб-приложение для опросов, и теперь мы создадим для него автоматические тесты.
Введение в автоматическое тестирование
Что такое автоматические тесты?
Тесты — это простые процедуры, которые проверяют работу вашего кода.
Тестирование выполняется на разных уровнях. Некоторые тесты могут относиться к мельчайшим деталям (возвращает ли определенный метод модели ожидаемые значения?), а другие проверяют общую работу программного обеспечения (последовательность действий пользователя на сайте приводит к желаемому результату?). Это ничем не отличается от тестирования, которое вы проводили ранее в Уроке 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.
"""
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для каждой модели или представления - отдельный метод теста для каждого набора условий, которые вы хотите проверить
- имена методов теста, которые описывают их функцию
Дополнительное тестирование
В этом руководстве представлены лишь некоторые основы тестирования. Вы можете сделать гораздо больше, и у вас есть ряд очень полезных инструментов для достижения весьма умных результатов.
END_OF_DOCUMENT_MARKERНапример, хотя наши тесты здесь охватывают часть внутренней логики модели и способ, которым наши представления публикуют информацию, вы можете использовать фреймворк «в браузере», такой как 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.2/intro/tutorial05/